From f2ecf3b1ddff1c2e6e22153392f864dcda82f2c4 Mon Sep 17 00:00:00 2001 From: Andreas Trawoeger Date: Fri, 23 Oct 2026 12:56:01 +0200 Subject: [PATCH] feat(build): the charly image store, annotated exports, and oci-layout parents (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every built image is now NAMED at export time and lands in an engine-neutral OCI layout at $XDG_DATA_HOME/charly/images (one index.json, each manifest annotated io.containerd.image.name= plus org.opencontainers.image.created). The engine import is a plain `load` with NO tag step: the annotation names the image, and the import verifies the reference resolves through the engine capability table — no engine word literal remains in the build path (ensure.go's podman pull/tag/exec literals are gone too). A parent box present in the store is bound to the solve as an `--oci-layout` named context (buildkitdrive.Build.OCILayouts, sdk v0.2026296.803, sdk#433), so a box-on-box build does not reach the registry for it; the registry stays the fallback when the parent is absent locally. Measured (buildkit 0.34.1, podman 6.1.4): a `name=` export imports tagged and an archive with the annotation stripped imports dangling; a child whose base is `ghcr.invalid/...` solves from the store while the control without the binding fails at DNS. The live tests carry both directions. Assisted-by: Claude Code deepseek-v4.1-flash:cloud (fully tested and validated) --- README.md | 21 +- candy/plugin-build/buildkit.go | 94 ++++- candy/plugin-build/buildkit_live_test.go | 4 + candy/plugin-build/charly.yml | 22 +- candy/plugin-build/ensure.go | 54 +-- candy/plugin-build/ensure_alias_test.go | 3 +- candy/plugin-build/go.mod | 29 +- candy/plugin-build/go.sum | 108 +++-- candy/plugin-build/imagestore.go | 461 +++++++++++++++++++++ candy/plugin-build/imagestore_live_test.go | 187 +++++++++ candy/plugin-build/imagestore_test.go | 297 +++++++++++++ 11 files changed, 1179 insertions(+), 101 deletions(-) create mode 100644 candy/plugin-build/imagestore.go create mode 100644 candy/plugin-build/imagestore_live_test.go create mode 100644 candy/plugin-build/imagestore_test.go diff --git a/README.md b/README.md index 423347d..52856cb 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,22 @@ The build drive for OpenCharly — it owns the BuildKit build behind The plugin OWNS the build drive (the build-order loop, the per-image build lock, the push, and the merge gate) for `build:box` and `build:generate`, -building through the `buildkitdrive` kit and bridging each image into the run -engine's store, +building through the `buildkitdrive` kit, plus the ensure-image orchestration for `build:ensure`. It runs the heavy loader/render resolve **plugin-side itself**, reaching the host only for what a SDK-only candy structurally cannot do (the bootstrap-delicate local scan, the git fetch, the build-time plugin connect, the host-fs prep) over a small `buildengine-*` `HostBuild` leg family. The layer merge crosses to `verb:oci` -via `InvokeProvider`. The plugin then runs podman directly — building each image -(Containerfile piped over stdin), gating the inline merge on the box's -`MergeAuto`, and pushing after merge. +via `InvokeProvider`. + +Each build's export is NAMED with `io.containerd.image.name=`, written +to the **charly image store** — one engine-neutral OCI layout at +`$XDG_DATA_HOME/charly/images` (override `CHARLY_IMAGE_STORE_DIR`) — and then +materialised into the run engine. The import needs **no tag step**: the +annotation names the image on load, and the drive verifies the reference +resolves. A parent box present in the store is supplied to the solve as an +`--oci-layout` named context, so a box-on-box build does not reach the registry +for it. It is **compiled into charly** (listed in the embedded `compiled_plugins:`): `charly box build` / `charly box generate` / every `dispatchBuildEnsure` caller @@ -43,8 +49,9 @@ charly box pull my-image ## Layout - `candy/plugin-build/` — the plugin module: `plugin.go` (provider + meta), - `drive.go`, `resolve*.go`, `render.go`, `buildkit.go`, `ensure.go`, - `host_prep.go`, `bootstrap.go`, `schema/build.cue`, `cmd/serve/main.go`. + `drive.go`, `resolve*.go`, `render.go`, `buildkit.go`, `imagestore.go`, + `ensure.go`, `host_prep.go`, `bootstrap.go`, `schema/build.cue`, + `cmd/serve/main.go`. - `candy/plugin-build/charly.yml` — the `plugin-build:` candy entity (`plugin:` block, `plan:` check). - `charly.yml` — the root project manifest (`discover: candy`). diff --git a/candy/plugin-build/buildkit.go b/candy/plugin-build/buildkit.go index 40e9877..fe5a48c 100644 --- a/candy/plugin-build/buildkit.go +++ b/candy/plugin-build/buildkit.go @@ -4,12 +4,18 @@ package build // this file no longer drives an engine at all. // // ⚑ One builder: BuildKit. The Containerfile is built by the buildkitdrive kit (daemon ensure + -// `buildctl` argv), and the built image is STORED IN THE CONFIGURED CONTAINER BACKEND via the -// SDK's LoadExport, because every later read of it — its capability labels, its deploy, its -// status, its inline merge, `charly clean` — resolves it through that backend. Each built image -// is ALSO pushed to the LOCAL CHARLY OCI REGISTRY under its FullTag, and the daemon mirrors -// `ghcr.io/opencharly` to that registry (DaemonOptions.RegistryMirror), so the next stage's -// `FROM ` resolves an internal base locally instead of against the upstream registry. +// `buildctl` argv). The export is NAMED with `io.containerd.image.name=`, written to the +// CHARLY IMAGE STORE (one engine-neutral OCI layout at $XDG_DATA_HOME/charly/images — see +// imagestore.go), and then materialised into the run engine, because every later read of it — its +// capability labels, its deploy, its status, its inline merge, `charly clean` — resolves it +// through that engine. The import needs NO tag step: the annotation names the image (measured on +// podman/docker/nerdctl), and the import verifies the reference resolves. +// +// A parent box that is present in the charly image store is bound to the daemon as an OCI layout +// named context, so a box-on-box build never reaches the registry for it. Each built image is ALSO +// pushed to the LOCAL CHARLY OCI REGISTRY under its FullTag, and the daemon mirrors +// `ghcr.io/opencharly` to that registry (DaemonOptions.RegistryMirror), so any base the store does +// not hold resolves internally instead of against the upstream registry. // The engine-named drivers this replaces ({podman|docker} build --layers, docker buildx --push, // podman build --manifest, the cache-arg matrix and the push retry ladder) are DELETED, which is // what closes plugin-build#29 by deletion: every cell of its engine×flag matrix belonged to one @@ -21,6 +27,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/opencharly/sdk/buildkitdrive" "github.com/opencharly/sdk/kit" @@ -86,10 +93,38 @@ func (c driveConfig) buildImage(ctx context.Context, box spec.BuildResolveBox, c Platforms: buildPlatforms(c.Platform, box.Platforms), NoCache: c.NoCache, } + + // A parent box built LOCALLY is supplied from the charly image store, not the registry: the + // daemon is handed the store as an OCI layout and the base reference is bound to the parent's + // manifest digest as a NAMED CONTEXT, which is the one form the dockerfile frontend resolves + // from buildkit's `--oci-layout` store (measured — `FROM oci-layout://` itself is not a valid + // dockerfile reference). The registry stays the fallback: when the base is absent from the + // store, nothing is bound and the daemon resolves the reference normally. + baseRef := containerfileBaseRef(containerfile) + baseDigest := "" + if baseRef != "" { + storeDir, serr := imageStoreDir() + if serr != nil { + return serr + } + digest, found, serr := storeLookup(baseRef) + if serr != nil { + return serr + } + if found { + baseDigest = digest + build.OCILayouts = []buildkitdrive.OCILayout{{ID: imageStoreID, Dir: storeDir}} + fmt.Fprintf(os.Stderr, "--- %s: base %s from the charly image store ---\n", box.Name, baseRef) + } + } + args, err := build.Args(dest) if err != nil { return err } + if baseDigest != "" { + args = append(args, "--opt", "context:"+baseRef+"=oci-layout://"+imageStoreID+"@"+baseDigest) + } if c.Push { args = append(args, pushOutput(box.FullTag)...) } @@ -108,12 +143,23 @@ func (c driveConfig) buildImage(ctx context.Context, box spec.BuildResolveBox, c return fmt.Errorf("building %s: %w", box.Name, err) } - // The bridge: load, tag, and VERIFY the reference resolves. The tag re-application is a named - // duty of the kit's LoadExport, not a caveat — a load returns success while landing an - // OCI-exported image UNTAGGED, so without it the CalVer ref would resolve to nothing and every - // downstream verb would break. - if err := buildkitdrive.LoadExport(ctx, runner, container.EngineBinary(c.RunEngine), dest, box.FullTag); err != nil { - return fmt.Errorf("bridging %s into the %s store: %w", box.Name, c.RunEngine, err) + // Name the archive, then make the image durable in the charly image store, then materialise it + // into the run engine. + // + // ⚑ There is NO tag step. The export is annotated with `io.containerd.image.name=` + // BEFORE the import, and every engine measured applies that annotation on load — so the + // reference resolves because of the annotation, not because a second command re-applied it. + // The import VERIFIES the reference resolves, so a stripped annotation cannot pass for a + // successful build. + created := time.Now().UTC() + if err := annotateExport(dest, box.FullTag, created); err != nil { + return fmt.Errorf("naming the %s export: %w", box.Name, err) + } + if _, err := storeAdd(dest, box.FullTag); err != nil { + return fmt.Errorf("storing %s in the charly image store: %w", box.Name, err) + } + if err := importExport(ctx, runner, c.RunEngine, dest, box.FullTag); err != nil { + return fmt.Errorf("importing %s into the %s store: %w", box.Name, c.RunEngine, err) } // The export is a full copy of the image, and keeping one per build on a persistent root is @@ -125,6 +171,30 @@ func (c driveConfig) buildImage(ctx context.Context, box spec.BuildResolveBox, c return nil } +// importExport materialises an ANNOTATED OCI archive into the run engine and proves the reference +// resolves. +// +// The engine binary and the existence probe both come from the capability table, never from a +// literal in this file: every engine measured takes ` load -i ` and exposes a +// table-provided probe (`image exists` / `image inspect`), so the drive names no engine. No `tag` +// step follows the load — the archive's `io.containerd.image.name` annotation is what names the +// image, and the probe below is what proves it did. +func importExport(ctx context.Context, r buildkitdrive.Runner, runEngine, tarPath, ref string) error { + engine := container.EngineBinary(runEngine) + if _, err := r.Run(ctx, engine, "load", "-i", tarPath); err != nil { + return err + } + capability, ok := container.EngineCapabilityFor(runEngine) + if !ok || len(capability.ImageExistsArgv) == 0 { + return fmt.Errorf("the engine capability table carries no image-exists probe for %q", runEngine) + } + probe := append(append([]string{}, capability.ImageExistsArgv...), ref) + if _, err := r.Run(ctx, engine, probe...); err != nil { + return fmt.Errorf("the loaded image is not resolvable as %s — the archive's %s annotation is what names it: %w", ref, imageNameAnnotation, err) + } + return nil +} + // localRegistryRef maps a built image onto the local charly OCI registry under the mirror // path the daemon resolves (ghcr.io/opencharly/: -> /opencharly/:). func localRegistryRef(mirror, fullTag string) string { diff --git a/candy/plugin-build/buildkit_live_test.go b/candy/plugin-build/buildkit_live_test.go index de2aa1a..949193e 100644 --- a/candy/plugin-build/buildkit_live_test.go +++ b/candy/plugin-build/buildkit_live_test.go @@ -39,6 +39,10 @@ func TestBuildImage_LiveBuildsAndBridgesIntoTheRunEngine(t *testing.T) { } } + // A live run writes the CHARLY IMAGE STORE, so point it (and the index lock's state root) at + // temp dirs: a check must never mutate the operator's store. + liveStoreDir(t) + const ( name = "leg4live" tag = "localhost/leg4-live:2026.287.1200" diff --git a/candy/plugin-build/charly.yml b/candy/plugin-build/charly.yml index e27fbda..cbdb6bd 100644 --- a/candy/plugin-build/charly.yml +++ b/candy/plugin-build/charly.yml @@ -13,19 +13,25 @@ plugin-build: the host only for what a sdk-only candy structurally cannot do (the bootstrap-delicate local scan, the git fetch, the build-time plugin connect, the host-fs prep) over a small `buildengine-*` HostBuild leg family; the layer MERGE crosses to verb:oci via - InvokeProvider (no HostBuild("merge") seam). Each stage is built with BuildKit and its - image is STORED IN THE CONFIGURED CONTAINER BACKEND (the SDK `LoadExport`), then pushed - to the LOCAL CHARLY OCI REGISTRY under the same FullTag, which the daemon mirrors - `ghcr.io/opencharly` to — so the next stage's `FROM` resolves an internal base locally, - never against the upstream registry. build:generate renders the .build/ + InvokeProvider (no HostBuild("merge") seam). Each image is built with BuildKit, its + export NAMED with `io.containerd.image.name=`, written to the engine-neutral + CHARLY IMAGE STORE (one OCI layout at $XDG_DATA_HOME/charly/images, see imagestore.go), + and materialised into the configured container backend with NO tag step — the + annotation names the image on load. A parent box present in the store is supplied to + the solve as an `--oci-layout` named context (buildkitdrive.Build.OCILayouts), so a + box-on-box build does not reach the registry for it; each image is ALSO pushed to the + LOCAL CHARLY OCI REGISTRY under the same FullTag, which the daemon mirrors + `ghcr.io/opencharly` to, so any base the store does not hold resolves internally + rather than against the upstream registry. build:generate renders the .build/ Containerfile tree in its own code (via sdk/deploykit, #67) and returns the written paths (no podman, no merge); build:ensure resolves a project-authored image identifier against charly.yml PLUGIN-SIDE (loaderkit.LoadUnified via the K1 loader reverse legs — no "box-ref-resolve" HostBuild seam, shed #55 coneK1 #8) and a remote @github.com/... identifier via HostBuild("remote-image-resolve", …) (git clone/cache - ONLY, then plugin-side resolve), then execs `podman pull`/`podman tag` directly (no - seam needed for that — same host, same privilege as the build drive's own podman exec). - Only the wire envelopes cross the seam; the podman exec happens IN the candy. COMPILED-IN (in the + ONLY, then plugin-side resolve), then pulls/tags through the resolved run engine's + binary from the engine capability table (no engine word literal, no seam needed — + same host, same privilege as the build drive). + Only the wire envelopes cross the seam; the engine exec happens IN the candy. COMPILED-IN (in the embedded compiled_plugins:) — `charly box build` / `charly box generate` / every dispatchBuildEnsure caller dispatch it IN-PROCESS, the host threading the reverse channel onto the Invoke context so HostBuild is reachable without a go-plugin broker. diff --git a/candy/plugin-build/ensure.go b/candy/plugin-build/ensure.go index 4781d73..3e18814 100644 --- a/candy/plugin-build/ensure.go +++ b/candy/plugin-build/ensure.go @@ -44,6 +44,7 @@ import ( "github.com/opencharly/sdk" "github.com/opencharly/sdk/kit" "github.com/opencharly/sdk/loaderkit" + "github.com/opencharly/spec/container" "github.com/opencharly/spec/spec" ) @@ -52,10 +53,9 @@ func runBoxEnsure(ctx context.Context, ex *sdk.Executor, req spec.BuildEnsureReq if req.Image == "" { return fmt.Errorf("ensure-image: empty image identifier") } + // An empty engine means "the default engine", which EngineBinary/EngineCapabilityFor resolve + // through the capability table — this file names no engine. runEngine := req.RunEngine - if runEngine == "" { - runEngine = "podman" - } // Tier 1: already present. Only matches a FULL ref (podman/docker "image exists" needs a // real storage key — a bare short name never matches here); a short name's existence check @@ -75,7 +75,7 @@ func runBoxEnsure(ctx context.Context, ex *sdk.Executor, req spec.BuildEnsureReq // split, and the bridge is what closed it. // Tier 3: resolve + pull + build-fallback. - return ensureViaResolveAndBuild(ctx, ex, req.Image, req.Dir) + return ensureViaResolveAndBuild(ctx, ex, req.Image, req.Dir, runEngine) } // ensureViaResolveAndBuild implements tier 3. A remote (@github.com/...) ref resolves via the @@ -85,15 +85,15 @@ func runBoxEnsure(ctx context.Context, ex *sdk.Executor, req spec.BuildEnsureReq // directly, no host seam). Both branches mirror the deleted core ensure-image helper's algorithm // verbatim: check existence of the resolved ref, attempt a pull, and on failure fall back to a // build. -func ensureViaResolveAndBuild(ctx context.Context, ex *sdk.Executor, image, dir string) error { +func ensureViaResolveAndBuild(ctx context.Context, ex *sdk.Executor, image, dir, runEngine string) error { stripped := kit.StripURLScheme(image) if spec.IsRemoteImageRef(stripped) { - return ensureRemoteRef(ctx, ex, image, stripped) + return ensureRemoteRef(ctx, ex, image, stripped, runEngine) } - return ensureProjectRef(ctx, ex, image, dir) + return ensureProjectRef(ctx, ex, image, dir, runEngine) } -func ensureRemoteRef(ctx context.Context, ex *sdk.Executor, image, stripped string) error { +func ensureRemoteRef(ctx context.Context, ex *sdk.Executor, image, stripped, runEngine string) error { rr, rerr := remoteImageResolve(ctx, ex, stripped, "") if rerr != nil || rr.Error != "" { errMsg := rr.Error @@ -113,12 +113,12 @@ func ensureRemoteRef(ctx context.Context, ex *sdk.Executor, image, stripped stri // where a remote box with no resolvable pull ref just built from source. imageRef := resolveRemoteImageRef(ctx, ex, rr.CacheDir, rr.BoxName) if imageRef != "" { - if kit.LocalImageExists("podman", imageRef) { + if kit.LocalImageExists(runEngine, imageRef) { fmt.Fprintf(os.Stderr, "ensure-image: %s present\n", imageRef) return nil } fmt.Fprintf(os.Stderr, "ensure-image: pulling %s\n", imageRef) - if err := podmanPull(ctx, imageRef); err == nil { + if err := enginePull(ctx, runEngine, imageRef); err == nil { return nil } else { fmt.Fprintf(os.Stderr, "ensure-image: pull %s failed: %v\n", imageRef, err) @@ -128,7 +128,7 @@ func ensureRemoteRef(ctx context.Context, ex *sdk.Executor, image, stripped stri if _, berr := runBoxBuild(ctx, ex, spec.BuildRequest{Boxes: []string{rr.BoxName}, Dir: rr.CacheDir}); berr != nil { return fmt.Errorf("ensure-image %q: pull failed and remote build failed: %w", image, berr) } - return aliasBuiltImageOntoRequestedRef(ctx, image, func() string { + return aliasBuiltImageOntoRequestedRef(ctx, image, runEngine, func() string { return resolveRemoteImageRef(ctx, ex, rr.CacheDir, rr.BoxName) }) } @@ -151,10 +151,10 @@ func ensureRemoteRef(ctx context.Context, ex *sdk.Executor, image, stripped stri // A failed alias is a HARD error, not a warning: the caller cannot proceed without the ref, and a // warning here only relocates the failure to a downstream message that names neither this build // nor the alias. -func aliasBuiltImageOntoRequestedRef(ctx context.Context, image string, resolveProduced func() string) error { +func aliasBuiltImageOntoRequestedRef(ctx context.Context, image, runEngine string, resolveProduced func() string) error { // A short-name input carries no tag to satisfy, and a build that already produced the exact // requested ref needs nothing further. - if !kit.LooksLikeFullRef(image) || kit.LocalImageExists("podman", image) { + if !kit.LooksLikeFullRef(image) || kit.LocalImageExists(runEngine, image) { return nil } produced := resolveProduced() @@ -162,7 +162,7 @@ func aliasBuiltImageOntoRequestedRef(ctx context.Context, image string, resolveP return fmt.Errorf("ensure-image %q: the build reported success but produced no local ref that satisfies it "+ "(resolved produced ref: %q) — the requested ref is still absent from local storage", image, produced) } - if terr := podmanTag(ctx, produced, image); terr != nil { + if terr := engineTag(ctx, runEngine, produced, image); terr != nil { return fmt.Errorf("ensure-image %q: tagging the built %s as the requested ref failed: %w", image, produced, terr) } fmt.Fprintf(os.Stderr, "ensure-image: tagged %s as %s\n", produced, image) @@ -195,7 +195,7 @@ func resolveRemoteImageRef(ctx context.Context, ex *sdk.Executor, dir, boxName s return kit.ResolveShellImageRef(registry, boxName, "") } -func ensureProjectRef(ctx context.Context, ex *sdk.Executor, image, dir string) error { +func ensureProjectRef(ctx context.Context, ex *sdk.Executor, image, dir, runEngine string) error { // Plugin-side self-resolve (#55 coneK1 #8): load the project cfg via the K1 loader reverse // legs + compute all 4 box-ref reply fields directly — NO "box-ref-resolve" HostBuild seam // (deleted) and NO deploykit.ResolveSpecBox (the host-side deploykit call is shed from this @@ -213,14 +213,14 @@ func ensureProjectRef(ctx context.Context, ex *sdk.Executor, image, dir string) existsRef := resolveImageRefPlugin(image, cfg) fallbackShort := buildableShortNamePlugin(image, cfg) - if existsRef != "" && kit.LocalImageExists("podman", existsRef) { + if existsRef != "" && kit.LocalImageExists(runEngine, existsRef) { fmt.Fprintf(os.Stderr, "ensure-image: %s present\n", existsRef) return nil } if existsRef != "" { fmt.Fprintf(os.Stderr, "ensure-image: pulling %s\n", existsRef) - if perr := podmanPull(ctx, existsRef); perr == nil { + if perr := enginePull(ctx, runEngine, existsRef); perr == nil { return nil } else { fmt.Fprintf(os.Stderr, "ensure-image: pull %s failed: %v\n", existsRef, perr) @@ -241,7 +241,7 @@ func ensureProjectRef(ctx context.Context, ex *sdk.Executor, image, dir string) return fmt.Errorf("ensure-image %q: pull failed and local build failed: %w", image, berr) } - return aliasBuiltImageOntoRequestedRef(ctx, image, func() string { + return aliasBuiltImageOntoRequestedRef(ctx, image, runEngine, func() string { return resolveImageRefPlugin(fallbackShort, cfg) }) } @@ -354,19 +354,21 @@ func remoteImageResolve(ctx context.Context, ex *sdk.Executor, ref, tag string) return reply, nil } -// podmanPull invokes `podman pull ` on the local machine. Errors propagate verbatim; the -// caller decides whether to fall back to a local build. -func podmanPull(ctx context.Context, ref string) error { - cmd := exec.CommandContext(ctx, "podman", "pull", ref) +// enginePull pulls a reference into the run engine. The binary comes from the capability table, +// so this file names no engine; errors propagate verbatim and the caller decides whether to fall +// back to a local build. +func enginePull(ctx context.Context, runEngine, ref string) error { + cmd := exec.CommandContext(ctx, container.EngineBinary(runEngine), "pull", ref) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr return cmd.Run() } -// podmanTag adds a second tag to an existing local image. Used to satisfy a tag-pinned input ref -// after a local build produced a different (CalVer) tag of the same image. -func podmanTag(ctx context.Context, src, dst string) error { - cmd := exec.CommandContext(ctx, "podman", "tag", src, dst) +// engineTag adds a second tag to an existing local image. Used to satisfy a tag-pinned input ref +// after a local build produced a different (CalVer) tag of the same image. The binary comes from +// the capability table, so this file names no engine. +func engineTag(ctx context.Context, runEngine, src, dst string) error { + cmd := exec.CommandContext(ctx, container.EngineBinary(runEngine), "tag", src, dst) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr return cmd.Run() diff --git a/candy/plugin-build/ensure_alias_test.go b/candy/plugin-build/ensure_alias_test.go index 5309cc6..c470869 100644 --- a/candy/plugin-build/ensure_alias_test.go +++ b/candy/plugin-build/ensure_alias_test.go @@ -25,7 +25,8 @@ import ( // resolve was consulted and what error came back. func aliasOutcome(t *testing.T, image, produced string) (consulted bool, err error) { t.Helper() - err = aliasBuiltImageOntoRequestedRef(context.Background(), image, func() string { + // "" means the default engine — the helper resolves the binary through the capability table. + err = aliasBuiltImageOntoRequestedRef(context.Background(), image, "", func() string { consulted = true return produced }) diff --git a/candy/plugin-build/go.mod b/candy/plugin-build/go.mod index 317ee93..e53eaf0 100644 --- a/candy/plugin-build/go.mod +++ b/candy/plugin-build/go.mod @@ -1,16 +1,19 @@ module github.com/opencharly/plugin-build/candy/plugin-build -go 1.26.4 +go 1.26.8 require ( - github.com/opencharly/sdk v0.2026293.417 - golang.org/x/sync v0.22.0 + github.com/opencharly/sdk v0.2026296.803 + golang.org/x/sync v0.23.0 ) require ( + github.com/containerd/containerd/v2 v2.4.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/log v0.2.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) @@ -29,16 +32,16 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/oklog/run v1.1.0 // indirect - github.com/opencharly/spec v0.2026289.611 - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/opencharly/spec v0.2026295.2356 + github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sys v0.47.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.48.0 // indirect golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect - google.golang.org/grpc v1.61.0 - google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect + google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/yaml.v3 v3.0.1 ) diff --git a/candy/plugin-build/go.sum b/candy/plugin-build/go.sum index 8802ec0..cddcccf 100644 --- a/candy/plugin-build/go.sum +++ b/candy/plugin-build/go.sum @@ -2,6 +2,10 @@ cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWp cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE= cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= +github.com/Microsoft/hcsshim v0.15.0-rc.4 h1:aZFX4LH0S20Lgjq0wG61StIClj7im4yzrxIClkaR8Z8= +github.com/Microsoft/hcsshim v0.15.0-rc.4/go.mod h1:BA9CBztgu4h/6Jsvo1O1M4qjWw09PoYpaEYgexPE578= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI= @@ -10,17 +14,33 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ= +github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= +github.com/containerd/containerd/api v1.12.0 h1:kuQm82SbDrCuO4n7hf2L8zsBtZLuympyq5X/VotfX2A= +github.com/containerd/containerd/api v1.12.0/go.mod h1:EBcSzoi9Vl18cdODaXUCskf3D2NT8lsSXeZJnU5jIUc= +github.com/containerd/containerd/v2 v2.4.1 h1:DUx/ZJN7cEu0WuzHClDB+68H/bqMEH5pWoEjf0ae4hc= +github.com/containerd/containerd/v2 v2.4.1/go.mod h1:vgLdtvl3prFk1d3ZVqQcsDD5Q9IKM+vAIdU54U85w+I= +github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= +github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc= +github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q= github.com/emicklei/proto v1.14.3/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -61,45 +81,63 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= +github.com/moby/sys/userns v0.2.1 h1:4OvdM7BcPkASbuouHsbW3aeMJSFlYDldBRnXVZhaRk8= +github.com/moby/sys/userns v0.2.1/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/opencharly/plugin-egress/candy/plugin-egress v0.2026237.1419 h1:n3aw1FXKRwiGXHYWn3sJhxdIi75/c++3czIszZBbITc= github.com/opencharly/plugin-egress/candy/plugin-egress v0.2026237.1419/go.mod h1:5Uz4H7GYjW9WS8yPJGlr+dRv/jrR985d+PbaxJeAJZ8= github.com/opencharly/plugin-init/candy/plugin-init v0.2026237.1424 h1:Bldh1DoESB1OhP9itppLcViak/z/INZRIFv+Nm28KNg= github.com/opencharly/plugin-init/candy/plugin-init v0.2026237.1424/go.mod h1:tzB4pMZllnxK6Jpyeq5jxg4EQe1p544rQkhNdmPn788= -github.com/opencharly/sdk v0.2026293.417 h1:QOqALvHfq0oNsPoqq46+ZEw1C2xWfG5zD31tGVUfDcw= -github.com/opencharly/sdk v0.2026293.417/go.mod h1:AUCggYkOCUZ+H11OAiPD/BXLSU8L0uW5t0u3ixM4gos= -github.com/opencharly/spec v0.2026289.611 h1:omYzOZLdDvRKAS2pVBj+8bOHb2/Kw1Zb/jzxiFe+hiA= -github.com/opencharly/spec v0.2026289.611/go.mod h1:VrjzjP2glMbDwP4JNm50OV0MraHFZnFKTl+bVlouoso= +github.com/opencharly/sdk v0.2026296.803 h1:Jh9ugSGnk4VlsNxRYTMUzW5no/ecoMDo1uSx6GaKZb0= +github.com/opencharly/sdk v0.2026296.803/go.mod h1:hW/TyFRQGMKeRf1k8YyAHqtyxNHAIgi/v9/PJ6MYHKo= +github.com/opencharly/spec v0.2026295.2356 h1:SmZJjQpUZusBTnGzesWYoLYZCngrMDJTr74jlJlTc1Q= +github.com/opencharly/spec v0.2026295.2356/go.mod h1:VrjzjP2glMbDwP4JNm50OV0MraHFZnFKTl+bVlouoso= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 h1:2PC6Ql3jipz1KvBlqUHjjk6v4aMwE86mfDu1XMH0LR8= github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -107,20 +145,22 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= -google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= -google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= -google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= -google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/candy/plugin-build/imagestore.go b/candy/plugin-build/imagestore.go new file mode 100644 index 0000000..fa1e232 --- /dev/null +++ b/candy/plugin-build/imagestore.go @@ -0,0 +1,461 @@ +package build + +// imagestore.go — the CHARLY IMAGE STORE: the engine-neutral artefact of every build. +// +// One OCI image layout at `$XDG_DATA_HOME/charly/images/` — `oci-layout` + ONE `index.json` + +// `blobs//` — holding every image this host has built. It is engine-NEUTRAL on +// purpose: the build's canonical output must not belong to whichever container engine happens to +// be selected, because the next deploy may resolve a different one. +// +// ⚑ THE NAMING ANNOTATION IS THE WHOLE CONTRACT, and it is `io.containerd.image.name`, NOT +// `org.opencontainers.image.ref.name`. Measured on buildkit 0.34.1: a `--output +// type=oci,name=` export writes BOTH — `io.containerd.image.name` = `:` and +// `org.opencontainers.image.ref.name` = the TAG ALONE. Every engine measured (podman 6.1.4, +// docker 29.9.2, nerdctl 2.4.1) imports such an archive with the full reference already applied +// and needs NO `tag` step; an archive built WITHOUT `name=` lands untagged/dangling everywhere. +// Store lookups therefore key on `io.containerd.image.name` only — keying on the tag-only +// annotation would match the wrong image (or none) for every namespaced ref. + +import ( + "archive/tar" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/opencharly/sdk/storekit" + "github.com/opencharly/spec/lock" +) + +const ( + // imageStoreDirName is the layout's path element under the charly data root. + imageStoreDirName = "images" + // imageStoreID is the id a solve addresses the store by (`oci-layout://@`). + imageStoreID = "charly" + // imageStoreDirEnv relocates the store for a disposable target, exactly as + // CHARLY_CACHE_DIR relocates the cache root. A check bed MUST use it: a bed's build must + // not write the operator's store. + imageStoreDirEnv = "CHARLY_IMAGE_STORE_DIR" + + // imageNameAnnotation is the annotation that NAMES an image inside an OCI layout index. + imageNameAnnotation = "io.containerd.image.name" + // refNameAnnotation carries the TAG ALONE in a buildkit export; it is never the lookup key. + refNameAnnotation = "org.opencontainers.image.ref.name" + // createdAnnotation dates an entry, and is the store's recency key. + createdAnnotation = "org.opencontainers.image.created" + + // imageStoreLockName is the store-index lock, deliberately OUTSIDE the store tree: a lock is + // transient state with no content address and must never live among content-addressed blobs. + imageStoreLockName = "image-store.lock" +) + +// imageStoreLayoutMarker is the `oci-layout` file's content. +const imageStoreLayoutMarker = `{"imageLayoutVersion":"1.0.0"}` + +// storeIndex is an OCI image layout's `index.json`. It is deliberately the full descriptor shape, +// so a rewritten export keeps every field buildkit wrote. +type storeIndex struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType,omitempty"` + Manifests []storeManifest `json:"manifests"` +} + +// storeManifest is one image in the index. Annotations is where the two naming keys live. +type storeManifest struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` + Annotations map[string]string `json:"annotations,omitempty"` + Platform json.RawMessage `json:"platform,omitempty"` +} + +// imageStoreDir resolves the charly image store. +// +// The store lives under the DATA root (`$XDG_DATA_HOME/charly/images`), not the cache root: a +// built image is a durable artefact, and the cache root's `images` path is the RETIRED pre-rename +// blob store that plugin-clean's retirement record still sweeps. +// +// WORKAROUND (opencharly/sdk#458): storekit — the ONE roots resolver — has no data root yet, so +// this file resolves `$XDG_DATA_HOME` itself. When RootData lands, this function becomes +// `storekit.Dir(storekit.RootData)` + `images` and the local resolution goes away. It is not a +// second resolver for an EXISTING root, which is the duplication storekit's doctrine forbids. +func imageStoreDir() (string, error) { + if dir := os.Getenv(imageStoreDirEnv); dir != "" { + return dir, nil + } + base := os.Getenv("XDG_DATA_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve the charly image store: %w", err) + } + base = filepath.Join(home, ".local", "share") + } + return filepath.Join(base, "charly", imageStoreDirName), nil +} + +// storeIndexPath is the layout's entry point. +func storeIndexPath(dir string) string { return filepath.Join(dir, "index.json") } + +// ensureLayout creates the layout directories and the `oci-layout` marker. It is idempotent. +func ensureLayout(dir string) error { + if err := os.MkdirAll(filepath.Join(dir, "blobs", "sha256"), 0o755); err != nil { + return fmt.Errorf("prepare the charly image store %s: %w", dir, err) + } + marker := filepath.Join(dir, storekit.LayoutFile) + if _, err := os.Stat(marker); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect the charly image store marker: %w", err) + } + return writeFileAtomic(marker, []byte(imageStoreLayoutMarker)) +} + +// writeFileAtomic publishes data at dest through a UNIQUE temp file in dest's own directory, then +// renames. Concurrent writers must never share a temp name, and a reader must never see a partial +// index. +func writeFileAtomic(dest string, data []byte) error { + f, err := os.CreateTemp(filepath.Dir(dest), "."+filepath.Base(dest)+".*") + if err != nil { + return err + } + name := f.Name() + defer func() { _ = os.Remove(name) }() // no-op once the rename has consumed it + if _, err := f.Write(data); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(name, dest) +} + +// readStoreIndex reads the layout's index. A missing index is an EMPTY store, never an error. +func readStoreIndex(dir string) (*storeIndex, error) { + data, err := os.ReadFile(storeIndexPath(dir)) + if errors.Is(err, os.ErrNotExist) { + return &storeIndex{SchemaVersion: 2, MediaType: "application/vnd.oci.image.index.v1+json"}, nil + } + if err != nil { + return nil, fmt.Errorf("read the charly image store index: %w", err) + } + var idx storeIndex + if err := json.Unmarshal(data, &idx); err != nil { + return nil, fmt.Errorf("parse the charly image store index %s: %w", storeIndexPath(dir), err) + } + return &idx, nil +} + +// storeLookup returns the MANIFEST digest a reference resolves to inside the store, keyed on +// `io.containerd.image.name` — the full reference — because that is the annotation the engines +// apply and the only one that names a namespaced ref unambiguously. +func storeLookup(ref string) (digest string, ok bool, err error) { + dir, err := imageStoreDir() + if err != nil { + return "", false, err + } + idx, err := readStoreIndex(dir) + if err != nil { + return "", false, err + } + for _, m := range idx.Manifests { + if m.Annotations[imageNameAnnotation] == ref { + return m.Digest, true, nil + } + } + return "", false, nil +} + +// annotateExport writes the naming/creation annotations into an exported OCI archive's +// `index.json`, IN PLACE (through a temp file + rename). +// +// This is the step that makes the archive self-naming: the engine import that follows applies the +// reference from `io.containerd.image.name`, so the drive no longer needs the load→tag→verify +// dance the export bridge used to require. +func annotateExport(tarPath, ref string, created time.Time) error { + if strings.TrimSpace(ref) == "" { + return fmt.Errorf("annotate the OCI export: no reference — the archive would be unaddressable") + } + return rewriteExportIndex(tarPath, func(idx *storeIndex) { + for i := range idx.Manifests { + if idx.Manifests[i].Annotations == nil { + idx.Manifests[i].Annotations = map[string]string{} + } + idx.Manifests[i].Annotations[imageNameAnnotation] = ref + idx.Manifests[i].Annotations[refNameAnnotation] = refTag(ref) + idx.Manifests[i].Annotations[createdAnnotation] = created.UTC().Format(time.RFC3339) + } + }) +} + +// rewriteExportIndex applies mutate to the archive's `index.json` and rewrites the archive. +func rewriteExportIndex(tarPath string, mutate func(*storeIndex)) error { + f, err := os.Open(tarPath) + if err != nil { + return fmt.Errorf("open the OCI export %s: %w", tarPath, err) + } + defer func() { _ = f.Close() }() // read-only open; the close error carries nothing + + tmp, err := os.CreateTemp(filepath.Dir(tarPath), "."+filepath.Base(tarPath)+".*") + if err != nil { + return fmt.Errorf("stage the OCI export %s: %w", tarPath, err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename has consumed it + + tr := tar.NewReader(f) + tw := tar.NewWriter(tmp) + replaced := false + for { + hdr, rerr := tr.Next() + if errors.Is(rerr, io.EOF) { + break + } + if rerr != nil { + _ = tw.Close() + _ = tmp.Close() + return fmt.Errorf("read the OCI export %s: %w", tarPath, rerr) + } + if strings.TrimPrefix(hdr.Name, "./") == "index.json" { + raw, rerr := io.ReadAll(tr) + if rerr != nil { + _ = tw.Close() + _ = tmp.Close() + return fmt.Errorf("read index.json from %s: %w", tarPath, rerr) + } + var idx storeIndex + if uerr := json.Unmarshal(raw, &idx); uerr != nil { + _ = tw.Close() + _ = tmp.Close() + return fmt.Errorf("parse index.json from %s: %w", tarPath, uerr) + } + mutate(&idx) + out, merr := json.Marshal(idx) + if merr != nil { + _ = tw.Close() + _ = tmp.Close() + return fmt.Errorf("render index.json for %s: %w", tarPath, merr) + } + hdr.Size = int64(len(out)) + if werr := tw.WriteHeader(hdr); werr != nil { + _ = tw.Close() + _ = tmp.Close() + return fmt.Errorf("rewrite index.json in %s: %w", tarPath, werr) + } + if _, werr := tw.Write(out); werr != nil { + _ = tw.Close() + _ = tmp.Close() + return fmt.Errorf("rewrite index.json in %s: %w", tarPath, werr) + } + replaced = true + continue + } + if werr := tw.WriteHeader(hdr); werr != nil { + _ = tw.Close() + _ = tmp.Close() + return fmt.Errorf("copy %s in %s: %w", hdr.Name, tarPath, werr) + } + if _, werr := io.Copy(tw, tr); werr != nil { + _ = tw.Close() + _ = tmp.Close() + return fmt.Errorf("copy %s in %s: %w", hdr.Name, tarPath, werr) + } + } + if !replaced { + _ = tw.Close() + _ = tmp.Close() + return fmt.Errorf("the OCI export %s carries no index.json", tarPath) + } + if err := tw.Close(); err != nil { + _ = tmp.Close() + return fmt.Errorf("finish the OCI export %s: %w", tarPath, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("finish the OCI export %s: %w", tarPath, err) + } + return os.Rename(tmpName, tarPath) +} + +// exportIndex reads an archive's index without rewriting it. +func exportIndex(tarPath string) (*storeIndex, error) { + f, err := os.Open(tarPath) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + tr := tar.NewReader(f) + for { + hdr, rerr := tr.Next() + if errors.Is(rerr, io.EOF) { + break + } + if rerr != nil { + return nil, rerr + } + if strings.TrimPrefix(hdr.Name, "./") != "index.json" { + continue + } + raw, rerr := io.ReadAll(tr) + if rerr != nil { + return nil, rerr + } + var idx storeIndex + if uerr := json.Unmarshal(raw, &idx); uerr != nil { + return nil, uerr + } + return &idx, nil + } + return nil, fmt.Errorf("the OCI export %s carries no index.json", tarPath) +} + +// storeAdd copies an exported archive's blobs into the store and merges its manifest descriptor(s) +// into the store index, replacing any entry that already names the same reference. One descriptor +// is written per manifest in the export (a multi-platform `--push` export contributes one per +// platform); a rebuild replaces the whole previous set for that reference. +// +// The blobs are content-addressed, so an existing blob is the same bytes and is left alone; the +// index is the only mutable file and it is rewritten atomically under a cross-process lock (two +// levels of a build write the store concurrently — see drive.go's errgroup). +func storeAdd(tarPath, ref string) (string, error) { + dir, err := imageStoreDir() + if err != nil { + return "", err + } + if err := ensureLayout(dir); err != nil { + return "", err + } + exported, err := exportIndex(tarPath) + if err != nil { + return "", fmt.Errorf("read the OCI export index: %w", err) + } + if len(exported.Manifests) == 0 { + return "", fmt.Errorf("the OCI export %s carries no image manifest", tarPath) + } + if exported.Manifests[0].Annotations[imageNameAnnotation] == "" { + // A store entry without the naming annotation is not addressable, which is the whole + // contract of this file — refuse rather than write a dangling entry. + return "", fmt.Errorf("the OCI export %s carries no %s annotation", tarPath, imageNameAnnotation) + } + if err := copyBlobsIntoStore(tarPath, dir); err != nil { + return "", err + } + + lockPath, err := storeLockPath() + if err != nil { + return "", err + } + release, err := lock.AcquireFileLock(lockPath, true) + if err != nil { + return "", fmt.Errorf("lock the charly image store index: %w", err) + } + defer func() { _ = release() }() // the release error carries nothing a caller can act on + + idx, err := readStoreIndex(dir) + if err != nil { + return "", err + } + kept := idx.Manifests[:0] + for _, m := range idx.Manifests { + if m.Annotations[imageNameAnnotation] == ref { + continue // superseded by this build + } + kept = append(kept, m) + } + idx.Manifests = append(kept, exported.Manifests...) + data, err := json.Marshal(idx) + if err != nil { + return "", fmt.Errorf("render the charly image store index: %w", err) + } + if err := writeFileAtomic(storeIndexPath(dir), data); err != nil { + return "", fmt.Errorf("write the charly image store index: %w", err) + } + return exported.Manifests[0].Digest, nil +} + +// copyBlobsIntoStore copies every `blobs/` entry of the archive into the layout, skipping blobs +// that already exist. Names are validated so a crafted archive cannot escape the store tree. +func copyBlobsIntoStore(tarPath, dir string) error { + f, err := os.Open(tarPath) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + tr := tar.NewReader(f) + for { + hdr, rerr := tr.Next() + if errors.Is(rerr, io.EOF) { + return nil + } + if rerr != nil { + return fmt.Errorf("read the OCI export %s: %w", tarPath, rerr) + } + name := strings.TrimPrefix(hdr.Name, "./") + if !strings.HasPrefix(name, "blobs/") || hdr.Typeflag != tar.TypeReg { + continue + } + if strings.Contains(name, "..") || filepath.IsAbs(name) { + return fmt.Errorf("the OCI export %s carries an unsafe blob path %q", tarPath, name) + } + dest := filepath.Join(dir, filepath.FromSlash(name)) + if _, serr := os.Stat(dest); serr == nil { + continue + } else if !errors.Is(serr, os.ErrNotExist) { + return serr + } + if merr := os.MkdirAll(filepath.Dir(dest), 0o755); merr != nil { + return merr + } + data, rerr := io.ReadAll(tr) + if rerr != nil { + return fmt.Errorf("read blob %s from %s: %w", name, tarPath, rerr) + } + if werr := writeFileAtomic(dest, data); werr != nil { + return fmt.Errorf("write blob %s: %w", name, werr) + } + } +} + +// storeLockPath is the store-index lock, in the state root (never inside the store tree). +func storeLockPath() (string, error) { + state, err := storekit.Dir(storekit.RootState) + if err != nil { + return "", fmt.Errorf("resolve the charly state root for the image store lock: %w", err) + } + return filepath.Join(state, "locks", imageStoreLockName), nil +} + +// refTag returns the tag of a reference: everything after the last colon that follows the last +// slash. A digest-pinned ref has no tag and yields "". +func refTag(ref string) string { + if strings.ContainsRune(ref, '@') { + return "" // a digest-pinned reference carries no tag + } + slash := strings.LastIndexByte(ref, '/') + colon := strings.LastIndexByte(ref, ':') + if colon <= slash || colon == len(ref)-1 { + return "" + } + return ref[colon+1:] +} + +// containerfileBaseRef extracts the direct base reference from a generated Containerfile. The +// renderer emits `ARG BASE_IMAGE=` as the first instruction of a non-scratch box (see +// sdk/deploykit/generate.go), and the drive overrides that ARG when the base is in the store. +func containerfileBaseRef(containerfile string) string { + for _, line := range strings.Split(containerfile, "\n") { + rest, ok := strings.CutPrefix(strings.TrimSpace(line), "ARG BASE_IMAGE=") + if ok { + return strings.TrimSpace(rest) + } + } + return "" +} diff --git a/candy/plugin-build/imagestore_live_test.go b/candy/plugin-build/imagestore_live_test.go new file mode 100644 index 0000000..53282b4 --- /dev/null +++ b/candy/plugin-build/imagestore_live_test.go @@ -0,0 +1,187 @@ +package build + +// imagestore_live_test.go — the LIVE proof of the two claims W3.6 rests on. +// +// Both tests SKIP VISIBLY when the toolchain is absent (R8) and are never mocked. They live in the +// module for the same reason buildkit_live_test.go does: assembling `charly` requires every pinned +// plugin to compile against the contract, so the changed path is driven directly. +// +// 1. The OCI naming annotation, WITH ITS NEGATIVE CONTROL. A `name=` export imports +// tagged; the same archive with the annotation stripped imports DANGLING. That is what proves +// the annotation — not coincidence, and not a tag step — is doing the naming. +// 2. A box-on-box build whose parent is in the charly image store while the parent's registry is +// UNREACHABLE, with the control that the same child fails without the store binding. + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/opencharly/sdk/buildkitdrive" + "github.com/opencharly/sdk/kit" + "github.com/opencharly/spec/spec" +) + +// liveStoreDir points a live run at a store. A check must NEVER mutate the operator's store, so +// the default is a temp dir; a caller that already exported CHARLY_IMAGE_STORE_DIR (a disposable +// bed, or a run keeping its emitted evidence) keeps its own. +func liveStoreDir(t *testing.T) { + t.Helper() + t.Setenv("XDG_STATE_HOME", t.TempDir()) + if os.Getenv(imageStoreDirEnv) == "" { + t.Setenv(imageStoreDirEnv, t.TempDir()) + } +} + +// liveTools skips unless the whole toolchain is present. +func liveTools(t *testing.T, tools ...string) { + t.Helper() + if testing.Short() { + t.Skip("live build: needs the BuildKit toolchain and a container runtime") + } + for _, tool := range tools { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not installed — skipping the live store proof: %v", tool, err) + } + } +} + +// A named export lands TAGGED; the same archive with `io.containerd.image.name` removed lands +// DANGLING. Both directions matter: without the control, the tagged result could be an artefact of +// the image already existing in the engine's store. +func TestImageStore_AnnotationNamesTheImport_AndItsAbsenceDoesNot(t *testing.T) { + liveTools(t, "rootlesskit", "buildkitd", "buildctl", "podman") + liveStoreDir(t) + + ctx := context.Background() + dir := t.TempDir() + cfg := driveConfig{RunEngine: "podman", Dir: dir, BuildDir: filepath.Join(dir, ".build")} + + const named = "localhost/w36-named-live:2026.296.1200" + namedFile := "FROM alpine:3.20\nLABEL w36=named\nCMD [\"/bin/true\"]\n" + if err := cfg.buildImage(ctx, spec.BuildResolveBox{Name: "w36named", FullTag: named}, namedFile); err != nil { + t.Fatalf("buildImage: %v", err) + } + defer exec.Command("podman", "rmi", "-f", named).Run() //nolint:errcheck + + if !kit.LocalImageExists("podman", named) { + t.Fatalf("%s does not resolve after the import — the naming annotation did not take", named) + } + digest, ok, err := storeLookup(named) + if err != nil || !ok || digest == "" { + t.Fatalf("the charly image store has no entry for %s (ok=%v, err=%v)", named, ok, err) + } + idx, err := readStoreIndex(mustStoreDir(t)) + if err != nil { + t.Fatal(err) + } + found := false + for _, m := range idx.Manifests { + if m.Annotations[imageNameAnnotation] == named { + found = true + if m.Annotations[createdAnnotation] == "" { + t.Errorf("the store entry for %s carries no %s", named, createdAnnotation) + } + } + } + if !found { + t.Fatalf("no store entry is annotated %s=%s", imageNameAnnotation, named) + } + + // THE CONTROL: a DIFFERENT image, exported with a name and then stripped of the naming + // annotation, must land dangling. Its differing LABEL keeps the control from reusing the + // named image's bytes, so nothing but the annotation can explain the outcome. + const control = "localhost/w36-control-live:2026.296.1200" + controlDir := t.TempDir() + controlFile := filepath.Join(controlDir, "Containerfile") + if err := os.WriteFile(controlFile, []byte("FROM alpine:3.20\nLABEL w36=control\nCMD [\"/bin/true\"]\n"), 0o644); err != nil { + t.Fatal(err) + } + controlTar := filepath.Join(controlDir, "control.tar") + daemon, err := buildkitdrive.Ensure(ctx, buildkitdrive.ExecRunner{}, buildkitdrive.DaemonOptions{}) + if err != nil { + t.Fatalf("preparing the builder: %v", err) + } + runner := buildkitdrive.ExecRunner{} + args := []string{"build", "--frontend", "dockerfile.v0", + "--addr", daemon.Addr(), + "--local", "context=" + controlDir, + "--local", "dockerfile=" + controlDir, + "--opt", "filename=Containerfile", + "--output", "type=oci,name=" + control + ",dest=" + controlTar, + } + if _, err := runner.Run(ctx, buildkitdrive.BuildctlBinary, args...); err != nil { + t.Fatalf("building the control image: %v", err) + } + if err := rewriteExportIndex(controlTar, func(idx *storeIndex) { + for i := range idx.Manifests { + delete(idx.Manifests[i].Annotations, imageNameAnnotation) + delete(idx.Manifests[i].Annotations, refNameAnnotation) + } + }); err != nil { + t.Fatalf("stripping the control's naming annotation: %v", err) + } + if err := importExport(ctx, runner, "podman", controlTar, control); err == nil { + t.Fatalf("an archive with NO %s annotation imported and reported %s resolvable — the "+ + "annotation would not be what names the image", imageNameAnnotation, control) + } + if kit.LocalImageExists("podman", control) { + t.Fatalf("the unnamed control archive landed tagged as %s", control) + } +} + +// mustStoreDir is the test's store dir (the env override is set by each test). +func mustStoreDir(t *testing.T) string { + t.Helper() + dir, err := imageStoreDir() + if err != nil { + t.Fatal(err) + } + return dir +} + +// A parent in the charly image store is supplied to the solve from the STORE while its registry is +// unreachable. The control — a child whose base is absent from the store — fails against the same +// unreachable registry, which is what makes the first build's success attributable to the binding. +func TestImageStore_BoxOnBoxBuildsWithTheRegistryUnreachable(t *testing.T) { + liveTools(t, "rootlesskit", "buildkitd", "buildctl", "podman") + liveStoreDir(t) + + ctx := context.Background() + dir := t.TempDir() + cfg := driveConfig{RunEngine: "podman", Dir: dir, BuildDir: filepath.Join(dir, ".build")} + + // `.invalid` is reserved by RFC 2606 and never resolves, so any registry access fails at DNS — + // the "registry unreachable" condition is a property of the reference, not of this host. + parentRef := "ghcr.invalid/opencharly/w36-live-parent:2026.296.1200" + parentFile := "FROM alpine:3.20\nLABEL w36=parent\nCMD [\"/bin/true\"]\n" + if err := cfg.buildImage(ctx, spec.BuildResolveBox{Name: "w36parent", FullTag: parentRef}, parentFile); err != nil { + t.Fatalf("building the parent: %v", err) + } + if _, ok, err := storeLookup(parentRef); err != nil || !ok { + t.Fatalf("the parent is not in the charly image store (ok=%v, err=%v)", ok, err) + } + + // THE CONTROL: a base that is NOT in the store must fail — proving the child's success below + // comes from the store binding and not from some other resolution path. + missingRef := "ghcr.invalid/opencharly/w36-live-absent:2026.296.1200" + missingFile := "ARG BASE_IMAGE=" + missingRef + "\nFROM ${BASE_IMAGE}\nLABEL w36=missing-control\n" + if err := cfg.buildImage(ctx, spec.BuildResolveBox{Name: "w36missing", FullTag: "localhost/w36-missing:2026.296.1200"}, missingFile); err == nil { + t.Fatalf("a child whose base is absent from the store and unreachable in the registry BUILT — "+ + "the box-on-box result below would prove nothing (base %s)", missingRef) + } + + childRef := "localhost/w36-live-child:2026.296.1200" + childFile := "ARG BASE_IMAGE=" + parentRef + "\nFROM ${BASE_IMAGE}\nLABEL w36=child\nCMD [\"/bin/true\"]\n" + if err := cfg.buildImage(ctx, spec.BuildResolveBox{Name: "w36child", FullTag: childRef}, childFile); err != nil { + t.Fatalf("the box-on-box build failed with the parent in the store: %v", err) + } + defer exec.Command("podman", "rmi", "-f", childRef).Run() //nolint:errcheck + defer exec.Command("podman", "rmi", "-f", parentRef).Run() //nolint:errcheck + + if !kit.LocalImageExists("podman", childRef) { + t.Fatalf("the child %s does not resolve after the import", childRef) + } +} diff --git a/candy/plugin-build/imagestore_test.go b/candy/plugin-build/imagestore_test.go new file mode 100644 index 0000000..b2d65b4 --- /dev/null +++ b/candy/plugin-build/imagestore_test.go @@ -0,0 +1,297 @@ +package build + +// imagestore_test.go — the charly image store's contract, pinned at its boundary. +// +// The two claims this file can fail without a daemon or an engine are the ones the drive depends +// on: an export can be NAMED (and the naming key is `io.containerd.image.name`, not the tag-only +// `org.opencontainers.image.ref.name`), and the store merges one entry per reference while copying +// the archive's blobs. The engine-import half of the proof is live in imagestore_live_test.go. + +import ( + "archive/tar" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + "time" +) + +// storeTempDir points the store and its lock at temp dirs for one test. The state root is +// relocated too: the index lock deliberately lives there, never inside the store tree. +func storeTempDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv(imageStoreDirEnv, dir) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + return dir +} + +// writeExportFixture writes a minimal but VALID OCI layout archive: oci-layout, index.json, and the +// manifest/config/layer blobs the index and manifest point at. +func writeExportFixture(t *testing.T, label string) (path, manifestDigest string) { + t.Helper() + configBlob := []byte(`{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}`) + layerBlob := []byte("layer-" + label) + configDigest := digestOf(configBlob) + layerDigest := digestOf(layerBlob) + manifestBlob, err := json.Marshal(map[string]any{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": map[string]any{ + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": configDigest, + "size": len(configBlob), + }, + "layers": []map[string]any{{ + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "digest": layerDigest, + "size": len(layerBlob), + }}, + }) + if err != nil { + t.Fatal(err) + } + manifestDigest = digestOf(manifestBlob) + indexBlob, err := json.Marshal(storeIndex{ + SchemaVersion: 2, + MediaType: "application/vnd.oci.image.index.v1+json", + Manifests: []storeManifest{{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + Digest: manifestDigest, + Size: int64(len(manifestBlob)), + Annotations: map[string]string{ + "org.opencontainers.image.ref.name": "fixture-tag", + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + + path = filepath.Join(t.TempDir(), "export.tar") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + tw := tar.NewWriter(f) + for _, e := range []struct { + name string + data []byte + }{ + {"oci-layout", []byte(imageStoreLayoutMarker)}, + {"index.json", indexBlob}, + {"blobs/sha256/" + hexOf(manifestDigest), manifestBlob}, + {"blobs/sha256/" + hexOf(configDigest), configBlob}, + {"blobs/sha256/" + hexOf(layerDigest), layerBlob}, + } { + if err := tw.WriteHeader(&tar.Header{Name: e.name, Mode: 0o644, Size: int64(len(e.data)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(e.data); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return path, manifestDigest +} + +func digestOf(b []byte) string { + sum := sha256.Sum256(b) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func hexOf(digest string) string { + const prefix = "sha256:" + if len(digest) > len(prefix) && digest[:len(prefix)] == prefix { + return digest[len(prefix):] + } + return digest +} + +func readTarEntry(t *testing.T, tarPath, name string) []byte { + t.Helper() + f, err := os.Open(tarPath) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + tr := tar.NewReader(f) + for { + hdr, rerr := tr.Next() + if rerr == io.EOF { + t.Fatalf("%s has no %s", tarPath, name) + } + if rerr != nil { + t.Fatal(rerr) + } + if hdr.Name == name { + data, rerr := io.ReadAll(tr) + if rerr != nil { + t.Fatal(rerr) + } + return data + } + } +} + +// The naming contract. `name=` is what buildkit writes, and the drive reproduces the +// same three annotations for the archives it builds; a lookup that read the TAG-ONLY annotation +// would be wrong for every namespaced ref, so that annotation must be the tag alone. +func TestAnnotateExportNamesWithTheFullReferenceAndDatesIt(t *testing.T) { + path, _ := writeExportFixture(t, "named") + ref := "ghcr.io/opencharly/fedora:2026.296.1200" + created := time.Date(2026, 10, 23, 10, 49, 59, 0, time.UTC) + if err := annotateExport(path, ref, created); err != nil { + t.Fatalf("annotateExport: %v", err) + } + idx, err := exportIndex(path) + if err != nil { + t.Fatal(err) + } + got := idx.Manifests[0].Annotations + if got[imageNameAnnotation] != ref { + t.Errorf("%s = %q, want %q", imageNameAnnotation, got[imageNameAnnotation], ref) + } + if got[refNameAnnotation] != "2026.296.1200" { + t.Errorf("%s = %q, want the TAG ALONE (this is the annotation a naive reader keys on)", + refNameAnnotation, got[refNameAnnotation]) + } + if got[createdAnnotation] != "2026-10-23T10:49:59Z" { + t.Errorf("%s = %q, want the RFC3339 build instant", createdAnnotation, got[createdAnnotation]) + } + // The rewrite must not lose what it did not touch. + if idx.Manifests[0].Digest == "" || idx.Manifests[0].Size == 0 { + t.Errorf("annotateExport lost the descriptor: %+v", idx.Manifests[0]) + } +} + +// The store is the build's durable artefact: adding copies every blob, one entry per reference, +// and a rebuild of the same reference REPLACES its entry rather than accumulating. +func TestStoreAddCopiesBlobsAndKeepsOneEntryPerReference(t *testing.T) { + dir := storeTempDir(t) + ref := "ghcr.io/opencharly/fedora:2026.296.1200" + + first, firstDigest := writeExportFixture(t, "first") + if err := annotateExport(first, ref, time.Now()); err != nil { + t.Fatal(err) + } + if _, err := storeAdd(first, ref); err != nil { + t.Fatalf("storeAdd first: %v", err) + } + if digest, ok, err := storeLookup(ref); err != nil || !ok || digest != firstDigest { + t.Fatalf("storeLookup first = (%q, %v, %v), want (%q, true, nil)", digest, ok, err, firstDigest) + } + + second, secondDigest := writeExportFixture(t, "second") + if err := annotateExport(second, ref, time.Now()); err != nil { + t.Fatal(err) + } + if _, err := storeAdd(second, ref); err != nil { + t.Fatalf("storeAdd second: %v", err) + } + idx, err := readStoreIndex(dir) + if err != nil { + t.Fatal(err) + } + if len(idx.Manifests) != 1 { + t.Fatalf("the store holds %d entries for one reference, want 1: %+v", len(idx.Manifests), idx.Manifests) + } + if idx.Manifests[0].Digest != secondDigest { + t.Errorf("entry digest = %s, want the newest build %s", idx.Manifests[0].Digest, secondDigest) + } + // Every blob the newest archive names must be present; the layer differs between fixtures, so + // a copy that skipped blobs would leave the store unable to serve the image. + manifest := readTarEntry(t, second, "blobs/sha256/"+hexOf(secondDigest)) + var m struct { + Config struct { + Digest string `json:"digest"` + } `json:"config"` + Layers []struct { + Digest string `json:"digest"` + } `json:"layers"` + } + if err := json.Unmarshal(manifest, &m); err != nil { + t.Fatal(err) + } + for _, d := range []string{m.Config.Digest, m.Layers[0].Digest} { + if _, err := os.Stat(filepath.Join(dir, "blobs", "sha256", hexOf(d))); err != nil { + t.Errorf("blob %s was not copied into the store: %v", d, err) + } + } +} + +// THE INSTRUMENT CHECK (STRICT_POLICY instrument #6): the store keys on the annotation the engines +// apply, and on nothing else. An entry carrying the tag-only annotation must NOT answer a lookup +// for the full reference — otherwise a store full of tag-only entries would look correct while +// every namespaced ref resolved to the wrong image (or to nothing). +func TestStoreLookupIgnoresTheTagOnlyAnnotation(t *testing.T) { + dir := storeTempDir(t) + ref := "ghcr.io/opencharly/fedora:2026.296.1200" + idx := storeIndex{ + SchemaVersion: 2, + Manifests: []storeManifest{{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + Digest: digestOf([]byte("tag-only")), + Size: 8, + Annotations: map[string]string{ + refNameAnnotation: "2026.296.1200", + }, + }}, + } + data, err := json.Marshal(idx) + if err != nil { + t.Fatal(err) + } + if err := ensureLayout(dir); err != nil { + t.Fatal(err) + } + if err := writeFileAtomic(storeIndexPath(dir), data); err != nil { + t.Fatal(err) + } + if _, ok, err := storeLookup(ref); err != nil || ok { + t.Fatalf("storeLookup found a tag-only entry for %s (ok=%v, err=%v); the lookup must key on %s", + ref, ok, err, imageNameAnnotation) + } +} + +// A store entry without the naming annotation is unaddressable, so storeAdd must refuse it rather +// than write one — the negative control for the contract above, in the write direction. +func TestStoreAddRefusesAnUnnamedExport(t *testing.T) { + storeTempDir(t) + path, _ := writeExportFixture(t, "unnamed") + if _, err := storeAdd(path, "ghcr.io/opencharly/fedora:2026.296.1200"); err == nil { + t.Fatal("storeAdd accepted an export with no io.containerd.image.name annotation") + } +} + +func TestContainerfileBaseRefReadsTheGeneratedArgument(t *testing.T) { + cf := "ARG BASE_IMAGE=ghcr.io/opencharly/fedora:2026.296.1200\n\nFROM ${BASE_IMAGE}\n" + if got := containerfileBaseRef(cf); got != "ghcr.io/opencharly/fedora:2026.296.1200" { + t.Errorf("containerfileBaseRef = %q", got) + } + if got := containerfileBaseRef("FROM scratch\n"); got != "" { + t.Errorf("a scratch box has no base argument, got %q", got) + } +} + +func TestRefTag(t *testing.T) { + cases := map[string]string{ + "ghcr.io/opencharly/fedora:2026.296.1200": "2026.296.1200", + "localhost:5000/opencharly/x:1": "1", + "ghcr.io/opencharly/fedora": "", + "ghcr.io/opencharly/fedora@sha256:abc": "", + } + for ref, want := range cases { + if got := refTag(ref); got != want { + t.Errorf("refTag(%q) = %q, want %q", ref, got, want) + } + } +}