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
2 changes: 1 addition & 1 deletion .claude/skills/test-bootc-build/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ caib build-legacy <manifest.aib.yml> \

## Common Error Patterns

- "Builder image not found" - prepare-builder task failed or result not passed
- "Builder image not found" - builder preparation in build-image step failed or OCI image reference not propagated to the build step
- "containers-storage: invalid reference" - skopeo copy using wrong format
- "setfiles: Operation not supported" - SELinux context issues in osbuild
- "unauthorized" - Token or registry auth issues
Expand Down
123 changes: 117 additions & 6 deletions cmd/caib/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"strings"
"text/tabwriter"
"time"
"unicode/utf8"

"github.com/containers/image/v5/copy"
"github.com/containers/image/v5/docker"
Expand Down Expand Up @@ -572,7 +573,7 @@ Example:
buildCmd.Flags().StringArrayVar(&aibExtraArgs, "extra-args", []string{}, "extra arguments to pass to AIB (can be repeated)")
buildCmd.Flags().IntVar(&timeout, "timeout", 60, "timeout in minutes")
buildCmd.Flags().BoolVarP(&waitForBuild, "wait", "w", false, "wait for build to complete")
buildCmd.Flags().BoolVarP(&followLogs, "follow", "f", true, "follow build logs")
buildCmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "follow build logs (shows full log output instead of progress bar)")
// Note: --push is optional when --disk is used (disk image becomes the output)
// Jumpstarter flash options
buildCmd.Flags().BoolVar(&flashAfterBuild, "flash", false, "flash the image to device after build completes")
Expand Down Expand Up @@ -622,7 +623,7 @@ Example:
diskCmd.Flags().StringArrayVar(&aibExtraArgs, "extra-args", []string{}, "extra arguments to pass to AIB (can be repeated)")
diskCmd.Flags().IntVar(&timeout, "timeout", 60, "timeout in minutes")
diskCmd.Flags().BoolVarP(&waitForBuild, "wait", "w", false, "wait for build to complete")
diskCmd.Flags().BoolVarP(&followLogs, "follow", "f", true, "follow build logs")
diskCmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "follow build logs (shows full log output instead of progress bar)")
// Jumpstarter flash options
diskCmd.Flags().BoolVar(&flashAfterBuild, "flash", false, "flash the image to device after build completes")
diskCmd.Flags().StringVar(&jumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)")
Expand Down Expand Up @@ -653,7 +654,7 @@ Example:
buildDevCmd.Flags().StringArrayVar(&aibExtraArgs, "extra-args", []string{}, "extra arguments to pass to AIB (can be repeated)")
buildDevCmd.Flags().IntVar(&timeout, "timeout", 60, "timeout in minutes")
buildDevCmd.Flags().BoolVarP(&waitForBuild, "wait", "w", false, "wait for build to complete")
buildDevCmd.Flags().BoolVarP(&followLogs, "follow", "f", true, "follow build logs")
buildDevCmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "follow build logs (shows full log output instead of progress bar)")
// Jumpstarter flash options
buildDevCmd.Flags().BoolVar(&flashAfterBuild, "flash", false, "flash the image to device after build completes")
buildDevCmd.Flags().StringVar(&jumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)")
Expand Down Expand Up @@ -681,7 +682,7 @@ Example:
flashCmd.Flags().StringVarP(&target, "target", "t", "", "target platform for exporter lookup")
flashCmd.Flags().StringVar(&exporterSelector, "exporter", "", "direct exporter selector (alternative to --target)")
flashCmd.Flags().StringVar(&leaseDuration, "lease", "03:00:00", "device lease duration (HH:MM:SS)")
flashCmd.Flags().BoolVarP(&followLogs, "follow", "f", true, "follow flash logs")
flashCmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "follow flash logs (shows full log output instead of progress bar)")
flashCmd.Flags().BoolVarP(&waitForBuild, "wait", "w", true, "wait for flash to complete")
_ = flashCmd.MarkFlagRequired("client")

Expand Down Expand Up @@ -1607,10 +1608,12 @@ func waitForBuildCompletion(ctx context.Context, api *buildapiclient.Client, nam
Transport: logTransport,
}
streamState := &logStreamState{}
pb := newProgressBar()

