Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions cmd/environment/images.go
Original file line number Diff line number Diff line change
@@ -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 <env> --file <policy.yaml|->",
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)
}
44 changes: 44 additions & 0 deletions cmd/environment/images_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
9 changes: 8 additions & 1 deletion cmd/environment/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down
27 changes: 27 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -2660,9 +2660,36 @@ codefly environment <subcommand>

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 <env> --file <policy.yaml|->
```

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
Expand Down
51 changes: 51 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <env> --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:<approved-index-digest>
variant: alpine3.23-musl
services:
payments/api:
runtime:
reference: registry.example/payments/alpine@sha256:<qualified-index-digest>
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.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion pkg/conformance/matrix.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"cli": "source",
"core": "v0.19.15",
"core": "v0.19.18",
"rows": [
{
"id": "linux-amd64-source",
Expand Down
90 changes: 90 additions & 0 deletions pkg/environments/build_images.go
Original file line number Diff line number Diff line change
@@ -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)
}
39 changes: 39 additions & 0 deletions pkg/environments/build_images_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
Loading
Loading