From 8a59297e92bbbf608b0c8bc97a7a0932977cd380 Mon Sep 17 00:00:00 2001 From: Stephan Butler Date: Thu, 4 Jun 2026 17:18:57 +0200 Subject: [PATCH 1/4] docs: added copilot instructions --- .github/copilot-instructions.md | 144 ++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..b88c390 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,144 @@ +# Builders Repository — Agent Instructions + +Trust these instructions and only search the codebase if the information below is incomplete or found to be incorrect. + +## What This Repository Does + +Centralised Docker base images for the Interledger Foundation ecosystem. Two images are currently maintained: + +| Folder | Image published at | Purpose | +|---|---|---| +| `chartvalidator/` | `ghcr.io/interledger/builders/chartvalidator` | Helm 3.19, kubeconform 0.7.0, kustomize, docker-cli, docker-credential-gcr, `chart-checker` Go binary | +| `gotester/` | `ghcr.io/interledger/builders/gotester` | Go 1.25, golangci-lint 2.5.0, Atlas CLI 1.2.0, PostgreSQL 17 client, docker-cli, make, bc, bash, git, curl, grep | + +## Repository Layout + +``` +builders/ +├── chartvalidator/ +│ ├── Dockerfile # Multi-stage: builds chart-checker from Go, then alpine image +│ ├── README.md +│ └── checker/ # Go source for chart-checker CLI +│ ├── go.mod # module: github.com/builderslab/chartvalidator/checker, go 1.24.5 +│ ├── go.sum +│ ├── main.go # CLI entry point (run-checks, render-only subcommands) +│ ├── *.go # Engine files: chart rendering, manifest validation, Docker validation +│ ├── *_test.go # Unit tests +│ └── test_data/ # YAML fixtures for tests +├── gotester/ +│ ├── Dockerfile # Single-stage: golang:1.25-alpine + tools +│ └── README.md +├── .tooling/ # Node.js CI helper scripts (excluded from Docker builds) +│ ├── package.json # type: "module", no external dependencies +│ ├── detect-changes.js # Which builder folders changed? +│ ├── versioning.js # Conventional commit parser + SemVer calculator +│ ├── version-calculator.js # Queries GHCR for current version, computes next +│ ├── validate-commits.js # Commit message linter (used in CI) +│ ├── docker-tags.js # Tag generation (v1.2.3, v1.2, v1, latest) +│ ├── image-tags.js # Fetch existing tags from GHCR +│ └── test*.js # Test suite (no test framework, plain Node.js) +└── .github/ + └── workflows/ + └── buildall.yaml # Single workflow: validate commits → detect changes → build +``` + +## Adding a New Builder Image + +Create a new top-level directory containing a `Dockerfile`. The CI detects all top-level directories that contain a `Dockerfile` (hidden directories and `.tooling/` are excluded automatically). No other configuration is required. + +## Commit Message Format (enforced by CI) + +All commits **must** follow Conventional Commits. The CI validates this before anything else runs — a non-conforming commit will fail the pipeline immediately. + +``` +(): +``` + +Valid types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert` + +Version bumps derived from commit type: +- Breaking change (`!` marker or `BREAKING CHANGE:` in body) → major +- `feat` → minor +- `fix`, `perf`, `refactor`, `revert` → patch +- `docs`, `style`, `test`, `chore`, `ci`, `build` → no version bump (image still rebuilt if files changed) + +## Building Locally + +### Docker images (run from repo root) + +```bash +# Build chartvalidator image (runs Go tests inside the Dockerfile) +docker build -t chartvalidator ./chartvalidator + +# Build gotester image +docker build -t gotester ./gotester +``` + +Note: The `chartvalidator` Dockerfile runs `go test .` during the builder stage (`RUN go test .`). If Go tests fail, the Docker build fails. + +### Go binary only (no Docker required) + +```bash +cd chartvalidator/checker +go test ./... # Run unit tests — takes ~0.3 s, all pass on a clean checkout +go build -o chart-checker . +``` + +Always run `go test ./...` from `chartvalidator/checker/`. Running from the repo root fails because there is no `go.mod` there. + +## Running Tests + +### Go tests (chartvalidator) + +```bash +cd chartvalidator/checker +go test ./... +``` + +Expected output: `ok github.com/builderslab/chartvalidator/checker 0.260s` + +### Node.js tooling tests + +`npm` is not required. Run directly with Node.js (≥18): + +```bash +cd .tooling +node test.js && node test-image-tags.js && node test-versioning.js && node test-docker-tags.js +``` + +Expected output: all suites report "0 failed". Individual suites can be run independently. Tests have no external dependencies and run without network access. + +## CI Pipeline (`.github/workflows/buildall.yaml`) + +Three sequential jobs on every PR and push to `main`: + +1. **validate-commits** — runs `node validate-commits.js` from `.tooling/`. Fails if the head commit message is not a valid conventional commit. **This must pass before any Docker build runs.** + +2. **detect-changes** — runs `node detect-changes.js detect` from `.tooling/`. Outputs a JSON array of builder folder names that have changed files. On `workflow_dispatch` or when no base ref is available, all folders are built. + +3. **build-and-push** — matrix over changed folders: + - Calls `node version-calculator.js` to fetch the current version from GHCR and compute the next SemVer based on the commit message + - Builds the Docker image (`docker/build-push-action`) + - Pushes to `ghcr.io/interledger/builders/` **only on merge to `main`**; PR builds test that the image builds successfully without pushing + +Image tags applied on every release: `v{major}.{minor}.{patch}`, `v{major}.{minor}`, `v{major}`, `latest`. + +## Linting and Validation + +There is no project-level linter beyond the commit message check. For the Go code in `chartvalidator/checker/`: + +```bash +cd chartvalidator/checker +go vet ./... # No output = pass +go test ./... # Must pass +``` + +If `golangci-lint` is available locally: `golangci-lint run ./...` (not enforced in this repo's CI, but the `gotester` image ships it for use in downstream repos). + +## Key Facts + +- The `.tooling/` scripts use ES module syntax (`import`/`export`); `package.json` sets `"type": "module"`. Do not convert to CommonJS. +- `detect-changes.js` always resolves paths relative to the git repository root regardless of the working directory from which it is invoked. +- Tooling scripts have no `node_modules` and no `package-lock.json` — they have zero external npm dependencies. +- Docker images are pushed only from the `main` branch; PRs only verify that the image builds. +- The `chartvalidator/tmp/` directory is a runtime artefact (gitignored via `chartvalidator/.gitignore`) produced when `chart-checker` is run; do not commit it. From e603d707bdb34a5b6f40301ad0473472cf137792 Mon Sep 17 00:00:00 2001 From: Stephan Butler Date: Thu, 4 Jun 2026 17:28:32 +0200 Subject: [PATCH 2/4] fix(chartvalidator): will now retry if docker manifest check fails --- chartvalidator/checker/engine_app_checker.go | 2 + .../checker/engine_docker_validation.go | 45 +++++++++----- .../checker/engine_docker_validation_test.go | 62 ++++++++++++++++--- 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/chartvalidator/checker/engine_app_checker.go b/chartvalidator/checker/engine_app_checker.go index adf46ad..104b184 100644 --- a/chartvalidator/checker/engine_app_checker.go +++ b/chartvalidator/checker/engine_app_checker.go @@ -5,6 +5,7 @@ import ( "fmt" "path/filepath" "sync" + "time" ) type AppCheckInstruction struct { @@ -86,6 +87,7 @@ func NewAppCheckerEngine(context context.Context, outputDir string, apiVersions cache: map[string]DockerImageValidationResult{}, pending: map[string]*sync.WaitGroup{}, cacheLock: sync.RWMutex{}, + retrySleepFn: time.Sleep, workerWaitGroup: sync.WaitGroup{}, } diff --git a/chartvalidator/checker/engine_docker_validation.go b/chartvalidator/checker/engine_docker_validation.go index 47cc05c..e9d013e 100644 --- a/chartvalidator/checker/engine_docker_validation.go +++ b/chartvalidator/checker/engine_docker_validation.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io/fs" + "math/rand" "os" "os/exec" "path/filepath" @@ -25,12 +26,13 @@ type DockerImageValidationEngine struct { executor CommandExecutor context context.Context - cache map[string]DockerImageValidationResult + cache map[string]DockerImageValidationResult pending map[string]*sync.WaitGroup cacheLock sync.RWMutex name string + retrySleepFn func(time.Duration) workerWaitGroup sync.WaitGroup } @@ -127,32 +129,43 @@ func (engine *DockerImageValidationEngine) waitForPending(chart ChartRenderParam } func (engine *DockerImageValidationEngine) validateSingleDockerImage(chart ChartRenderParams, image string, workerId int) DockerImageValidationResult { - ctx, cancel := context.WithTimeout(engine.context, 2*time.Minute) - defer cancel() + const maxRetries = 3 args := []string{"manifest", "inspect", image} - cmd := engine.executor.CommandContext(ctx, "docker", args...) - - // Print the command being executed using interface methods - cmdStr := fmt.Sprintf("%s %s", filepath.Base(cmd.GetPath()), strings.Join(cmd.GetArgs()[1:], " ")) - logEngineDebug(engine.name, workerId, fmt.Sprintf("executing: %s", cmdStr)) + cmdStr := fmt.Sprintf("docker %s", strings.Join(args, " ")) + + var err error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + sleepSecs := 1 + rand.Intn(15) + logEngineWarning(engine.name, workerId, fmt.Sprintf("retrying %s (attempt %d/%d) after %ds", cmdStr, attempt+1, maxRetries+1, sleepSecs)) + engine.retrySleepFn(time.Duration(sleepSecs) * time.Second) + select { + case <-engine.context.Done(): + return DockerImageValidationResult{Image: image, Exists: false, Error: engine.context.Err(), Chart: chart} + default: + } + } - err := cmd.Run() + ctx, cancel := context.WithTimeout(engine.context, 2*time.Minute) + cmd := engine.executor.CommandContext(ctx, "docker", args...) + logEngineDebug(engine.name, workerId, fmt.Sprintf("executing: %s", cmdStr)) + err = cmd.Run() + cancel() - exists := err == nil - if err != nil { + if err == nil { + logEngineDebug(engine.name, workerId, fmt.Sprintf("completed: %s", cmdStr)) + break + } logEngineWarning(engine.name, workerId, fmt.Sprintf("failed: %s", cmdStr)) - } else { - logEngineDebug(engine.name, workerId, fmt.Sprintf("completed: %s", cmdStr)) } return DockerImageValidationResult{ Image: image, - Exists: exists, + Exists: err == nil, Error: err, - Chart: chart, + Chart: chart, } - } // findJSONFiles recursively finds all JSON files in the given directory diff --git a/chartvalidator/checker/engine_docker_validation_test.go b/chartvalidator/checker/engine_docker_validation_test.go index d64db16..07e133e 100644 --- a/chartvalidator/checker/engine_docker_validation_test.go +++ b/chartvalidator/checker/engine_docker_validation_test.go @@ -15,13 +15,14 @@ import ( // Helper function to create a Docker validation engine func createDockerValidationEngine(mockExecutor *MockCommandExecutor) *DockerImageValidationEngine { return &DockerImageValidationEngine{ - inputChan: make(chan ImageExtractionResult), - outputChan: make(chan DockerImageValidationResult), - executor: mockExecutor, - context: createTestContext(), - cache: make(map[string]DockerImageValidationResult), - pending: make(map[string]*sync.WaitGroup), - name: "DockerImageValidationEngine", + inputChan: make(chan ImageExtractionResult), + outputChan: make(chan DockerImageValidationResult), + executor: mockExecutor, + context: createTestContext(), + cache: make(map[string]DockerImageValidationResult), + pending: make(map[string]*sync.WaitGroup), + name: "DockerImageValidationEngine", + retrySleepFn: func(time.Duration) {}, } } @@ -419,6 +420,53 @@ func TestValidateSingleDockerImage(t *testing.T) { } } +func TestDockerValidationRetry(t *testing.T) { + callCount := 0 + mockExecutor := createMockExecutorWithBehavior(func() error { + callCount++ + if callCount < 3 { + return fmt.Errorf("transient docker error") + } + return nil + }) + + engine := createDockerValidationEngine(mockExecutor) + engine.Start(1) + + img := "nginx:1.20" + go func() { + engine.inputChan <- ImageExtractionResult{Image: img} + }() + + result := <-engine.outputChan + assert.Equal(t, img, result.Image) + assert.Nil(t, result.Error, "expected success after retries") + assert.True(t, result.Exists) + assert.Equal(t, 3, callCount, "expected 3 attempts (2 failures then 1 success)") +} + +func TestDockerValidationRetriesExhausted(t *testing.T) { + callCount := 0 + mockExecutor := createMockExecutorWithBehavior(func() error { + callCount++ + return fmt.Errorf("persistent docker error") + }) + + engine := createDockerValidationEngine(mockExecutor) + engine.Start(1) + + img := "nginx:1.20" + go func() { + engine.inputChan <- ImageExtractionResult{Image: img} + }() + + result := <-engine.outputChan + assert.Equal(t, img, result.Image) + assert.NotNil(t, result.Error) + assert.False(t, result.Exists) + assert.Equal(t, 4, callCount, "expected 4 attempts (1 initial + 3 retries)") +} + func TestDockerValidationError(t *testing.T) { mockExecutor := createMockExecutorWithBehavior(func() error { return fmt.Errorf("mocked docker error") From 6df95d6f392134bf8d9615626469d15fb51a7a76 Mon Sep 17 00:00:00 2001 From: Stephan Butler Date: Thu, 4 Jun 2026 17:35:19 +0200 Subject: [PATCH 3/4] docs: readme for chartvalidator --- chartvalidator/README.md | 162 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/chartvalidator/README.md b/chartvalidator/README.md index 511076e..32da194 100644 --- a/chartvalidator/README.md +++ b/chartvalidator/README.md @@ -1,3 +1,161 @@ -## Chart Validator +# Chart Validator -A image for assisting in validation of Kubernetes charts. Has tools for rendering charts and validating them using KubeConform \ No newline at end of file +A CI tool that validates Kubernetes Helm charts end-to-end: it renders each chart with its real values, validates the resulting manifests against Kubernetes API schemas, and confirms that every referenced Docker image actually exists in its registry. + +It is designed to run in CI against [ArgoCD ApplicationSet](https://argo-cd.readthedocs.io/en/stable/user-guide/application-set/) files, so problems are caught before a deployment is attempted. + +--- + +## How it works + +Chart Validator runs a five-stage pipeline. Each stage is backed by a pool of concurrent workers (10 by default) and passes results to the next stage via Go channels. + +``` +ApplicationSet YAML files + │ + ▼ + 1. Parse ApplicationSets — find every chart referenced in each environment + │ + ▼ + 2. Render charts — helm template with the chart's real values files + │ + ▼ + 3. Validate manifests — kubeconform against Kubernetes API schemas + │ + ▼ + 4. Extract images — parse container image references from the manifests + │ + ▼ + 5. Validate images — docker manifest inspect for every unique image +``` + +A failure at any stage is reported immediately; the tool exits non-zero if any check fails. + +### Stage 1 — Parse ApplicationSets + +Scans `{envdir}/{env}/appsets/*.appset.yaml` for entries of the form: + +```yaml +spec: + generators: + - list: + elements: + - chartName: my-chart + repoURL: https://charts.example.com + chartVersion: "1.2.3" + baseValuesFile: env/base/values.yaml + valuesOverride: env/production/values.yaml +``` + +Each element becomes one unit of work flowing through the rest of the pipeline. Paths in `baseValuesFile` and `valuesOverride` are relative to the parent directory of `envdir` (i.e. prefixed with `../`). + +### Stage 2 — Render charts + +Runs `helm template` for each chart, combining the base values file and the override values file. Repository URLs that look like OCI registries but lack a scheme (e.g. `europe-west4-docker.pkg.dev/my-project/charts`) are automatically prefixed with `oci://` because ArgoCD accepts scheme-less URLs but Helm CLI requires the explicit prefix. + +Flags passed to Helm: +- `-f baseValuesFile -f valuesOverride` — layered values +- `--version chartVersion` +- `--include-crds` +- `--kube-version` — the target Kubernetes version (defaults to `1.33.0`) +- `--api-versions` — any additional API groups passed via `-api-versions` + +Rendered manifests are written to the output directory and passed to the next stage by file path. + +### Stage 3 — Validate manifests + +Runs `kubeconform` in strict mode against the rendered YAML. CRD schemas are resolved from three sources in order: + +1. **Local schemas** — JSON schema files in the `schemas/` directory bundled with the image (covers Traefik, 1Password Connect, Prometheus Operator). +2. **CRDs catalog** — remote fallback at `https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/…` for any CRD not in the local set. +3. **Built-in schemas** — kubeconform's own upstream Kubernetes schemas. + +`CustomResourceDefinition` resources themselves are skipped during validation (kubeconform cannot validate a CRD against itself). + +### Stage 4 — Extract images + +Parses the rendered YAML and collects the `image` field from every `containers` and `initContainers` entry in Pods, Deployments, DaemonSets, and StatefulSets. Duplicate image references are deduplicated before being sent to the next stage. + +### Stage 5 — Validate images + +Runs `docker manifest inspect {image}` for each unique image. Results are cached so the same image is only checked once even if it appears across multiple charts. + +If a manifest inspect fails due to a transient network error, the check is retried up to **3 times** with a random backoff of **1–15 seconds** between attempts before the image is considered missing. + +--- + +## Usage + +``` +chart-checker [flags] + +Commands: + run-checks Render charts and run all validation checks. + render-only Render charts only; skip all validation. + help Show this help. +``` + +### Flags + +All flags apply to both commands. + +| Flag | Default | Description | +|---|---|---| +| `-env` | _(all environments)_ | Process only this environment (the folder name under `-envdir`). | +| `-envdir` | `../env` | Directory that contains per-environment subdirectories. | +| `-output` | `manifests` | Directory where rendered manifests are written. Cleared on each run. | +| `-api-versions` | _(none)_ | Comma-separated additional Kubernetes API versions passed to `helm template`. | +| `-k8s-version` | `1.33.0` | Kubernetes version used for Helm rendering and kubeconform validation. | +| `-v` | `false` | Enable verbose (debug-level) logging. | + +### Examples + +```bash +# Validate all environments +chart-checker run-checks + +# Validate a single environment +chart-checker run-checks -env sandbox + +# Validate with a specific Kubernetes version +chart-checker run-checks -env production -k8s-version 1.30.0 + +# Render charts only (useful for debugging values or template issues) +chart-checker render-only -env staging -v +``` + +--- + +## Registry authentication + +Image validation uses the Docker daemon's credential store, so any registry reachable by `docker pull` on the host is also reachable by the validator. + +The container image ships with `docker-credential-gcr` pre-configured for Google Artifact Registry endpoints (`gcr.io`, `*.pkg.dev`). For other registries, mount or inject a pre-authenticated `~/.docker/config.json`, or configure the appropriate credential helper in the Docker config before invoking the tool. + +--- + +## Updating CRD schemas + +Local CRD schemas must be regenerated whenever a new CRD-bearing chart version is adopted. The schemas live in `schemas/` and are committed to the repository. + +From `tmp/ci/`: + +```bash +make update-schemas +``` + +This renders the CRD charts (Traefik, 1Password Connect, Prometheus Operator), converts their OpenAPI specs to JSON Schema format, and writes the output to `schemas/`. To target a specific chart version: + +```bash +make update-schemas TRAEFIK_VERSION=35.2.0 PROMETHEUS_OPERATOR_VERSION=76.3.0 +``` + +--- + +## Building the image + +```bash +docker build -t chartvalidator . +``` + +Unit tests run as part of the build. The resulting image contains `chart-checker`, `helm`, `kubeconform`, `kustomize`, and `docker` CLI. From 1b9b7357083095d367dd1aca768832db84532721 Mon Sep 17 00:00:00 2001 From: Stephan Butler Date: Thu, 4 Jun 2026 17:49:23 +0200 Subject: [PATCH 4/4] fix: after comment feedback --- chartvalidator/README.md | 2 +- .../checker/engine_docker_validation.go | 30 ++++++++++- .../checker/engine_docker_validation_test.go | 52 ++++++++++++++----- chartvalidator/checker/exec_mock.go | 4 ++ 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/chartvalidator/README.md b/chartvalidator/README.md index 32da194..08c054e 100644 --- a/chartvalidator/README.md +++ b/chartvalidator/README.md @@ -80,7 +80,7 @@ Parses the rendered YAML and collects the `image` field from every `containers` Runs `docker manifest inspect {image}` for each unique image. Results are cached so the same image is only checked once even if it appears across multiple charts. -If a manifest inspect fails due to a transient network error, the check is retried up to **3 times** with a random backoff of **1–15 seconds** between attempts before the image is considered missing. +If a manifest inspect fails, the check is retried up to **3 times** with a random backoff of **1–15 seconds** between attempts. Failures that are clearly permanent — image not found (`manifest unknown`), auth errors (`unauthorized`, `denied`), or a bad image reference — are not retried. --- diff --git a/chartvalidator/checker/engine_docker_validation.go b/chartvalidator/checker/engine_docker_validation.go index e9d013e..0782801 100644 --- a/chartvalidator/checker/engine_docker_validation.go +++ b/chartvalidator/checker/engine_docker_validation.go @@ -135,6 +135,7 @@ func (engine *DockerImageValidationEngine) validateSingleDockerImage(chart Chart cmdStr := fmt.Sprintf("docker %s", strings.Join(args, " ")) var err error + var output []byte for attempt := 0; attempt <= maxRetries; attempt++ { if attempt > 0 { sleepSecs := 1 + rand.Intn(15) @@ -150,14 +151,20 @@ func (engine *DockerImageValidationEngine) validateSingleDockerImage(chart Chart ctx, cancel := context.WithTimeout(engine.context, 2*time.Minute) cmd := engine.executor.CommandContext(ctx, "docker", args...) logEngineDebug(engine.name, workerId, fmt.Sprintf("executing: %s", cmdStr)) - err = cmd.Run() + output, err = cmd.CombinedOutput() cancel() if err == nil { logEngineDebug(engine.name, workerId, fmt.Sprintf("completed: %s", cmdStr)) break } - logEngineWarning(engine.name, workerId, fmt.Sprintf("failed: %s", cmdStr)) + + logEngineWarning(engine.name, workerId, fmt.Sprintf("failed: %s: %s", cmdStr, strings.TrimSpace(string(output)))) + + if isPermanentDockerError(output) { + logEngineWarning(engine.name, workerId, fmt.Sprintf("not retrying %s: permanent failure detected", cmdStr)) + break + } } return DockerImageValidationResult{ @@ -168,6 +175,25 @@ func (engine *DockerImageValidationEngine) validateSingleDockerImage(chart Chart } } +// isPermanentDockerError returns true if the command output indicates a failure +// that will not resolve on retry, such as an image not existing or an auth error. +func isPermanentDockerError(output []byte) bool { + s := strings.ToLower(string(output)) + for _, pattern := range []string{ + "manifest unknown", + "no such manifest", + "unauthorized", + "denied", + "invalid reference format", + "name unknown", + } { + if strings.Contains(s, pattern) { + return true + } + } + return false +} + // findJSONFiles recursively finds all JSON files in the given directory func findJSONFiles(dir string) ([]string, error) { var jsonFiles []string diff --git a/chartvalidator/checker/engine_docker_validation_test.go b/chartvalidator/checker/engine_docker_validation_test.go index 07e133e..c9cb99a 100644 --- a/chartvalidator/checker/engine_docker_validation_test.go +++ b/chartvalidator/checker/engine_docker_validation_test.go @@ -66,6 +66,7 @@ func sendImagesToEngine(engine *DockerImageValidationEngine, images []string) { Image: img, } } + close(engine.inputChan) }() } @@ -113,9 +114,8 @@ func TestDockerImageValidationEngine(t *testing.T) { img := "nginx:1.20" go func(s string) { - engine.inputChan <- ImageExtractionResult{ - Image: s, - } + engine.inputChan <- ImageExtractionResult{Image: s} + close(engine.inputChan) }(img) result := <-engine.outputChan @@ -127,7 +127,6 @@ func TestDockerImageValidationEngine(t *testing.T) { } assertCommandExecution(t, mockExecutor, "docker manifest inspect nginx:1.20") - engine.context.Done() } func TestDockerImageValidationCache(t *testing.T) { @@ -436,6 +435,7 @@ func TestDockerValidationRetry(t *testing.T) { img := "nginx:1.20" go func() { engine.inputChan <- ImageExtractionResult{Image: img} + close(engine.inputChan) }() result := <-engine.outputChan @@ -449,7 +449,7 @@ func TestDockerValidationRetriesExhausted(t *testing.T) { callCount := 0 mockExecutor := createMockExecutorWithBehavior(func() error { callCount++ - return fmt.Errorf("persistent docker error") + return fmt.Errorf("transient docker error") }) engine := createDockerValidationEngine(mockExecutor) @@ -458,6 +458,7 @@ func TestDockerValidationRetriesExhausted(t *testing.T) { img := "nginx:1.20" go func() { engine.inputChan <- ImageExtractionResult{Image: img} + close(engine.inputChan) }() result := <-engine.outputChan @@ -467,24 +468,51 @@ func TestDockerValidationRetriesExhausted(t *testing.T) { assert.Equal(t, 4, callCount, "expected 4 attempts (1 initial + 3 retries)") } +func TestDockerValidationPermanentError(t *testing.T) { + callCount := 0 + mockExecutor := &MockCommandExecutor{ + Output: []byte("unauthorized: authentication required"), + BehaviorOnRun: func() error { + callCount++ + return fmt.Errorf("exit status 1") + }, + } + + engine := createDockerValidationEngine(mockExecutor) + engine.Start(1) + + img := "private.registry.io/app:v1.0" + go func() { + engine.inputChan <- ImageExtractionResult{Image: img} + close(engine.inputChan) + }() + + result := <-engine.outputChan + assert.Equal(t, img, result.Image) + assert.NotNil(t, result.Error) + assert.False(t, result.Exists) + assert.Equal(t, 1, callCount, "expected exactly 1 attempt: permanent errors should not be retried") +} + func TestDockerValidationError(t *testing.T) { - mockExecutor := createMockExecutorWithBehavior(func() error { - return fmt.Errorf("mocked docker error") - }) + mockExecutor := &MockCommandExecutor{ + Output: []byte("manifest unknown: manifest unknown"), + BehaviorOnRun: func() error { + return fmt.Errorf("exit status 1") + }, + } engine := createDockerValidationEngine(mockExecutor) engine.Start(1) img := "nonexistent:image" go func(s string) { - engine.inputChan <- ImageExtractionResult{ - Image: s, - } + engine.inputChan <- ImageExtractionResult{Image: s} + close(engine.inputChan) }(img) result := <-engine.outputChan assert.Equal(t, result.Image, img) assert.NotNil(t, result.Error) assertCommandExecution(t, mockExecutor, "docker manifest inspect nonexistent:image") - engine.context.Done() } \ No newline at end of file diff --git a/chartvalidator/checker/exec_mock.go b/chartvalidator/checker/exec_mock.go index e1bcc97..e5cbd95 100644 --- a/chartvalidator/checker/exec_mock.go +++ b/chartvalidator/checker/exec_mock.go @@ -45,6 +45,10 @@ func (m *MockCommand) SetDir(dir string) { } func (m *MockCommand) CombinedOutput() ([]byte, error) { + if m.executor.BehaviorOnRun != nil { + err := m.executor.BehaviorOnRun() + return m.output, err + } return m.output, m.err }