Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions cmd/caib/buildcmd/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,8 @@ func (h *Handler) waitForBuildCompletion(ctx context.Context, api *buildapiclien
lastPhase == phaseFlashing

handleErr := fmt.Errorf("%s", st.Message)
// Only show push/flash results when an image was actually produced.
hasImage := st.DiskImage != "" || st.ContainerImage != ""
if hasImage && (isFlashFailure || *h.opts.FlashAfterBuild) {
if hasImage && isFlashFailure {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for tests covering the waitForBuildCompletion failure path

# Find test files for buildcmd package
fd -e go -t f 'test' cmd/caib/buildcmd

# Search for tests that cover the phaseFailed or failure scenarios
rg -n -A5 -B5 'phaseFailed|TestWaitForBuildCompletion.*[Ff]ail' --type=go --glob 'cmd/caib/buildcmd/*test*.go'

# Look for tests covering hasImage and isFlashFailure conditions
rg -n 'hasImage|isFlashFailure' --type=go --glob 'cmd/caib/buildcmd/*test*.go'

Repository: centos-automotive-suite/automotive-dev-operator

Length of output: 205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== waitForBuildCompletion in logs.go =="
rg -n "func waitForBuildCompletion" -S cmd/caib/buildcmd/logs.go
rg -n "hasImage|isFlashFailure|phaseFailed|FlashAfterBuild|flash" -S cmd/caib/buildcmd/logs.go

echo
echo "== Test files in cmd/caib/buildcmd =="
fd -e go -t f 'test' cmd/caib/buildcmd | sort

echo
echo "== Search for relevant test coverage =="
# Search for waitForBuildCompletion / phaseFailed / flash-related messaging in tests
rg -n -S "waitForBuildCompletion|phaseFailed|FlashAfterBuild|isFlashFailure|hasImage|flash" --type=go cmd/caib/buildcmd/*test*.go || true

echo
echo "== Show any tests mentioning 'flash' in buildcmd tests =="
rg -n -S "flash" --type=go cmd/caib/buildcmd/*test*.go || true

echo
echo "== Show any tests that mention 'DiskImage' or 'ContainerImage' =="
rg -n -S "DiskImage|ContainerImage" --type=go cmd/caib/buildcmd/*test*.go || true

Repository: centos-automotive-suite/automotive-dev-operator

Length of output: 131


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "== Locate relevant code in logs.go =="
# Show the relevant area around where phaseFailed handling likely lives
rg -n "phaseFailed|FlashAfterBuild|flash" -S cmd/caib/buildcmd/logs.go || true

# Try to find the function by looking for nearby identifiers (safer than exact func name)
rg -n "waitFor|Completion|BuildCompletion|phaseFailed" -S cmd/caib/buildcmd/logs.go || true

# Print the exact section around the line numbers mentioned in the review comment
# (169 was the changed line; include a wider window)
sed -n '130,210p' cmd/caib/buildcmd/logs.go

echo
echo "== Test files in cmd/caib/buildcmd =="
fd -e go -t f 'test' cmd/caib/buildcmd | sort

echo
echo "== Search for flash/phaseFailed coverage in tests =="
rg -n -S "phaseFailed|FlashAfterBuild|isFlashFailure|hasImage|flash" --type=go cmd/caib/buildcmd/*test*.go || true

echo
echo "== Search for any tests calling the logs/wait logic =="
rg -n -S "waitFor|logs.go|buildcmd/logs" --type=go cmd/caib/buildcmd/*test*.go || true

Repository: centos-automotive-suite/automotive-dev-operator

Length of output: 4353


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Inspect build_disk_test.go for phaseFailed/flash assertions =="
sed -n '1,220p' cmd/caib/buildcmd/build_disk_test.go

echo
echo "== Search within tests for flash-related output/assertions =="
rg -n -S "phaseFailed|flash was not executed|Warning: --flash|Build and flash completed successfully|lease has been acquired|displayBuildResults|handleFlashError|displayFlashInstructions|JumpstarterTargetMappings|errPrefixFlash|failed" cmd/caib/buildcmd/*test*.go

echo
echo "== Search within tests for DiskImage/ContainerImage population on failure stubs =="
rg -n -S "DiskImage|ContainerImage|Message:|Phase:|ImageBuildPhase|phaseFlashing|phaseFailed|errPrefixFlash" cmd/caib/buildcmd/*test*.go

Repository: centos-automotive-suite/automotive-dev-operator

Length of output: 6974


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== File sizes =="
wc -l cmd/caib/buildcmd/logs.go cmd/caib/buildcmd/build_disk_test.go cmd/caib/buildcmd/build_define_test.go cmd/caib/buildcmd/build_creds_test.go

echo
echo "== Remainder of build_disk_test.go =="
sed -n '220,500p' cmd/caib/buildcmd/build_disk_test.go

echo
echo "== Search all buildcmd tests for flash/error handling assertions =="
rg -n -S "handleFlashError|displayFlashInstructions|errPrefixFlash|flashWasExecuted|flash failure|Build and flash|Warning: --flash|To release the lease|Check OperatorConfig|phaseFlashing|phaseFailed" cmd/caib/buildcmd/*test*.go || true

echo
echo "== Search all buildcmd tests for st.Phase == phaseFailed or errPrefixFlash usage =="
rg -n -S "st\.Phase\s*==\s*phaseFailed|errPrefixFlash|phaseFlashing|flashAfterBuild" cmd/caib/buildcmd/*test*.go || true

echo
echo "== Search for any mock build API returning phases/images =="
rg -n -S "buildapiclient|Stream|Get.*Build|Phase|DiskImage|ContainerImage|phasePending|phaseCancelled" cmd/caib/buildcmd/*test*.go || true

echo
echo "== Quick peek: build_define_test.go =="
sed -n '1,240p' cmd/caib/buildcmd/build_define_test.go

echo
echo "== Quick peek: build_creds_test.go =="
sed -n '1,260p' cmd/caib/buildcmd/build_creds_test.go

Repository: centos-automotive-suite/automotive-dev-operator

Length of output: 4990


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== cat -n build_disk_test.go =="
cat -n cmd/caib/buildcmd/build_disk_test.go

echo
echo "== rg for phaseFailed / handleFlashError / displayBuildResults in tests =="
rg -n -S "phaseFailed|handleFlashError|displayBuildResults|displayFlashInstructions|errPrefixFlash|FlashAfterBuild" cmd/caib/buildcmd/*test*.go || true

echo
echo "== rg for hasImage / isFlashFailure in tests =="
rg -n -S "hasImage|isFlashFailure" cmd/caib/buildcmd/*test*.go || true

Repository: centos-automotive-suite/automotive-dev-operator

Length of output: 8112


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Repo-wide searches in *_test.go for flash failure path identifiers =="
rg -n -S "waitForBuildCompletion" --type=go --glob '**/*_test.go' . || true
rg -n -S "handleFlashError|displayBuildResults|displayFlashInstructions|errPrefixFlash|phaseFlashing|isFlashFailure|hasImage && isFlashFailure|hasImage.*isFlashFailure" --type=go --glob '**/*_test.go' . || true

Repository: centos-automotive-suite/automotive-dev-operator

Length of output: 254


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for CLI flash error handlers in tests =="
rg -n -S "handleFlashError|displayBuildResults|displayFlashInstructions|errPrefixFlash" --type=go --glob '**/*_test.go' . || true

echo
echo "== Search for the specific tightened condition components in tests =="
rg -n -S "hasImage\s*&&\s*isFlashFailure|isFlashFailure|errPrefixFlash|phaseFailed.*(flash|Flash)|phaseFlashing" --type=go --glob '**/*_test.go' . || true

echo
echo "== Search for waitForBuildCompletion usage in tests =="
rg -n -S "waitForBuildCompletion" --type=go --glob '**/*_test.go' . || true

echo
echo "== Any tests under cmd/caib/buildcmd besides *_test.go already found? =="
fd -e go -t f '*_test.go' cmd/caib/buildcmd | sort

Repository: centos-automotive-suite/automotive-dev-operator

Length of output: 864


Add tests covering flash-specific phaseFailed handling (hasImage && isFlashFailure)
cmd/caib/buildcmd/logs.go gates the flash-error path in waitForBuildCompletion with hasImage := st.DiskImage != "" || st.ContainerImage != "" and isFlashFailure := ..., then calls displayBuildResults + handleFlashError only when hasImage && isFlashFailure. Current tests under cmd/caib/buildcmd/*_test.go don’t exercise the phaseFailed branch or assert any flash error/results output (no coverage of waitForBuildCompletion, handleFlashError, or displayBuildResults).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/caib/buildcmd/logs.go` at line 169, Add unit tests that exercise the
flash-specific branch in waitForBuildCompletion by constructing build status
states where hasImage (set DiskImage or ContainerImage on the status) and
isFlashFailure (simulate a phaseFailed flash error) are true; invoke
waitForBuildCompletion (or the minimal exported function that calls it) and
assert that displayBuildResults and handleFlashError behaviors occur
(capture/log output or use test doubles/mocks for displayBuildResults and
handleFlashError) and verify the flash error message/output is produced; focus
tests on the phaseFailed scenario to ensure coverage for the hasImage &&
isFlashFailure path.

h.displayBuildResults(ctx, api, name)
h.handleFlashError(handleErr, st)
} else {
Expand Down
280 changes: 280 additions & 0 deletions cmd/caib/buildcmd/logs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
package buildcmd

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi"
buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client"
)

func newTestOpts() Options {
opts := newTestDiskOpts()
var (
workspace string
extraRepos []string
flashCmd string
exporterSelector string
)
opts.Workspace = &workspace
opts.ExtraRepos = &extraRepos
opts.FlashCmd = &flashCmd
opts.ExporterSelector = &exporterSelector
return opts
}

// fakeBuildServer creates an httptest.Server that responds to /v1/builds/<name>
// with the given BuildResponse sequence. Each call to GetBuild returns the next
// response; once exhausted it repeats the last one. Progress endpoint returns 404.
func fakeBuildServer(t *testing.T, responses []buildapitypes.BuildResponse) *httptest.Server {
t.Helper()
callIdx := 0
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/progress") {
w.WriteHeader(http.StatusNotFound)
return
}
idx := callIdx
if idx < len(responses)-1 {
callIdx++
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(responses[idx]); err != nil {
t.Errorf("failed to encode response: %v", err)
}
}))
}

func TestWaitForBuildCompletion_BuildFailedNoDiskImage(t *testing.T) {
responses := []buildapitypes.BuildResponse{
{
Name: "test-build",
Phase: "Failed",
Message: `"step-build-image" exited with code 1`,
},
}
srv := fakeBuildServer(t, responses)
defer srv.Close()

opts := newTestOpts()
*opts.ServerURL = srv.URL
timeout := 1
opts.Timeout = &timeout

var capturedErr error
opts.HandleError = func(err error) { capturedErr = err }
h := NewHandler(opts)

api, err := buildapiclient.New(srv.URL)
if err != nil {
t.Fatalf("failed to create client: %v", err)
}

// Capture stdout to verify no flash instructions printed
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

waitErr := h.waitForBuildCompletion(t.Context(), api, "test-build")

_ = w.Close()
out, _ := io.ReadAll(r)
os.Stdout = old

if waitErr == nil {
t.Fatal("expected error from waitForBuildCompletion")
}

output := string(out)
if strings.Contains(output, "Flash failed") {
t.Errorf("should not show flash instructions when build itself failed, got: %s", output)
}
if strings.Contains(output, "flash manually") {
t.Errorf("should not show manual flash guidance when build failed, got: %s", output)
}
if capturedErr == nil {
t.Fatal("expected HandleError to be called")
}
if !strings.Contains(capturedErr.Error(), "step-build-image") {
t.Errorf("error should contain build failure message, got: %v", capturedErr)
}
}

func TestWaitForBuildCompletion_BuildFailedWithDiskImage_NotFlashFailure(t *testing.T) {
// Edge case: server returns DiskImage on a build failure (shouldn't happen
// with server fix, but tests client-side defense-in-depth).
responses := []buildapitypes.BuildResponse{
{
Name: "test-build",
Phase: "Failed",
Message: `"step-build-image" exited with code 1`,
DiskImage: "registry.example.com/img:disk",
},
}
srv := fakeBuildServer(t, responses)
defer srv.Close()

opts := newTestOpts()
*opts.ServerURL = srv.URL
*opts.FlashAfterBuild = true
timeout := 1
opts.Timeout = &timeout

var capturedErr error
opts.HandleError = func(err error) { capturedErr = err }
h := NewHandler(opts)

api, err := buildapiclient.New(srv.URL)
if err != nil {
t.Fatalf("failed to create client: %v", err)
}

old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

waitErr := h.waitForBuildCompletion(t.Context(), api, "test-build")

_ = w.Close()
out, _ := io.ReadAll(r)
os.Stdout = old

if waitErr == nil {
t.Fatal("expected error from waitForBuildCompletion")
}

output := string(out)
// Even though DiskImage is set and FlashAfterBuild is true, the message
// does not indicate a flash failure, so no flash instructions should appear.
if strings.Contains(output, "Flash failed") {
t.Errorf("should not show flash instructions for build failure (not flash failure), got: %s", output)
}
if capturedErr == nil {
t.Fatal("expected HandleError to be called")
}
}

func TestWaitForBuildCompletion_FlashFailure_ShowsFlashInstructions(t *testing.T) {
responses := []buildapitypes.BuildResponse{
{
Name: "test-build",
Phase: "Failed",
Message: "Flash to device failed: timeout waiting for device",
DiskImage: "registry.example.com/ns/test-build:disk",
Jumpstarter: &buildapitypes.JumpstarterInfo{
Available: true,
ExporterSelector: "board-type=renesas-rcar-s4,enabled=true",
FlashCmd: "j storage flash oci://registry.example.com/ns/test-build:disk",
},
},
}
srv := fakeBuildServer(t, responses)
defer srv.Close()

opts := newTestOpts()
*opts.ServerURL = srv.URL
*opts.FlashAfterBuild = true
timeout := 1
opts.Timeout = &timeout

var capturedErr error
opts.HandleError = func(err error) { capturedErr = err }
h := NewHandler(opts)

api, err := buildapiclient.New(srv.URL)
if err != nil {
t.Fatalf("failed to create client: %v", err)
}

old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

waitErr := h.waitForBuildCompletion(t.Context(), api, "test-build")

_ = w.Close()
out, _ := io.ReadAll(r)
os.Stdout = old

if waitErr == nil {
t.Fatal("expected error from waitForBuildCompletion")
}

output := string(out)
if !strings.Contains(output, "Flash failed") {
t.Errorf("expected flash failure message, got: %s", output)
}
if !strings.Contains(output, "flash manually") {
t.Errorf("expected manual flash guidance, got: %s", output)
}
if !strings.Contains(output, "j storage flash") {
t.Errorf("expected flash command in output, got: %s", output)
}
if capturedErr == nil {
t.Fatal("expected HandleError to be called")
}
if !strings.Contains(capturedErr.Error(), "Flash to device failed") {
t.Errorf("error should contain flash failure message, got: %v", capturedErr)
}
}

func TestWaitForBuildCompletion_FlashFailure_NoJumpstarter(t *testing.T) {
// Flash failure detected but no Jumpstarter info — should still call
// handleFlashError (which calls handleError) but not print flash instructions.
responses := []buildapitypes.BuildResponse{
{
Name: "test-build",
Phase: "Failed",
Message: "Flash to device failed: connection refused",
DiskImage: "registry.example.com/ns/test-build:disk",
},
}
srv := fakeBuildServer(t, responses)
defer srv.Close()

opts := newTestOpts()
*opts.ServerURL = srv.URL
*opts.FlashAfterBuild = true
timeout := 1
opts.Timeout = &timeout

var capturedErr error
opts.HandleError = func(err error) { capturedErr = err }
h := NewHandler(opts)

api, err := buildapiclient.New(srv.URL)
if err != nil {
t.Fatalf("failed to create client: %v", err)
}

old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

waitErr := h.waitForBuildCompletion(t.Context(), api, "test-build")

_ = w.Close()
out, _ := io.ReadAll(r)
os.Stdout = old

if waitErr == nil {
t.Fatal("expected error from waitForBuildCompletion")
}

output := string(out)
// No Jumpstarter info, so no "flash manually" instructions should appear
if strings.Contains(output, "flash manually") {
t.Errorf("should not show flash instructions without Jumpstarter info, got: %s", output)
}
if capturedErr == nil {
t.Fatal("expected HandleError to be called")
}
if !strings.Contains(capturedErr.Error(), "Flash to device failed") {
t.Errorf("error should contain flash failure message, got: %v", capturedErr)
}
}
29 changes: 25 additions & 4 deletions internal/buildapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ const (
maxManifestSize = 900 * 1024
)

// buildProducedArtifacts reports whether artifact URLs point to real objects.
// For Failed builds, only true when flash was attempted (proving push succeeded).
func buildProducedArtifacts(build *automotivev1alpha1.ImageBuild) bool {
switch build.Status.Phase {
case phaseCompleted, phaseFlashing:
return true
case phaseFailed:
return build.Status.FlashTaskRunName != ""
default:
return false
}
}

var getClientFromRequestFn = getClientFromRequest
var getRESTConfigFromRequestFn = getRESTConfigFromRequest
var createInternalRegistrySecretFn = createInternalRegistrySecret
Expand Down Expand Up @@ -1330,8 +1343,11 @@ func listBuilds(c *gin.Context) {
compStr = b.Status.CompletionTime.Format(time.RFC3339)
}

containerImage := b.Spec.GetContainerPush()
diskImage := b.Spec.GetExportOCI()
var containerImage, diskImage string
if buildProducedArtifacts(&b) {
containerImage = b.Spec.GetContainerPush()
diskImage = b.Spec.GetExportOCI()
}
if b.Spec.GetUseServiceAccountAuth() && externalRoute != "" {
if containerImage != "" {
containerImage = translateToExternalURL(containerImage, externalRoute)
Expand Down Expand Up @@ -1369,8 +1385,13 @@ func (a *APIServer) getBuild(c *gin.Context, name string) {
return
}

containerImage := build.Spec.GetContainerPush()
diskImage := build.Spec.GetExportOCI()
// Only report artifact URLs when the build progressed past the Building
// phase — otherwise no image was produced and the URLs are misleading.
var containerImage, diskImage string
if buildProducedArtifacts(build) {
containerImage = build.Spec.GetContainerPush()
diskImage = build.Spec.GetExportOCI()
}
var warning string

if build.Spec.GetUseServiceAccountAuth() {
Expand Down
25 changes: 25 additions & 0 deletions internal/buildapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -689,4 +689,29 @@ var _ = Describe("APIServer Performance", func() {

Expect(w.Code).To(Equal(http.StatusOK))
})

Context("buildProducedArtifacts", func() {
DescribeTable("returns correct result for each phase",
func(phase string, pushTaskRun string, flashTaskRun string, expected bool) {
build := &automotivev1alpha1.ImageBuild{
Status: automotivev1alpha1.ImageBuildStatus{
Phase: phase,
PushTaskRunName: pushTaskRun,
FlashTaskRunName: flashTaskRun,
},
}
Expect(buildProducedArtifacts(build)).To(Equal(expected))
},
Entry("Pending", phasePending, "", "", false),
Entry("Uploading", phaseUploading, "", "", false),
Entry("Building", phaseBuilding, "", "", false),
Entry("Pushing (in progress)", phasePushing, "", "", false),
Entry("Flashing", phaseFlashing, "", "", true),
Entry("Completed", phaseCompleted, "", "", true),
Entry("Cancelled", phaseCancelled, "", "", false),
Entry("Failed during build (no push/flash)", phaseFailed, "", "", false),
Entry("Failed during push", phaseFailed, "push-taskrun", "", false),
Entry("Failed during flash", phaseFailed, "push-taskrun", "flash-taskrun", true),
)
})
})
Loading