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..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,6 +461,13 @@ 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] 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..eeca0fe6 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. 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 +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/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/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..83d49ec5 --- /dev/null +++ b/pkg/orchestration/build_images.go @@ -0,0 +1,196 @@ +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" + "google.golang.org/protobuf/proto" +) + +func (b *Builder) selectBuildImages(ctx context.Context) error { + b.imageBindings = nil + 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)) +} + +// 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 new file mode 100644 index 00000000..c9052837 --- /dev/null +++ b/pkg/orchestration/build_images_test.go @@ -0,0 +1,67 @@ +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") +} + +// 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 7b1665d9..45f513f7 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,10 @@ func (b *Builder) decideImageReuse( wool.Field("agent", b.instance.Service.Agent.Identifier()), wool.Field("release_pins", pinned), wool.Field("image", decision.image)) } } + recipe, decision, err = qualifyBoundImage(recipe, decision, identityErr, shouldPush, b.imageBindings) + if err != nil { + return decision, err + } if b.world.RebuildImages { return decision, nil } @@ -519,7 +526,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..1e15e578 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,16 +382,11 @@ func (b *Builder) Plan(ctx context.Context) (*OutputProperty, error) { return nil, w.NewError("call to build failed") } + 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 := 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 { 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 {