From 6f782aa72b52cc5f95fda36fd7fc88464ed61b76 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:21:54 -0400 Subject: [PATCH 1/3] feat(build): select environment image roles by service type and service --- cmd/environment/images.go | 78 ++++++++++++ cmd/environment/images_test.go | 44 +++++++ cmd/environment/import.go | 4 + docs/cli-reference.md | 27 ++++ docs/commands.md | 51 ++++++++ go.mod | 2 +- go.sum | 4 +- pkg/environments/build_images.go | 90 +++++++++++++ pkg/environments/build_images_test.go | 39 ++++++ pkg/environments/coordinate_contract.go | 5 +- pkg/environments/environment.go | 14 +- pkg/environments/workspace.go | 5 +- pkg/orchestration/build_images.go | 163 ++++++++++++++++++++++++ pkg/orchestration/build_images_test.go | 41 ++++++ pkg/orchestration/build_plan.go | 17 ++- pkg/orchestration/builder.go | 13 +- pkg/orchestration/image_cache.go | 32 +++-- 17 files changed, 604 insertions(+), 25 deletions(-) create mode 100644 cmd/environment/images.go create mode 100644 cmd/environment/images_test.go create mode 100644 pkg/environments/build_images.go create mode 100644 pkg/environments/build_images_test.go create mode 100644 pkg/orchestration/build_images.go create mode 100644 pkg/orchestration/build_images_test.go diff --git a/cmd/environment/images.go b/cmd/environment/images.go new file mode 100644 index 00000000..26bbcbc1 --- /dev/null +++ b/cmd/environment/images.go @@ -0,0 +1,78 @@ +package environment + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/pkg/environments" + "github.com/codefly-dev/core/resources" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var imagesCmd = &cobra.Command{ + Use: "images --file ", + Short: "Set registry relocations and immutable image roles for an environment", + Long: `Replace an environment's build-images policy using a YAML or JSON file. +Service types are exact publisher/agent names; services are exact module/service +names. A service selection wins over the type selection for the same role. +Registry relocations apply to remaining agent defaults, preserving their digest. +Every replacement requires a reference pinned by sha256 and an agent-supported +recipe variant. Unsupported agents or variants refuse before image execution. +Use an empty object ({}) to clear the policy; --dry-run prints the change. +This edits workspace declarations only. It does not build or deploy images.`, + Example: " codefly environment images staging --file images.yaml --dry-run\n codefly environment images staging --file images.yaml", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, done := common.NewContext() + defer done() + file, _ := cmd.Flags().GetString("file") + data, err := readContract(cmd.InOrStdin(), file) + if err != nil { + return err + } + policy, err := parseImagePolicy(data) + if err != nil { + return err + } + contract, err := json.Marshal(map[string]any{"schema": environments.CoordinateContractSchema, "requires_capabilities": []string{"build-image-bindings"}, "environment": map[string]any{"name": args[0], "build-images": policy}}) + if err != nil { + return err + } + dir, err := resources.FindUp[resources.Workspace](ctx) + if err != nil || dir == nil { + return fmt.Errorf("no %s found", resources.WorkspaceConfigurationName) + } + dryRun, _ := cmd.Flags().GetBool("dry-run") + return runImport(ctx, &importOptions{dir: *dir, envName: args[0], contractData: contract, dryRun: dryRun, now: time.Now().UTC(), stdout: cmd.OutOrStdout()}) + }, +} + +func parseImagePolicy(data []byte) (*environments.BuildImages, error) { + var policy *environments.BuildImages + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&policy); err != nil { + return nil, err + } + if policy == nil { + return nil, fmt.Errorf("image policy must be an object; use {} to clear it") + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return nil, fmt.Errorf("image policy must contain exactly one document") + } + return policy, policy.Validate() +} + +func init() { + imagesCmd.Flags().String("file", "", "YAML or JSON image policy file, or - for stdin") + _ = imagesCmd.MarkFlagRequired("file") + imagesCmd.Flags().Bool("dry-run", false, "Print the workspace diff without writing") + Cmd.AddCommand(imagesCmd) +} diff --git a/cmd/environment/images_test.go b/cmd/environment/images_test.go new file mode 100644 index 00000000..934493d4 --- /dev/null +++ b/cmd/environment/images_test.go @@ -0,0 +1,44 @@ +package environment + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" +) + +func TestImagePolicyImportReplacesWithdrawnSelections(t *testing.T) { + dir := writeWorkspace(t, "name: example\nenvironments:\n - name: stage\n build-images:\n registries:\n docker.io: old.example/cache\n types:\n codefly.dev/go:\n runtime: {reference: scratch, variant: scratch}\n") + for _, file := range []string{`services: {m/s: {runtime: {reference: scratch, variant: scratch}}}`, `{}`} { + policy, err := parseImagePolicy([]byte(file)) + require.NoError(t, err) + contract, err := json.Marshal(map[string]any{"schema": "codefly/coordinate/v1", "environment": map[string]any{"name": "stage", "build-images": policy}}) + require.NoError(t, err) + var output bytes.Buffer + opts := &importOptions{dir: dir, envName: "stage", contractData: contract, now: time.Now(), stdout: &output, dryRun: true} + path := filepath.Join(dir, resources.WorkspaceConfigurationName) + before, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, runImport(context.Background(), opts)) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) + opts.dryRun = false + require.NoError(t, runImport(context.Background(), opts)) + after, err = os.ReadFile(path) + require.NoError(t, err) + require.NotContains(t, string(after), "old.example") + require.NotContains(t, string(after), "codefly.dev/go") + } + for _, bad := range []string{"null", "types: {}\n---\ntypes: {}", "typo: {}"} { + _, err := parseImagePolicy([]byte(bad)) + require.Error(t, err) + } + require.Contains(t, imagesCmd.Long, "does not build or deploy") +} diff --git a/cmd/environment/import.go b/cmd/environment/import.go index f94fb096..0725f2a9 100644 --- a/cmd/environment/import.go +++ b/cmd/environment/import.go @@ -465,6 +465,10 @@ func applyContractFields(target, declaration *yaml.Node) { for i := 0; i+1 < len(declaration.Content); i += 2 { key, value := declaration.Content[i].Value, declaration.Content[i+1] current := yamledit.MapValue(target, key) + if key == "build-images" { + yamledit.SetMapValue(target, key, value) + continue + } if current != nil && current.Kind == yaml.ScalarNode && value.Kind == yaml.ScalarNode && current.Tag == value.Tag && current.Value == value.Value { continue } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b5a0c1c9..7cd33a4b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2660,9 +2660,36 @@ codefly environment Subcommands: +- [`codefly environment images`](#codefly-environment-images) - [`codefly environment import`](#codefly-environment-import) - [`codefly environment show`](#codefly-environment-show) +## `codefly environment images` + +Set registry relocations and immutable image roles for an environment + +``` +Replace an environment's build-images policy using a YAML or JSON file. +Service types are exact publisher/agent names; services are exact module/service +names. A service selection wins over the type selection for the same role. +Registry relocations apply to remaining agent defaults, preserving their digest. +Every replacement requires a reference pinned by sha256 and an agent-supported +recipe variant. Unsupported agents or variants refuse before image execution. +Use an empty object ({}) to clear the policy; --dry-run prints the change. +This edits workspace declarations only. It does not build or deploy images. +``` + +``` +codefly environment images --file +``` + +Flags: + +``` + --dry-run Print the workspace diff without writing + --file string YAML or JSON image policy file, or - for stdin +``` + ## `codefly environment import` Import explicit environment declarations from a codefly/coordinate/v1 contract diff --git a/docs/commands.md b/docs/commands.md index dde8b037..00e31a3f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5078,3 +5078,54 @@ on each `foreign_methods` entry with `service:`. The hop routes it only to that service's published gRPC/Connect listeners. An absent selector requires exactly one such service; unknown or non-protobuf selections are refused. This does not change a method's authority policy or authorize shadowing a published method. + +### Environment build images + +`codefly environment images --file images.yaml` replaces the environment's +image-selection policy; `--dry-run` shows the workspace diff and `{}` clears it. +The same `build-images` object can arrive through `environment import` from +infra-base's coordinate contract, with `requires_capabilities: [build-image-bindings]`. +An imported policy replaces the whole object so removed overrides cannot linger. +An omitted object preserves an operator-owned policy; an explicit `{}` clears it. + +```yaml +registries: + docker.io: registry.example/approved-public +# Keys name an agent type, not its release version. +types: + codefly.dev/go-grpc: + runtime: + reference: registry.example/approved/alpine@sha256: + variant: alpine3.23-musl +services: + payments/api: + runtime: + reference: registry.example/payments/alpine@sha256: + variant: alpine3.23-musl +``` + +Replace the example digests with actual 64-character SHA256 index digests. +A service's role wins over the type's role; otherwise registry relocation applies +to the agent's default, preserving its digest and repository path. Explicit +replacement references are already destinations and are not relocated again. +This differs from `registry.mirrors`: relocation renders the destination into +the recipe and makes no upstream fallback claim. Output images still use +`registry.url`. Host Docker credentials authenticate selected registries; policy +contains no credentials. + +The running agent must advertise `base-image-bindings/v1` and its loaded service's +roles and recipe variants. A native Go service exposes `runtime`; a static Go +service exposes `runtime-support` for the files assembled into its scratch +runtime. Selecting the wrong role or an unsupported variant refuses. The CLI +checks FROM, external COPY, RUN mount sources and the Dockerfile frontend against +the selected references before prefetch/build. Agents render recipes; the CLI +does not rewrite them. Existing agents without this contract continue to work +when no policy applies, and refuse when one does. + +Effective bindings enter the image input digest, base-input label and +`org.codefly.build.image-bindings` label. Policy builds use input-qualified tags, +so they neither overwrite an owner release nor reuse its image merely because +its application source is unchanged. This is image selection, not package-source +restriction, network isolation, automatic Wolfi adaptation or CVE clearance. +Qualify replacement contents and every target architecture before adoption; +a variant declaration alone does not prove an image's ABI or tools. diff --git a/go.mod b/go.mod index 6cfe8916..a1ef2402 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261026023425-000d44495d93 github.com/codefly-dev/authority/libraries/fixture-composition/go v0.0.0-20261027171336-5178f01696e3 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad7224855 - github.com/codefly-dev/core v0.19.15 + github.com/codefly-dev/core v0.19.18-0.20261029150419-9d2348c460ae github.com/codefly-dev/golor v0.1.3 github.com/codefly-dev/llm v0.1.7 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 diff --git a/go.sum b/go.sum index 719f1773..0979f3b5 100644 --- a/go.sum +++ b/go.sum @@ -117,8 +117,8 @@ github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad722 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad7224855/go.mod h1:rfn41uiORQ36RrOeFEjXHPLC9NUi0j14AoYJtcovsn4= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 h1:4EygH7/J0E6aM7hUPdbQGfit1rsLUNCtWJ8kMh2b9PE= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= -github.com/codefly-dev/core v0.19.15 h1:GzjDvPEuoyxsbQ82L4/Qgpog9z8vMSHeBScOTAvDBiQ= -github.com/codefly-dev/core v0.19.15/go.mod h1:Q7PHKw8SFb1wNxlTy2EgG11cI9Zmq55TYY1fdsQUhpE= +github.com/codefly-dev/core v0.19.18-0.20261029150419-9d2348c460ae h1:nGuvGc4oRJamUQckRTR60hqOiNESR+qbUeaxwjH9aK0= +github.com/codefly-dev/core v0.19.18-0.20261029150419-9d2348c460ae/go.mod h1:Q7PHKw8SFb1wNxlTy2EgG11cI9Zmq55TYY1fdsQUhpE= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= github.com/codefly-dev/golor v0.1.3/go.mod h1:sl/u/K1l7J0Pr3xyVZp8fOJYQItKKst1No9JqgzLLoY= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= diff --git a/pkg/environments/build_images.go b/pkg/environments/build_images.go new file mode 100644 index 00000000..d458e6dd --- /dev/null +++ b/pkg/environments/build_images.go @@ -0,0 +1,90 @@ +package environments + +import ( + "fmt" + "strings" + + "github.com/codefly-dev/core/agents/services" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" +) + +// BuildImages selects agent-owned image roles without changing agent recipes. +// Registry relocation keeps the digest; type and service entries can select +// different content. An explicit service entry wins for the same role. +type BuildImages struct { + Registries map[string]string `yaml:"registries,omitempty" json:"registries,omitempty"` + Types map[string]map[string]BuildImage `yaml:"types,omitempty" json:"types,omitempty"` + Services map[string]map[string]BuildImage `yaml:"services,omitempty" json:"services,omitempty"` +} + +type BuildImage struct { + Reference string `yaml:"reference" json:"reference"` + Variant string `yaml:"variant" json:"variant"` +} + +func (policy *BuildImages) Validate() error { + if policy == nil { + return nil + } + // Registry relocations use the existing canonical registry/path grammar. + if err := (&EnvironmentRegistry{Mirrors: policy.Registries}).Validate(); err != nil { + return fmt.Errorf("build-images registries: %w", err) + } + for kind, selections := range map[string]map[string]map[string]BuildImage{"types": policy.Types, "services": policy.Services} { + for selector, roles := range selections { + parts := strings.Split(selector, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" || strings.ContainsAny(selector, " \t\r\n:*@") || parts[0] == "." || parts[0] == ".." || parts[1] == "." || parts[1] == ".." { + return fmt.Errorf("build-images %s selector %q must be an exact %s", kind, selector, map[string]string{"types": "publisher/agent", "services": "module/service"}[kind]) + } + if len(roles) == 0 { + return fmt.Errorf("build-images %s %q declares no roles", kind, selector) + } + for role, input := range roles { + if err := services.ValidateBuildImageInput(&builderv0.BuildImageInput{Role: role, Reference: input.Reference, Variant: input.Variant}); err != nil { + return err + } + } + } + } + return nil +} + +func (policy *BuildImages) Applies(agent, service string) bool { + return policy != nil && (len(policy.Registries) > 0 || len(policy.Types[agent]) > 0 || len(policy.Services[service]) > 0) +} + +// Resolve preserves declaration order and leaves the caller's values untouched. +// Explicit bindings are destination references and are never relocated again. +func (policy *BuildImages) Resolve(agent, service string, declarations []*builderv0.BuildImageInput) ([]*builderv0.BuildImageInput, error) { + if err := policy.Validate(); err != nil { + return nil, err + } + defaults, err := services.ResolveBuildImages(declarations, nil) + if err != nil { + return nil, err + } + if policy == nil { + return defaults, nil + } + chosen := make(map[string]BuildImage) + for k, v := range policy.Types[agent] { + chosen[k] = v + } + for k, v := range policy.Services[service] { + chosen[k] = v + } + bindings := make([]*builderv0.BuildImageInput, 0, len(chosen)) + for role, input := range chosen { + bindings = append(bindings, &builderv0.BuildImageInput{Role: role, Reference: input.Reference, Variant: input.Variant}) + } + resolved, err := services.ResolveBuildImages(defaults, bindings) + if err != nil { + return nil, err + } + for _, input := range resolved { + if _, explicit := chosen[input.Role]; !explicit && input.Reference != "scratch" { + input.Reference = MirroredReference(input.Reference, policy.Registries) + } + } + return services.ResolveBuildImages(defaults, resolved) +} diff --git a/pkg/environments/build_images_test.go b/pkg/environments/build_images_test.go new file mode 100644 index 00000000..2eaa766f --- /dev/null +++ b/pkg/environments/build_images_test.go @@ -0,0 +1,39 @@ +package environments + +import ( + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/stretchr/testify/require" + "strings" + "testing" +) + +func TestBuildImagesSelectionPrecedenceAndNoMutation(t *testing.T) { + ref := "docker.io/library/alpine@sha256:" + strings.Repeat("a", 64) + declared := []*builderv0.BuildImageInput{{Role: "runtime", Reference: ref, Variant: "alpine3.23-musl"}, {Role: "builder", Reference: ref, Variant: "go1.27"}} + typeRef := "registry.example/type/alpine@sha256:" + strings.Repeat("b", 64) + serviceRef := "registry.example/service/alpine@sha256:" + strings.Repeat("c", 64) + policy := &BuildImages{Registries: map[string]string{"docker.io": "registry.example/public"}, Types: map[string]map[string]BuildImage{"codefly.dev/unknown-agent": {"runtime": {Reference: typeRef, Variant: "alpine3.23-musl"}}}, Services: map[string]map[string]BuildImage{"module/api": {"runtime": {Reference: serviceRef, Variant: "alpine3.23-musl"}}}} + inputs, err := policy.Resolve("codefly.dev/unknown-agent", "module/api", declared) + require.NoError(t, err) + require.Equal(t, serviceRef, inputs[0].Reference) + require.Equal(t, "registry.example/public/library/alpine@sha256:"+strings.Repeat("a", 64), inputs[1].Reference) + inputs, err = policy.Resolve("codefly.dev/unknown-agent", "module/other", declared) + require.NoError(t, err) + require.Equal(t, typeRef, inputs[0].Reference) + require.Equal(t, ref, declared[0].Reference) + policy.Services["module/api"]["unknown"] = BuildImage{Reference: serviceRef, Variant: "alpine3.23-musl"} + _, err = policy.Resolve("codefly.dev/unknown-agent", "module/api", declared) + require.ErrorContains(t, err, "unsupported image role") +} + +func TestBuildImagesCoordinateImportStrictness(t *testing.T) { + prefix := `{"schema":"codefly/coordinate/v1","requires_capabilities":["build-image-bindings"],"environment":{"name":"stage","build-images":` + for _, policy := range []string{`{"types":{"go":{"runtime":{"reference":"scratch","variant":"scratch"}}}}`, `{"services":{"m/s":{"runtime":{"reference":"alpine:latest","variant":"alpine"}}}}`, `{"registries":{"docker.io":"https://registry.example"}}`, `{"typo":{}}`} { + _, err := ParseCoordinateContract([]byte(prefix + policy + `}}`)) + require.Error(t, err, policy) + } + c, err := ParseCoordinateContract([]byte(prefix + `{"types":{"codefly.dev/unknown":{"runtime":{"reference":"scratch","variant":"scratch"}}}}}}`)) + require.NoError(t, err) + require.True(t, c.Environment.BuildImages.Applies("codefly.dev/unknown", "m/s")) + require.False(t, c.Environment.BuildImages.Applies("codefly.dev/another", "m/s")) +} diff --git a/pkg/environments/coordinate_contract.go b/pkg/environments/coordinate_contract.go index 44d55dde..f85de3da 100644 --- a/pkg/environments/coordinate_contract.go +++ b/pkg/environments/coordinate_contract.go @@ -56,7 +56,7 @@ func (c *CoordinateContract) validate() error { } for _, capability := range c.RequiresCapabilities { switch capability { - case CapabilityManagedServiceIdentity, CapabilityServiceIdentity: + case CapabilityManagedServiceIdentity, CapabilityServiceIdentity, "build-image-bindings": default: return fmt.Errorf("coordinate contract requires unsupported capability %q", capability) } @@ -85,6 +85,9 @@ func (c *CoordinateContract) validate() error { if err := env.Registry.Validate(); err != nil { return err } + if err := env.BuildImages.Validate(); err != nil { + return err + } if env.Gitops != nil && (strings.TrimSpace(env.Gitops.RepoURL) == "" || strings.TrimSpace(env.Gitops.Path) == "" || strings.TrimSpace(env.Gitops.Branch) == "") { return fmt.Errorf("declared delivery target requires repository, path and branch") } diff --git a/pkg/environments/environment.go b/pkg/environments/environment.go index 18fefcc4..dae2c417 100644 --- a/pkg/environments/environment.go +++ b/pkg/environments/environment.go @@ -1332,6 +1332,11 @@ func (env *Environment) validateServiceKeyCollisions() error { // projection time, so ValidateEnvironments cross-checks every such block. func (env *Environment) serviceScopedNames() map[string][]string { names := make(map[string][]string, 3) + if env.BuildImages != nil { + for name := range env.BuildImages.Services { + names["build-images"] = append(names["build-images"], name) + } + } if env.ServiceSecrets != nil { for name := range env.ServiceSecrets.Services { names["service-secrets"] = append(names["service-secrets"], name) @@ -1473,10 +1478,11 @@ type Environment struct { // Empty values fall back to legacy defaults (local k3d, ~/.kube/config, // the default namespace, the --org flag's hardcoded registry) so // existing workspace YAMLs keep working unchanged. - Cluster *EnvironmentCluster `yaml:"cluster,omitempty"` - Registry *EnvironmentRegistry `yaml:"registry,omitempty"` - Namespace string `yaml:"namespace,omitempty"` - Gitops *EnvironmentGitops `yaml:"gitops,omitempty"` + Cluster *EnvironmentCluster `yaml:"cluster,omitempty"` + Registry *EnvironmentRegistry `yaml:"registry,omitempty"` + BuildImages *BuildImages `yaml:"build-images,omitempty"` + Namespace string `yaml:"namespace,omitempty"` + Gitops *EnvironmentGitops `yaml:"gitops,omitempty"` Ingress []EnvironmentIngressRoute `yaml:"ingress,omitempty"` diff --git a/pkg/environments/workspace.go b/pkg/environments/workspace.go index ff5bfaa1..4d687d0c 100644 --- a/pkg/environments/workspace.go +++ b/pkg/environments/workspace.go @@ -133,6 +133,9 @@ func (env *Environment) Validate() error { if err := env.Registry.Validate(); err != nil { return err } + if err := env.BuildImages.Validate(); err != nil { + return err + } // A developer identity is a local host's allowlist entry. A deployed host // refuses it anyway, by its own allowlist; refusing it here keeps a // developer's identity out of every manifest a hosted environment renders. @@ -264,7 +267,7 @@ func ValidateWorkspace(ctx context.Context, workspace *resources.Workspace, sele // A module-qualified service-identity key names exactly one // service of that module, or nothing (a typo that would leave // the workload on the default identity with no report). - if _, service, qualified := strings.Cut(name, "/"); qualified && (block == "service-identity" || block == "service-mesh" || block == "service-config") { + if _, service, qualified := strings.Cut(name, "/"); qualified && (block == "service-identity" || block == "service-mesh" || block == "service-config" || block == "build-images") { if !slices.Contains(byName[service], name) { return w.Wrap(fmt.Errorf("environment %q %s references unknown service %q", env.Name, block, name)) } diff --git a/pkg/orchestration/build_images.go b/pkg/orchestration/build_images.go new file mode 100644 index 00000000..2789e746 --- /dev/null +++ b/pkg/orchestration/build_images.go @@ -0,0 +1,163 @@ +package orchestration + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/asottile/dockerfile" + coreservices "github.com/codefly-dev/core/agents/services" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/codefly-dev/core/services" +) + +func (b *Builder) selectBuildImages(ctx context.Context) error { + if b.world.Env == nil { + return nil + } + policy := b.world.Env.BuildImages + agent := b.instance.Service.Agent + agentType := agent.Publisher + "/" + agent.Name + if !policy.Applies(agentType, b.instance.Unique()) { + return nil + } + client, err := services.LoadBuilder(ctx, b.instance.Service) + if err != nil { + return err + } + capabilities, err := client.BuildCapabilities(ctx, &builderv0.BuildCapabilitiesRequest{}) + if err != nil { + return fmt.Errorf("cannot inspect image inputs for %s: %w", b.instance.Unique(), err) + } + if err = coreservices.RequireBuildImageBindings(capabilities); err != nil { + return err + } + b.imageBindings, err = policy.Resolve(agentType, b.instance.Unique(), capabilities.GetImageInputs()) + return err +} + +// verifyBuildImages closes the producer/consumer boundary: a new getter or a +// claimed capability cannot silently discard environment policy. It runs after +// normal recipe integrity checks, before any prefetch or image execution. +func verifyBuildImages(outputDir string, plan *builderv0.DockerBuildPlan, bindings []*builderv0.BuildImageInput) error { + if len(bindings) == 0 { + return nil + } + if plan == nil { + return fmt.Errorf("image bindings require a verified Docker build plan") + } + expected := map[string]bool{} + for _, input := range bindings { + if input.Reference != "scratch" { + expected[input.Reference] = false + } + } + for _, recipe := range plan.GetRecipes() { + path, err := recipeDockerfile(outputDir, recipe) + if err != nil { + return err + } + refs, err := externalPolicyImages(path) + if err != nil { + return err + } + for _, ref := range refs { + if _, ok := expected[ref]; !ok { + return fmt.Errorf("recipe %q uses undeclared or unselected image %q", recipe.GetName(), ref) + } + expected[ref] = true + } + } + for ref, used := range expected { + if !used { + return fmt.Errorf("recipe omitted selected image %q", ref) + } + } + return nil +} + +// externalPolicyImages covers external COPY and RUN mounts and the frontend as +// well as FROM. Parameters whose image cannot be proven refuse policy execution. +func externalPolicyImages(path string) ([]string, error) { + commands, err := dockerfile.ParseFile(path) + if err != nil { + return nil, err + } + payload, err := os.ReadFile(path) + if err != nil { + return nil, err + } + refs := []string{} + stages := map[string]bool{"scratch": true} + add := func(ref string) error { + if stages[strings.ToLower(ref)] { + return nil + } + if ref == "" || strings.ContainsAny(ref, "$") { + return fmt.Errorf("unresolved external image %q in %s", ref, path) + } + refs = append(refs, ref) + return nil + } + // Docker parser directives precede the first non-comment instruction. + for _, line := range strings.Split(string(payload), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if !strings.HasPrefix(line, "#") { + break + } + directive := strings.TrimSpace(strings.TrimPrefix(line, "#")) + if key, value, ok := strings.Cut(directive, "="); ok && strings.EqualFold(strings.TrimSpace(key), "syntax") { + if err = add(strings.TrimSpace(value)); err != nil { + return nil, err + } + } + } + stageIndex := 0 + for _, command := range commands { + if strings.EqualFold(command.Cmd, "from") { + if len(command.Value) == 0 { + return nil, fmt.Errorf("FROM has no image in %s", path) + } + if err = add(command.Value[0]); err != nil { + return nil, err + } + stages[fmt.Sprint(stageIndex)] = true + stageIndex++ + if len(command.Value) >= 3 && strings.EqualFold(command.Value[1], "as") { + stages[strings.ToLower(command.Value[2])] = true + } + } + if strings.EqualFold(command.Cmd, "copy") || strings.EqualFold(command.Cmd, "run") { + for _, flag := range command.Flags { + if value, ok := strings.CutPrefix(flag, "--from="); ok { + if err = add(value); err != nil { + return nil, err + } + } + if value, ok := strings.CutPrefix(flag, "--mount="); ok { + for _, option := range strings.Split(value, ",") { + if from, ok := strings.CutPrefix(option, "from="); ok { + if err = add(from); err != nil { + return nil, err + } + } + } + } + } + } + } + return refs, nil +} + +func withImageBindingsLabel(args []string, bindings []*builderv0.BuildImageInput) []string { + if len(bindings) == 0 { + return args + } + payload, _ := json.Marshal(bindings) // protobuf fields contain only strings + return withBuildFlags(args, "--label", "org.codefly.build.image-bindings="+string(payload)) +} diff --git a/pkg/orchestration/build_images_test.go b/pkg/orchestration/build_images_test.go new file mode 100644 index 00000000..568aa114 --- /dev/null +++ b/pkg/orchestration/build_images_test.go @@ -0,0 +1,41 @@ +package orchestration + +import ( + "os" + "path/filepath" + "strings" + "testing" + + coreservices "github.com/codefly-dev/core/agents/services" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "github.com/stretchr/testify/require" +) + +func TestBuildImagePolicyVerifiesEveryExternalRecipeImage(t *testing.T) { + ref := "registry.example/alpine@sha256:" + strings.Repeat("a", 64) + other := "registry.example/tool@sha256:" + strings.Repeat("b", 64) + dir := t.TempDir() + path := filepath.Join(dir, "Dockerfile") + for _, body := range []string{"FROM " + other + "\n", "FROM " + ref + "\nCOPY --from=" + other + " /tool /tool\n", "# syntax=" + other + "\nFROM " + ref + "\n", "FROM " + ref + "\nRUN --mount=type=bind,from=" + other + ",target=/tool true\n", "ARG BASE\nFROM ${BASE}\n"} { + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + plan, err := coreservices.SingleImageBuildPlan(dir, "example.test/app:v1", []string{"linux/amd64"}, []string{"Dockerfile"}) + require.NoError(t, err) + require.Error(t, verifyBuildImages(dir, plan, []*builderv0.BuildImageInput{{Role: "runtime", Reference: ref, Variant: "alpine"}}), body) + } + body := "# syntax=" + other + "\nFROM scratch AS proxy\nFROM " + ref + " AS build\nRUN --mount=type=bind,from=proxy,target=/proxy true\nFROM scratch\nCOPY --from=build /app /app\n" + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + plan, err := coreservices.SingleImageBuildPlan(dir, "example.test/app:v1", []string{"linux/amd64"}, []string{"Dockerfile"}) + require.NoError(t, err) + bindings := []*builderv0.BuildImageInput{{Role: "runtime", Reference: ref, Variant: "alpine"}, {Role: "frontend", Reference: other, Variant: "dockerfile-v1"}} + require.NoError(t, verifyBuildImages(dir, plan, bindings)) + scope := imageBuildScope{OutputDir: dir, ContextDir: dir, WorkspaceDir: dir} + first, err := imageBuildIdentity(t.Context(), plan.Recipes[0], plan, scope, path, "", []string{"buildx", "build", dir}, nil, nil, bindings...) + require.NoError(t, err) + bindings[0].Reference = other + second, err := imageBuildIdentity(t.Context(), plan.Recipes[0], plan, scope, path, "", []string{"buildx", "build", dir}, nil, nil, bindings...) + require.NoError(t, err) + require.NotEqual(t, first.Key, second.Key) + require.NotEqual(t, first.Classes.Bases, second.Classes.Bases) + require.Equal(t, first.Classes.Code, second.Classes.Code) + require.ErrorContains(t, verifyBuildImages(dir, plan, bindings), "unselected") +} diff --git a/pkg/orchestration/build_plan.go b/pkg/orchestration/build_plan.go index 7b1665d9..d586c8a3 100644 --- a/pkg/orchestration/build_plan.go +++ b/pkg/orchestration/build_plan.go @@ -61,6 +61,9 @@ func (b *Builder) buildFromPlan(ctx context.Context, outputDir string, plan *bui if err := coreservices.VerifyDockerBuildPlan(outputDir, plan); err != nil { return w.Wrapf(err, "cannot verify build recipe for %s", b.instance.Unique()) } + if err := verifyBuildImages(outputDir, plan, b.imageBindings); err != nil { + return err + } if err := b.checkDisposableImagePlan(plan); err != nil { return err } @@ -152,7 +155,7 @@ func (b *Builder) decideImageReuse( if err != nil { return imageReuse{}, err } - identity, identityErr := imageBuildIdentity(ctx, recipe, plan, scope, prepared.Dockerfile, probeBuilder, identityArgs, private.Proxies, mirrors) + identity, identityErr := imageBuildIdentity(ctx, recipe, plan, scope, prepared.Dockerfile, probeBuilder, identityArgs, private.Proxies, mirrors, b.imageBindings...) if err = b.checkDisposableImageIdentity(identity, identityErr); err != nil { return imageReuse{}, err } @@ -189,6 +192,16 @@ func (b *Builder) decideImageReuse( wool.Field("agent", b.instance.Service.Agent.Identifier()), wool.Field("release_pins", pinned), wool.Field("image", decision.image)) } } + if len(b.imageBindings) > 0 { + if identity.Key == "" { + return decision, fmt.Errorf("selected image bindings require a complete build identity: %w", identityErr) + } + if shouldPush && decision.image == "" { + recipe = proto.CloneOf(recipe) + recipe.Image = overrideBuildImage(recipe.GetImage(), identity.Key) + decision.image = recipe.Image + } + } if b.world.RebuildImages { return decision, nil } @@ -519,7 +532,7 @@ func (b *Builder) buildRecipe( args, err := cachedBuildxArgs(recipe, dockerfile, contextDir, shouldPush, multiArch, metadataFile, builderName, cache, private) if err == nil { - args = withBuildAgentLabel(withBuildInputsLabel(args, identity), b.instance.Service.Agent) + args = withImageBindingsLabel(withBuildAgentLabel(withBuildInputsLabel(args, identity), b.instance.Service.Agent), b.imageBindings) if shouldPush { args = withSourceLabel(args, b.world.Env, workspaceSourceRepository(ctx, b.world.Workspace.Dir()), multiArch) } diff --git a/pkg/orchestration/builder.go b/pkg/orchestration/builder.go index 47ed0193..725da7ba 100644 --- a/pkg/orchestration/builder.go +++ b/pkg/orchestration/builder.go @@ -43,7 +43,8 @@ type Builder struct { outputPropertyForBuild *BuilderBuildManager outputPropertyForSync *BuilderSyncManager - imageDigest string + imageDigest string + imageBindings []*builderv0.BuildImageInput // localRuntime builds a recipe for this machine's container engine, keeping // generated files out of authored services and naming the image by its inputs. @@ -267,6 +268,9 @@ func (b *Builder) dockerBuildContext(ctx context.Context) (*builderv0.DockerBuil if err := b.world.checkDisposableImagePolicy(); err != nil { return nil, err } + if err := b.selectBuildImages(ctx); err != nil { + return nil, err + } dockerContext, err := builder.DockerBuildContext(ctx, b.world.Workspace) if err != nil { return nil, err @@ -274,6 +278,7 @@ func (b *Builder) dockerBuildContext(ctx context.Context) (*builderv0.DockerBuil if _, err = dockerhelpers.CacheArguments(b.world.BuildCache, coreservices.RecipeBuildPlatforms()); err != nil { return nil, fmt.Errorf("cannot configure build cache: %w", err) } + dockerContext.ImageBindings = b.imageBindings dockerContext.Cache = scopedBuildCache(b.world.BuildCache, b.world.Workspace.Name, b.instance.Unique(), "") dockerContext.BuildxBuilder = b.world.BuildxBuilder mirrors := registryMirrors(b.world.Env) @@ -377,6 +382,9 @@ func (b *Builder) Plan(ctx context.Context) (*OutputProperty, error) { return nil, w.NewError("call to build failed") } + if len(b.imageBindings) > 0 && resp.GetResult().GetDockerBuildPlan() == nil { + return nil, fmt.Errorf("image bindings require a Docker build plan") + } planned := &plannedBuild{recipeRoot: recipeRoot, outputDir: outputDir, response: resp} if plan := resp.Result.GetDockerBuildPlan(); plan != nil { // Fetch only for a recipe tree that verifies: a declared module root is @@ -384,6 +392,9 @@ func (b *Builder) Plan(ctx context.Context) (*OutputProperty, error) { if err := coreservices.VerifyDockerBuildPlan(outputDir, plan); err != nil { return nil, w.Wrapf(err, "cannot verify build recipe for %s", b.instance.Unique()) } + if err := verifyBuildImages(outputDir, plan, b.imageBindings); err != nil { + return nil, err + } if err := b.checkDisposableImagePlan(plan); err != nil { return nil, err } diff --git a/pkg/orchestration/image_cache.go b/pkg/orchestration/image_cache.go index 5e0ba2fa..c431c5a8 100644 --- a/pkg/orchestration/image_cache.go +++ b/pkg/orchestration/image_cache.go @@ -137,7 +137,8 @@ type imageInputs struct { GoModules []string `json:"go_module_digests,omitempty"` // Bases is the resolved manifest digest of each external image the recipe's // Dockerfile builds from. - Bases []string `json:"base_image_digests,omitempty"` + Bases []string `json:"base_image_digests,omitempty"` + ImageBindings []*builderv0.BuildImageInput `json:"image_bindings,omitempty"` } // imageCacheEntry records which image a previously executed build produced for @@ -272,7 +273,11 @@ func identityClasses(inputs *imageInputs) (*buildClasses, error) { if err != nil { return nil, err } - bases, err := classDigest("bases", inputs.Bases) + var baseInputs any = inputs.Bases + if len(inputs.ImageBindings) > 0 { + baseInputs = []any{inputs.Bases, inputs.ImageBindings} + } + bases, err := classDigest("bases", baseInputs) if err != nil { return nil, err } @@ -304,7 +309,7 @@ func contextDigest(ctx context.Context, recipe *builderv0.DockerBuildRecipe, sco // imageBuildIdentity digests the inputs of one recipe's image build. A zero // identity with no error means the build is not cacheable and must run. -func imageBuildIdentity(ctx context.Context, recipe *builderv0.DockerBuildRecipe, plan *builderv0.DockerBuildPlan, scope imageBuildScope, dockerfilePath, builderName string, args []string, proxies, mirrors map[string]string) (imageIdentity, error) { +func imageBuildIdentity(ctx context.Context, recipe *builderv0.DockerBuildRecipe, plan *builderv0.DockerBuildPlan, scope imageBuildScope, dockerfilePath, builderName string, args []string, proxies, mirrors map[string]string, bindings ...*builderv0.BuildImageInput) (imageIdentity, error) { recipeTree := plan.GetDigest() if recipeTree == "" { return imageIdentity{}, fmt.Errorf("recipe tree of %s declares no verified content digest", recipe.GetName()) @@ -322,16 +327,17 @@ func imageBuildIdentity(ctx context.Context, recipe *builderv0.DockerBuildRecipe return imageIdentity{}, err } inputs := imageInputs{ - Schema: imageCacheSchema, - Recipe: recipe.GetName(), - Dockerfile: recipe.GetDockerfile(), - Context: recipe.GetContext(), - Dockerignore: recipe.GetDockerignore(), - Command: normalizedBuildxArgs(args, scope.ContextDir, dockerfilePath, proxies), - RecipeTree: recipeTree, - ContextTree: contextTree, - GoModules: goModules, - Bases: bases, + Schema: imageCacheSchema, + Recipe: recipe.GetName(), + Dockerfile: recipe.GetDockerfile(), + Context: recipe.GetContext(), + Dockerignore: recipe.GetDockerignore(), + Command: normalizedBuildxArgs(args, scope.ContextDir, dockerfilePath, proxies), + RecipeTree: recipeTree, + ContextTree: contextTree, + GoModules: goModules, + Bases: bases, + ImageBindings: bindings, } payload, err := json.Marshal(inputs) if err != nil { From 8156e9348e8a81c1228b6769e22be60de5d2266b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:33:21 -0400 Subject: [PATCH 2/3] fix(build): qualify policy images and preserve optional contracts --- cmd/environment/import.go | 13 ++++++---- pkg/conformance/matrix.json | 2 +- pkg/orchestration/build_images.go | 33 ++++++++++++++++++++++++++ pkg/orchestration/build_images_test.go | 26 ++++++++++++++++++++ pkg/orchestration/build_plan.go | 12 +++------- pkg/orchestration/builder.go | 17 +++---------- 6 files changed, 74 insertions(+), 29 deletions(-) diff --git a/cmd/environment/import.go b/cmd/environment/import.go index 0725f2a9..e14aa6fd 100644 --- a/cmd/environment/import.go +++ b/cmd/environment/import.go @@ -251,7 +251,7 @@ func runImport(ctx context.Context, opts *importOptions) error { // EndLine after the edit would under-count and strand the original's // trailing lines. endLine := yamledit.EndLine(envNode) - applyContractFields(envNode, declaredEnvironment) + applyEnvironmentContractFields(envNode, declaredEnvironment) withdrawOwnedAbsences(envNode, declaredEnvironment, contractOwnedOptional, false) if intent != nil { withdrawOwnedAbsences(envNode, declaredEnvironment, productOwnedOptional, true) @@ -461,14 +461,17 @@ func clearFootComments(node *yaml.Node) { // Merge only explicitly declared fields. Omitted keys retain operator-owned // values; sequences and scalars replace their exact named field. +func applyEnvironmentContractFields(target, declaration *yaml.Node) { + if images := yamledit.MapValue(declaration, "build-images"); images != nil { + yamledit.SetMapValue(target, "build-images", images) + } + applyContractFields(target, declaration) +} + func applyContractFields(target, declaration *yaml.Node) { for i := 0; i+1 < len(declaration.Content); i += 2 { key, value := declaration.Content[i].Value, declaration.Content[i+1] current := yamledit.MapValue(target, key) - if key == "build-images" { - yamledit.SetMapValue(target, key, value) - continue - } if current != nil && current.Kind == yaml.ScalarNode && value.Kind == yaml.ScalarNode && current.Tag == value.Tag && current.Value == value.Value { continue } diff --git a/pkg/conformance/matrix.json b/pkg/conformance/matrix.json index b53376d3..4379e048 100644 --- a/pkg/conformance/matrix.json +++ b/pkg/conformance/matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "cli": "source", - "core": "v0.19.15", + "core": "v0.19.18", "rows": [ { "id": "linux-amd64-source", diff --git a/pkg/orchestration/build_images.go b/pkg/orchestration/build_images.go index 2789e746..83d49ec5 100644 --- a/pkg/orchestration/build_images.go +++ b/pkg/orchestration/build_images.go @@ -11,9 +11,11 @@ import ( coreservices "github.com/codefly-dev/core/agents/services" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" "github.com/codefly-dev/core/services" + "google.golang.org/protobuf/proto" ) func (b *Builder) selectBuildImages(ctx context.Context) error { + b.imageBindings = nil if b.world.Env == nil { return nil } @@ -161,3 +163,34 @@ func withImageBindingsLabel(args []string, bindings []*builderv0.BuildImageInput payload, _ := json.Marshal(bindings) // protobuf fields contain only strings return withBuildFlags(args, "--label", "org.codefly.build.image-bindings="+string(payload)) } + +// qualifyBoundImage keeps environment-specific inputs apart from owner releases. +func qualifyBoundImage(recipe *builderv0.DockerBuildRecipe, decision imageReuse, identityErr error, shouldPush bool, bindings []*builderv0.BuildImageInput) (*builderv0.DockerBuildRecipe, imageReuse, error) { + if len(bindings) == 0 { + return recipe, decision, nil + } + if decision.identity.Key == "" { + return recipe, decision, fmt.Errorf("selected image bindings require a complete build identity: %w", identityErr) + } + if shouldPush && decision.image == "" { + recipe = proto.CloneOf(recipe) + recipe.Image = overrideBuildImage(recipe.GetImage(), decision.identity.Key) + decision.image = recipe.Image + } + return recipe, decision, nil +} + +func (b *Builder) verifyImagePlan(outputDir string, plan *builderv0.DockerBuildPlan) error { + if plan != nil { + if err := coreservices.VerifyDockerBuildPlan(outputDir, plan); err != nil { + return fmt.Errorf("cannot verify build recipe for %s: %w", b.instance.Unique(), err) + } + } + if err := verifyBuildImages(outputDir, plan, b.imageBindings); err != nil { + return err + } + if plan != nil { + return b.checkDisposableImagePlan(plan) + } + return nil +} diff --git a/pkg/orchestration/build_images_test.go b/pkg/orchestration/build_images_test.go index 568aa114..c9052837 100644 --- a/pkg/orchestration/build_images_test.go +++ b/pkg/orchestration/build_images_test.go @@ -39,3 +39,29 @@ func TestBuildImagePolicyVerifiesEveryExternalRecipeImage(t *testing.T) { require.Equal(t, first.Classes.Code, second.Classes.Code) require.ErrorContains(t, verifyBuildImages(dir, plan, bindings), "unselected") } + +// A reused Builder must forget bindings when its environment no longer selects them. +func TestBuildImagePolicyClearsPriorSelection(t *testing.T) { + b := &Builder{world: &World{}, imageBindings: []*builderv0.BuildImageInput{{Role: "runtime"}}} + require.NoError(t, b.selectBuildImages(t.Context())) + require.Empty(t, b.imageBindings) +} + +// Policy inputs need a complete identity and a distinct publication tag, even +// when the service source version did not change. Existing qualified names stay. +func TestBuildImagePolicyQualifiesPublication(t *testing.T) { + original := &builderv0.DockerBuildRecipe{Image: "registry.example/app:v1.0.0"} + bindings := []*builderv0.BuildImageInput{{Role: "runtime"}} + _, _, err := qualifyBoundImage(original, imageReuse{}, nil, true, bindings) + require.ErrorContains(t, err, "complete build identity") + decision := imageReuse{} + decision.identity.Key = strings.Repeat("a", 64) + selected, result, err := qualifyBoundImage(original, decision, nil, true, bindings) + require.NoError(t, err) + require.NotEqual(t, original.Image, selected.Image) + require.Equal(t, selected.Image, result.image) + require.Equal(t, "registry.example/app:v1.0.0", original.Image) + _, repeated, err := qualifyBoundImage(selected, result, nil, true, bindings) + require.NoError(t, err) + require.Equal(t, result.image, repeated.image) +} diff --git a/pkg/orchestration/build_plan.go b/pkg/orchestration/build_plan.go index d586c8a3..45f513f7 100644 --- a/pkg/orchestration/build_plan.go +++ b/pkg/orchestration/build_plan.go @@ -192,15 +192,9 @@ func (b *Builder) decideImageReuse( wool.Field("agent", b.instance.Service.Agent.Identifier()), wool.Field("release_pins", pinned), wool.Field("image", decision.image)) } } - if len(b.imageBindings) > 0 { - if identity.Key == "" { - return decision, fmt.Errorf("selected image bindings require a complete build identity: %w", identityErr) - } - if shouldPush && decision.image == "" { - recipe = proto.CloneOf(recipe) - recipe.Image = overrideBuildImage(recipe.GetImage(), identity.Key) - decision.image = recipe.Image - } + recipe, decision, err = qualifyBoundImage(recipe, decision, identityErr, shouldPush, b.imageBindings) + if err != nil { + return decision, err } if b.world.RebuildImages { return decision, nil diff --git a/pkg/orchestration/builder.go b/pkg/orchestration/builder.go index 725da7ba..1e15e578 100644 --- a/pkg/orchestration/builder.go +++ b/pkg/orchestration/builder.go @@ -382,22 +382,11 @@ func (b *Builder) Plan(ctx context.Context) (*OutputProperty, error) { return nil, w.NewError("call to build failed") } - if len(b.imageBindings) > 0 && resp.GetResult().GetDockerBuildPlan() == nil { - return nil, fmt.Errorf("image bindings require a Docker build plan") + if err := b.verifyImagePlan(outputDir, resp.GetResult().GetDockerBuildPlan()); err != nil { + return nil, err } planned := &plannedBuild{recipeRoot: recipeRoot, outputDir: outputDir, response: resp} - if plan := resp.Result.GetDockerBuildPlan(); plan != nil { - // Fetch only for a recipe tree that verifies: a declared module root is - // resolved against the context the verified plan selects. - if err := coreservices.VerifyDockerBuildPlan(outputDir, plan); err != nil { - return nil, w.Wrapf(err, "cannot verify build recipe for %s", b.instance.Unique()) - } - if err := verifyBuildImages(outputDir, plan, b.imageBindings); err != nil { - return nil, err - } - if err := b.checkDisposableImagePlan(plan); err != nil { - return nil, err - } + if plan := resp.GetResult().GetDockerBuildPlan(); plan != nil { contextRoot := recipeContextRoot(b.instance.Service.Dir(), outputDir, plan) for _, recipe := range plan.GetRecipes() { if len(recipe.GetGoModuleDownloads()) == 0 && len(recipe.GetGitSourceDownloads()) == 0 { From 016684bdeefda5fce0e84042f99e61b7ca4046df Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:46:56 -0400 Subject: [PATCH 3/3] docs(build): explain stable Go runtime input across service shapes --- docs/commands.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 00e31a3f..eeca0fe6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5114,9 +5114,9 @@ the recipe and makes no upstream fallback claim. Output images still use contains no credentials. The running agent must advertise `base-image-bindings/v1` and its loaded service's -roles and recipe variants. A native Go service exposes `runtime`; a static Go -service exposes `runtime-support` for the files assembled into its scratch -runtime. Selecting the wrong role or an unsupported variant refuses. The CLI +roles and recipe variants. Go services expose a stable `runtime` input, so one +type-wide binding covers native and static services. Native services use it as +the final-stage base; static services use it only to assemble files for scratch. Selecting the wrong role or an unsupported variant refuses. The CLI checks FROM, external COPY, RUN mount sources and the Dockerfile frontend against the selected references before prefetch/build. Agents render recipes; the CLI does not rewrite them. Existing agents without this contract continue to work