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
2 changes: 1 addition & 1 deletion agents/contract/contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"protocolVersion": 1,
"capabilities": ["container-recovery-scope/v1"],
"startupProtocolVersion": 2,
"operationContracts": ["artifact-execution/v1", "runtime-init-dependency-mappings/v1", "codefly.dev/docker-build-recipe/v4", "codefly.dev/docker-build-recipe/v5", "codefly.dev/docker-build-recipe/v6", "configuration-value-template/v1", "runtime-init-image/v1", "deployment-composition-provenance/v1", "runtime-started-artifact/v1", "runtime-backend-declaration/v1"]
"operationContracts": ["base-image-bindings/v1", "artifact-execution/v1", "runtime-init-dependency-mappings/v1", "codefly.dev/docker-build-recipe/v4", "codefly.dev/docker-build-recipe/v5", "codefly.dev/docker-build-recipe/v6", "configuration-value-template/v1", "runtime-init-image/v1", "deployment-composition-provenance/v1", "runtime-started-artifact/v1", "runtime-backend-declaration/v1"]
}
5 changes: 3 additions & 2 deletions agents/contract/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import (
)

func TestOperationSupportDoesNotAdvertiseExecutorAdoption(t *testing.T) {
require.Equal(t, []string{artifactexecution.Contract, RuntimeInitDependencyMappings, "codefly.dev/docker-build-recipe/v4", "codefly.dev/docker-build-recipe/v5", "codefly.dev/docker-build-recipe/v6", ConfigurationValueTemplate, RuntimeInitImage, DeploymentCompositionProvenance, RuntimeStartedArtifact, RuntimeBackendDeclaration}, SupportedOperationContracts())
require.Equal(t, []string{"base-image-bindings/v1", artifactexecution.Contract, RuntimeInitDependencyMappings, "codefly.dev/docker-build-recipe/v4", "codefly.dev/docker-build-recipe/v5", "codefly.dev/docker-build-recipe/v6", ConfigurationValueTemplate, RuntimeInitImage, DeploymentCompositionProvenance, RuntimeStartedArtifact, RuntimeBackendDeclaration}, SupportedOperationContracts())
require.NotContains(t, Current().Capabilities, "base-image-bindings/v1")
require.NotContains(t, Current().Capabilities, artifactexecution.Contract)
require.NotContains(t, Current().Capabilities, RuntimeInitDependencyMappings)
require.NotContains(t, Current().Capabilities, RuntimeInitImage)
Expand All @@ -29,7 +30,7 @@ func TestOperationSupportDoesNotAdvertiseExecutorAdoption(t *testing.T) {
// supported_backends unset look like one that can run nowhere on this host.
require.NotContains(t, Current().Capabilities, RuntimeBackendDeclaration)
SupportedOperationContracts()[0] = "changed"
require.Equal(t, []string{artifactexecution.Contract, RuntimeInitDependencyMappings, "codefly.dev/docker-build-recipe/v4", "codefly.dev/docker-build-recipe/v5", "codefly.dev/docker-build-recipe/v6", ConfigurationValueTemplate, RuntimeInitImage, DeploymentCompositionProvenance, RuntimeStartedArtifact, RuntimeBackendDeclaration}, SupportedOperationContracts())
require.Equal(t, []string{"base-image-bindings/v1", artifactexecution.Contract, RuntimeInitDependencyMappings, "codefly.dev/docker-build-recipe/v4", "codefly.dev/docker-build-recipe/v5", "codefly.dev/docker-build-recipe/v6", ConfigurationValueTemplate, RuntimeInitImage, DeploymentCompositionProvenance, RuntimeStartedArtifact, RuntimeBackendDeclaration}, SupportedOperationContracts())
}

func TestCheck(t *testing.T) {
Expand Down
102 changes: 102 additions & 0 deletions agents/services/build_images.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package services

import (
"fmt"
"regexp"
"slices"
"strings"

builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
"github.com/distribution/reference"
"google.golang.org/protobuf/proto"
)

// BuildImageBindingsContract requires the agent to declare its loaded service's
// image inputs and render every selected binding into its verified recipe.
// Merely compiling the transport does not implement this operation contract.
const BuildImageBindingsContract = "base-image-bindings/v1"

var imageRoleName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)

// ValidateBuildImageInput rejects ambiguous or mutable image bindings. The
// variant is an agent-owned compatibility declaration, not a host-side roster.
func ValidateBuildImageInput(input *builderv0.BuildImageInput) error {
if input == nil || !imageRoleName.MatchString(input.GetRole()) {
return fmt.Errorf("image input requires a lowercase role name")
}
if input.Variant == "" || strings.TrimSpace(input.Variant) != input.Variant {
return fmt.Errorf("image role %q requires an explicit recipe variant", input.Role)
}
if input.Reference == "scratch" {
return nil
}
named, err := reference.ParseNormalizedNamed(input.Reference)
if err != nil {
return fmt.Errorf("image role %q: invalid reference: %w", input.Role, err)
}
digested, ok := named.(reference.Digested)
if !ok || digested.Digest().Algorithm().String() != "sha256" || digested.Digest().Validate() != nil {
return fmt.Errorf("image role %q requires a sha256-pinned reference", input.Role)
}
if !strings.HasPrefix(input.Reference, reference.Domain(named)+"/") {
return fmt.Errorf("image role %q requires a fully qualified registry reference", input.Role)
}
return nil
}

// ResolveBuildImages validates bindings against the agent's declared recipe
// inputs. Missing bindings retain defaults; unknown roles, duplicate roles and
// unsupported variants refuse. The result owns its messages and preserves the
// agent's declaration order. Consumers render only from this result.
func ResolveBuildImages(defaults, bindings []*builderv0.BuildImageInput) ([]*builderv0.BuildImageInput, error) {
result := make([]*builderv0.BuildImageInput, 0, len(defaults))
indices := make(map[string]int, len(defaults))
for _, input := range defaults {
if err := ValidateBuildImageInput(input); err != nil {
return nil, err
}
if _, exists := indices[input.Role]; exists {
return nil, fmt.Errorf("duplicate declared image role %q", input.Role)
}
indices[input.Role] = len(result)
result = append(result, proto.CloneOf(input))
}
seen := make(map[string]bool, len(bindings))
for _, input := range bindings {
if err := ValidateBuildImageInput(input); err != nil {
return nil, err
}
i, exists := indices[input.Role]
if !exists {
return nil, fmt.Errorf("unsupported image role %q", input.Role)
}
if seen[input.Role] {
return nil, fmt.Errorf("duplicate image binding %q", input.Role)
}
seen[input.Role] = true
if input.Variant != result[i].Variant {
return nil, fmt.Errorf("image role %q: unsupported recipe variant %q; this service requires %q", input.Role, input.Variant, result[i].Variant)
}
// scratch is a built-in empty filesystem, not a registry image. Replacing
// it changes the recipe contract even if a caller copies the variant name.
if (input.Reference == "scratch") != (result[i].Reference == "scratch") {
return nil, fmt.Errorf("image role %q cannot substitute scratch and a registry image", input.Role)
}
result[i] = proto.CloneOf(input)
}
return result, nil
}

// RequireBuildImageBindings holds an opt-in request to the running executor's
// advertisement. A known agent name, library version or generated getter is
// never evidence that an executor implements the operation.
func RequireBuildImageBindings(capabilities *builderv0.BuildCapabilitiesResponse) error {
if !slices.Contains(capabilities.GetExecutionContracts(), BuildImageBindingsContract) {
return fmt.Errorf("builder must advertise %s to select environment image bindings", BuildImageBindingsContract)
}
if len(capabilities.GetImageInputs()) == 0 {
return fmt.Errorf("builder advertises %s but declares no image inputs", BuildImageBindingsContract)
}
_, err := ResolveBuildImages(capabilities.ImageInputs, nil)
return err
}
58 changes: 58 additions & 0 deletions agents/services/build_images_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package services

import (
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
"github.com/stretchr/testify/require"
"strings"
"testing"
)

func testImage(role, ref, variant string) *builderv0.BuildImageInput {
return &builderv0.BuildImageInput{Role: role, Reference: ref, Variant: variant}
}

// The recipe owns compatibility, even when a caller selects another registry
// or a different digest. A selection must never silently become the default.
func TestBuildImageBindingsRefuseIncompatibleOrAmbiguousSelections(t *testing.T) {
ref := "docker.io/library/alpine@sha256:" + strings.Repeat("a", 64)
defaults := []*builderv0.BuildImageInput{testImage("runtime", ref, "alpine3.23-musl"), testImage("empty", "scratch", "scratch")}
for _, tc := range []struct {
name string
bindings []*builderv0.BuildImageInput
refusal string
}{
{"unknown", []*builderv0.BuildImageInput{testImage("typo", ref, "alpine3.23-musl")}, "unsupported image role"},
{"variant", []*builderv0.BuildImageInput{testImage("runtime", ref, "debian-glibc")}, "unsupported recipe variant"},
{"mutable", []*builderv0.BuildImageInput{testImage("runtime", "docker.io/library/alpine:3.23", "alpine3.23-musl")}, "sha256-pinned"},
{"implicit-registry", []*builderv0.BuildImageInput{testImage("runtime", "alpine@sha256:"+strings.Repeat("a", 64), "alpine3.23-musl")}, "fully qualified"},
{"scratch", []*builderv0.BuildImageInput{testImage("runtime", "scratch", "alpine3.23-musl")}, "cannot substitute scratch"},
{"duplicate", []*builderv0.BuildImageInput{defaults[0], defaults[0]}, "duplicate image binding"},
{"nil", []*builderv0.BuildImageInput{nil}, "role name"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := ResolveBuildImages(defaults, tc.bindings)
require.ErrorContains(t, err, tc.refusal)
})
}
selected := testImage("runtime", "registry.example/approved/alpine@sha256:"+strings.Repeat("b", 64), "alpine3.23-musl")
actual, err := ResolveBuildImages(defaults, []*builderv0.BuildImageInput{selected})
require.NoError(t, err)
require.Equal(t, selected.Reference, actual[0].Reference)
require.Equal(t, "scratch", actual[1].Reference)
actual[0].Reference = "mutated"
actual[1].Reference = "mutated"
require.Contains(t, selected.Reference, "registry.example")
require.Equal(t, "scratch", defaults[1].Reference)
_, err = ResolveBuildImages(append(defaults, defaults[0]), nil)
require.ErrorContains(t, err, "duplicate declared")
}