for {
select {
case <-timeoutCtx.Done():
pb.clear()
handleError(fmt.Errorf("timed out waiting for build"))
case <-ticker.C:
reqCtx, cancelReq := context.WithTimeout(ctx, 2*time.Minute)
Expand All @@ -1621,8 +1624,23 @@ func waitForBuildCompletion(ctx context.Context, api *buildapiclient.Client, nam
continue
}

// Update status display (only when not streaming)
if !streamState.active && (!userFollowRequested || !streamState.canRetry()) {
// Progress bar mode: when not following logs, poll progress endpoint
if !followLogs && !streamState.active {
progressCtx, progressCancel := context.WithTimeout(ctx, 10*time.Second)
progress, _ := api.GetBuildProgress(progressCtx, name)
progressCancel()
// Use phase from progress response (fresher than GetBuild)
displayPhase := st.Phase
var step *buildapitypes.BuildStep
if progress != nil {
step = progress.Step
if progress.Phase != "" {
displayPhase = progress.Phase
}
}
pb.render(displayPhase, step)
} else if !streamState.active && (!userFollowRequested || !streamState.canRetry()) {
// Fallback: text status when streaming is not active
if st.Phase != lastPhase || st.Message != lastMessage {
fmt.Printf("status: %s - %s\n", st.Phase, st.Message)
lastPhase = st.Phase
Expand All @@ -1632,6 +1650,7 @@ func waitForBuildCompletion(ctx context.Context, api *buildapiclient.Client, nam

// Handle terminal build states
if st.Phase == phaseCompleted {
pb.clear()
flashWasExecuted := strings.Contains(st.Message, "flash")
if flashWasExecuted {
bannerColor := func(a ...any) string { return fmt.Sprint(a...) }
Expand Down Expand Up @@ -1682,6 +1701,7 @@ func waitForBuildCompletion(ctx context.Context, api *buildapiclient.Client, nam
return
}
if st.Phase == phaseFailed {
pb.clear()
// Provide phase-specific error messages
errPrefix := errPrefixBuild
isFlashFailure := false
Expand Down Expand Up @@ -1784,6 +1804,97 @@ func isBuildActive(phase string) bool {
return phase == "Building" || phase == "Running" || phase == "Uploading" || phase == phaseFlashing
}

// progressBar renders build progress. In a TTY it uses \r overwrite with a
// visual bar; in non-TTY mode it prints one line per status change.
// Progress is monotonic: once a higher done count is rendered, lower values
// are ignored to prevent regressions from transient log read failures.
type progressBar struct {
lastLine string
isTTY bool
highStep *buildapitypes.BuildStep // highest progress seen so far
}

func newProgressBar() *progressBar {
return &progressBar{isTTY: term.IsTerminal(int(os.Stdout.Fd()))}
}

func (pb *progressBar) render(phase string, step *buildapitypes.BuildStep) {
// Enforce monotonic progress: never go backwards in Done or Total
if step != nil && pb.highStep != nil {
if step.Done < pb.highStep.Done {
step.Done = pb.highStep.Done
}
if step.Total < pb.highStep.Total {
step.Total = pb.highStep.Total
}
}
if step != nil {
pb.highStep = step
}

if pb.isTTY {
pb.renderTTY(phase, step)
} else {
pb.renderPlain(phase, step)
}
}

func (pb *progressBar) renderTTY(phase string, step *buildapitypes.BuildStep) {
var line string
if step == nil {
line = fmt.Sprintf("\r%-10s \u25cc waiting for progress...", phase)
} else {
barWidth := 30
filled := 0
if step.Total > 0 {
filled = min(barWidth, barWidth*step.Done/step.Total)
}
bar := strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", barWidth-filled)
line = fmt.Sprintf("\r%-10s \u2502%s\u2502 %2d/%-2d %s", phase, bar, step.Done, step.Total, step.Stage)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if line == pb.lastLine {
return
}
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || width <= 0 {
width = 80
}
// Use rune count (not byte length) because the bar contains multi-byte
// UTF-8 characters (█, ░, │) that are 3 bytes each but 1 display column.
displayWidth := utf8.RuneCountInString(line) - 1 // subtract 1 for \r
if displayWidth < width {
line += strings.Repeat(" ", width-displayWidth)
}
_, _ = fmt.Fprint(os.Stdout, line)
pb.lastLine = line
}

func (pb *progressBar) renderPlain(phase string, step *buildapitypes.BuildStep) {
var line string
if step == nil {
line = fmt.Sprintf("%s: waiting for progress...", phase)
} else {
line = fmt.Sprintf("%s: [%d/%d] %s", phase, step.Done, step.Total, step.Stage)
}
if line == pb.lastLine {
return
}
fmt.Println(line)
pb.lastLine = line
}

func (pb *progressBar) clear() {
if !pb.isTTY || pb.lastLine == "" {
return
}
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || width <= 0 {
width = 80
}
_, _ = fmt.Fprint(os.Stdout, "\r"+strings.Repeat(" ", width)+"\r")
pb.lastLine = ""
}

// tryLogStreaming attempts to stream logs and returns error if it fails
func tryLogStreaming(ctx context.Context, logClient *http.Client, name string, state *logStreamState) error {
logURL := buildLogURL(name, state.startTime)
Expand Down
2 changes: 2 additions & 0 deletions config/rbac/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ resources:
- image_editor_role.yaml
- image_viewer_role.yaml
- pipeline_registry_rolebinding.yaml
- pipeline_pod_role.yaml
- pipeline_pod_rolebinding.yaml
Comment on lines +32 to +33

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate and inspect the RBAC files mentioned in the review

echo "=== Searching for pipeline_pod_role.yaml ==="
fd -e yaml 'pipeline_pod_role' -exec cat {} \;

echo ""
echo "=== Searching for pipeline_pod_rolebinding.yaml ==="
fd -e yaml 'pipeline_pod_rolebinding' -exec cat {} \;

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

Length of output: 581


🏁 Script executed:

#!/bin/bash
# Locate and inspect the RBAC files with corrected syntax

echo "=== Searching for pipeline_pod_role.yaml ==="
find . -name "pipeline_pod_role.yaml" -type f | head -20

echo ""
echo "=== Searching for pipeline_pod_rolebinding.yaml ==="
find . -name "pipeline_pod_rolebinding.yaml" -type f | head -20

echo ""
echo "=== Listing files in config/rbac directory ==="
ls -la config/rbac/ 2>/dev/null || echo "Directory not found"

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

Length of output: 1764


🏁 Script executed:

#!/bin/bash
# Read the actual content of both RBAC files

echo "=== Content of pipeline_pod_role.yaml ==="
cat config/rbac/pipeline_pod_role.yaml

echo ""
echo "=== Content of pipeline_pod_rolebinding.yaml ==="
cat config/rbac/pipeline_pod_rolebinding.yaml

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

Length of output: 636


Use a namespace-scoped Role instead of ClusterRole for pod patching.

The pipeline_pod_role.yaml file currently defines a ClusterRole, but since it's bound via a RoleBinding scoped to the system namespace, a namespace-scoped Role is more appropriate. While the effective permissions are limited to the namespace, the ClusterRole object exists cluster-wide and can be rebound elsewhere by any cluster-admin, unnecessarily broadening the attack surface. This violates the principle of least privilege.

Change pipeline_pod_role.yaml to use kind: Role and add the namespace: system metadata field, then update the roleRef.kind in pipeline_pod_rolebinding.yaml from ClusterRole to Role.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/rbac/kustomization.yaml` around lines 32 - 33, Change the
pipeline_pod_role.yaml to define a namespace-scoped Role instead of a
ClusterRole by setting kind: Role and adding metadata.namespace: system, keeping
the same rules and metadata.name; then update pipeline_pod_rolebinding.yaml to
point roleRef.kind: Role (instead of ClusterRole) so the RoleBinding in the
system namespace correctly references the namespaced Role. Ensure the Role name
used in metadata.name matches the roleRef.name in the RoleBinding.


8 changes: 8 additions & 0 deletions config/rbac/pipeline_pod_role.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pipeline-pod-annotator
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["patch"]
Comment thread
bennyz marked this conversation as resolved.
13 changes: 13 additions & 0 deletions config/rbac/pipeline_pod_rolebinding.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: pipeline-pod-annotator
namespace: system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: pipeline-pod-annotator
subjects:
- kind: ServiceAccount
name: pipeline
namespace: system
34 changes: 34 additions & 0 deletions internal/buildapi/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,40 @@ func (c *Client) GetBuild(ctx context.Context, name string) (*buildapi.BuildResp
return &out, nil
}

// GetBuildProgress retrieves the current progress of a build.
// Returns nil, nil (no error) on 404 to handle older servers gracefully.
func (c *Client) GetBuildProgress(ctx context.Context, name string) (*buildapi.BuildProgress, error) {
endpoint := c.resolve(path.Join("/v1/builds", url.PathEscape(name), "progress"))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
if c.authToken != "" {
req.Header.Set("Authorization", "Bearer "+c.authToken)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close response body: %v\n", err)
}
}()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("get build progress failed: %s: %s", resp.Status, string(b))
}
var out buildapi.BuildProgress
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}

// GetBuildTemplate retrieves a build template reconstructed from ImageBuild inputs.
func (c *Client) GetBuildTemplate(ctx context.Context, name string) (*buildapi.BuildTemplateResponse, error) {
endpoint := c.resolve(path.Join("/v1/builds", url.PathEscape(name), "template"))
Expand Down
47 changes: 47 additions & 0 deletions internal/buildapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,26 @@ paths:
text/plain:
schema:
type: string
/v1/builds/{name}/progress:
parameters:
- in: path
name: name
schema:
type: string
required: true
get:
summary: Get build progress
operationId: getBuildProgress
description: Returns the current build phase and, for active builds, the latest progress step from build script markers.
responses:
'200':
description: Build progress
content:
application/json:
schema:
$ref: '#/components/schemas/BuildProgress'
'404':
description: Not found
/v1/builds/{name}/template:
parameters:
- in: path
Expand Down Expand Up @@ -218,6 +238,33 @@ components:
requestedBy:
type: string
nullable: true
BuildProgress:
type: object
properties:
phase:
type: string
description: ImageBuild phase (Pending, Building, Completed, Failed, etc.)
step:
$ref: '#/components/schemas/BuildStep'
required:
- phase
BuildStep:
type: object
description: A progress checkpoint emitted by the build script
properties:
stage:
type: string
description: Human-readable stage name
done:
type: integer
description: Number of steps completed
total:
type: integer
description: Total number of steps
required:
- stage
- done
- total
BuildTemplateResponse:
allOf:
- $ref: '#/components/schemas/BuildRequest'
Expand Down
Loading