// Capability is an explicit executor promise, never inferred from a known
// agent name, an image declaration alone or a new library version.
func TestBuildImageBindingsRequireExplicitExecutorContract(t *testing.T) {
input := testImage("runtime", "scratch", "scratch")
require.Error(t, RequireBuildImageBindings(nil))
require.Error(t, RequireBuildImageBindings(&builderv0.BuildCapabilitiesResponse{ImageInputs: []*builderv0.BuildImageInput{input}}))
require.ErrorContains(t, RequireBuildImageBindings(&builderv0.BuildCapabilitiesResponse{ExecutionContracts: []string{BuildImageBindingsContract}}), "no image inputs")
require.NoError(t, RequireBuildImageBindings(&builderv0.BuildCapabilitiesResponse{ExecutionContracts: []string{"unknown-contract/v99", BuildImageBindingsContract}, ImageInputs: []*builderv0.BuildImageInput{input}}))
}
13 changes: 13 additions & 0 deletions agents/services/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ func (b *BuilderAgent) Build(ctx context.Context, req *builderv0.BuildRequest, o
}
}
selected := req.GetBuildContext().GetDockerBuildContext().GetBuildxBuilder()
bindings := req.GetBuildContext().GetDockerBuildContext().GetImageBindings()
if len(bindings) > 0 {
capabilities, err := b.BuildCapabilities(ctx, &builderv0.BuildCapabilitiesRequest{}, opts...)
if err != nil {
return nil, fmt.Errorf("cannot verify image binding support before execution: %w", err)
}
if err = RequireBuildImageBindings(capabilities); err != nil {
return nil, err
}
if _, err = ResolveBuildImages(capabilities.GetImageInputs(), bindings); err != nil {
return nil, err
}
}
if selected != "" {
capabilities, err := b.BuildCapabilities(ctx, &builderv0.BuildCapabilitiesRequest{}, opts...)
if err != nil {
Expand Down
8 changes: 8 additions & 0 deletions docs/agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,11 @@ that service before admitting the replacement. Identical bytes remain reusable
after atomic reinstallation. Startup also rejects a concurrently replaced
executable before publishing the connection. These checks do not establish
publisher authority or qualify a rebuilt runtime output.

## Environment image selection

`base-image-bindings/v1` is an optional builder operation: the running agent
advertises its loaded service's image roles and recipe variants, validates
selected immutable bindings and renders them into its recipe. See
[environment image bindings](build-image-bindings.md). Callers selecting this
policy require the live advertisement; unconfigured builds remain unchanged.
35 changes: 35 additions & 0 deletions docs/build-image-bindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Environment image bindings

An environment can select immutable images for roles declared by a service's
running builder agent. This is the image-selection portion of customer build
policy; it does not implement package-source restrictions, dependency adaptation,
network isolation or universal vulnerability clearance.

The optional `base-image-bindings/v1` operation uses the existing
`BuildCapabilities` and `Build` RPCs. `BuildCapabilities.image_inputs` declares
unique roles, default fully qualified sha256-pinned references and agent-owned
recipe variants for the loaded service. A role can name `scratch`. Declarations
must reflect the service's CGO, native library and runtime package requirements.
Generated bindings alone do not advertise implementation.

The CLI resolves environment defaults, service-type selections and explicit
service selections into `DockerBuildContext.image_bindings`. A replacement names
its role, exact reference and supported variant. The agent validates these with
`services.ResolveBuildImages` and renders from the returned values. Unknown or
duplicate roles, mutable references, variant mismatches and replacement between
scratch and a registry image refuse. No agent identity or release roster decides
compatibility. `BuilderAgent.Build` refuses unsupported bindings before dispatch.

Registry relocation preserves content digest and variant. A different base
requires an explicit digest and the agent's supported recipe variant. Naming a
variant asserts compatibility; it does not prove arbitrary image contents have
that ABI or tooling. Image execution and target-platform qualification remain
required before adoption. A Wolfi recipe requires an implementation in its agent;
changing an Alpine image name cannot provide it.

The CLI must verify the emitted recipe actually consumes the resolved references
before fetching/building, retain the effective bindings with build evidence, and
include them in cache/reuse decisions. Agent recipe files are never rewritten by
the CLI. Builds without a selected image policy keep the existing contract and
require no fleet update. This Core contract alone does not enable CLI or agent
support or update any deployed image.
Loading
Loading