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
41 changes: 32 additions & 9 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -2058,8 +2058,11 @@ Every composed service whose agent is that publisher and name runs against that
version — in `codefly run`, `codefly deploy gitops render` and every `codefly
ci` verb alike, because core applies it at the one place a composed module
loads a service. Only the version moves; the agent's publisher, name and kind
never do, and the module's own files are untouched. Each run or render states
it once per overridden agent:
never do, and the module's own files are untouched. A release built under an
override is not the release's build, so its image is tagged apart from the
release ([Keeping an image whose inputs did not
change](#keeping-an-image-whose-inputs-did-not-change)). Each run or render
states it once per overridden agent:

```text
agent codefly.dev/go-grpc overridden to 0.1.47 by workspace.codefly.yaml (3 services; module pins: 0.1.46)
Expand Down Expand Up @@ -4040,16 +4043,36 @@ two environments, is one image. Before a pushed build of a released version
for `<image>:<version>`. Every build records its input digest on the image as
the label `org.codefly.build.inputs`, and the published image is adopted —
nothing is built — when that label equals this build's inputs. A published
image recording **other** inputs refuses the build: its version was not moved
with its code, and the fix is a new release, never a rebuild under the old
version. One recording none (pushed before builds recorded their inputs) is
built again. Only the registry answering that the tag does not exist lets a
released version be built: any other failed read (an authorization refusal, a
timeout, an unreachable registry) stops the render, because building over it
would overwrite a released tag on any registry whose tags are mutable. With a
image recording **other** inputs refuses the build, and the fix is a new
release, never a rebuild under the old version. Every build also records the
agent that emitted its recipe as `org.codefly.build.agent`, so the refusal says
which input differs: another agent built the published image (a tag that holds
a build the release does not name), or the same agent did and the service's
code or a base image changed without its version moving. One recording no
inputs (pushed before builds recorded them) is built again. With a registry
Only the registry answering that the tag does not exist lets a released version
be built: any other failed read (an authorization refusal, a timeout, an
unreachable registry) stops the render, because building over it would
overwrite a released tag on any registry whose tags are mutable. With a
registry whose tags are immutable, each version then holds exactly one build
for good.

**A released version names the build its release pins.** A workspace's
[`agent-overrides`](#moving-the-agent-version-of-composed-services) moves the
agent a composed service builds with, and the agent emits the build recipe, so
the same release built under an override is another build. It is never pushed
under the release's tag: it is tagged `<version>-build.<identity>` — the first
16 hex characters of its input digest, e.g. `excel:0.3.2-build.3f9c0a1b2d4e5f60`
— and the release check does not apply to it. That tag holds one build; another
checkout composing the same override adopts it from the registry the same way.
Where the environment pulls through a remote repository, the qualified tag is
pushed and probed at the push registry like any other (`ghcr.io/…:0.3.2-build.…`).
The deployment still names the release's tag in the repository the cell pulls
from, pinned to the qualified build's digest (the digest is always captured for such a build), and the cell file
records only the repository and that digest, so nothing admits the wrong image.
An override to the version the module already pins moves nothing and builds
the release as usual.

A build also reuses the image this workspace already built from the same inputs
instead of building it again. A configuration-only change — an edit to a value
under `configurations/`, a workspace or module manifest, anything a service reads
Expand Down
13 changes: 9 additions & 4 deletions pkg/gitops/cell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,10 +524,15 @@ func TestAHostStatingAnEnvelopeCarriesDelivery(t *testing.T) {
// `@` followed by another algorithm, a short digest or upper-case hex is a
// digest no running pod reports, and is refused.
func TestOnlyASha256DigestPinsAnImage(t *testing.T) {
repository, digest, err := splitPinnedImage("registry.example.test/acme/api:1.2.0@sha256:" + strings.Repeat("a", 64))
require.NoError(t, err)
require.Equal(t, "registry.example.test/acme/api", repository)
require.Equal(t, "sha256:"+strings.Repeat("a", 64), digest)
// The tag is informational: a build pushed under a build-qualified tag (an
// agent the workspace overrides) is deployed under the release's tag with
// its own digest, and the cell names the repository and that digest alone.
for _, tag := range []string{":1.2.0", ":1.2.0-build.0123456789abcdef", ""} {
repository, digest, err := splitPinnedImage("registry.example.test/acme/api" + tag + "@sha256:" + strings.Repeat("a", 64))
require.NoError(t, err)
require.Equal(t, "registry.example.test/acme/api", repository, tag)
require.Equal(t, "sha256:"+strings.Repeat("a", 64), digest, tag)
}
for _, image := range []string{
"registry.example.test/acme/api:1.2.0",
"registry.example.test/acme/api@latest",
Expand Down
102 changes: 72 additions & 30 deletions pkg/orchestration/build_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ func (b *Builder) buildFromPlan(ctx context.Context, outputDir string, plan *bui
type imageReuse struct {
identity imageIdentity
entry *imageCacheEntry
// image, when set, is the reference the recipe is pushed under instead of
// the one the agent named: a build-qualified tag for a build whose agent the
// workspace overrides (override_build.go).
image string
}

// decideImageReuse takes the reuse decision for a recipe as it will be built
Expand Down Expand Up @@ -149,6 +153,25 @@ func (b *Builder) decideImageReuse(
wool.Field("recipe", recipe.GetName()), wool.ErrField(identityErr))
}
decision := imageReuse{identity: identity}
// A build under a workspace agent override is not the build the release
// names, so it is pushed under a tag of its own (override_build.go).
if shouldPush && releasedVersionTag(recipe.GetImage()) {
pinned, overrideErr := b.overriddenAgent(ctx)
if overrideErr != nil {
return decision, w.Wrap(overrideErr)
}
if pinned != "" {
if identity.Key == "" {
return decision, w.NewError("%s builds with agent %s, which the workspace overrides from the %s its release pins, and an image input cannot be bound to name that build apart from the release: %v",
b.instance.Unique(), b.instance.Service.Agent.Identifier(), pinned, identityErr)
}
recipe = proto.CloneOf(recipe)
recipe.Image = overrideBuildImage(recipe.GetImage(), identity.Key)
decision.image = recipe.GetImage()
w.Info("the agent is overridden, so this is not the release's build; tagging it apart",
wool.Field("agent", b.instance.Service.Agent.Identifier()), wool.Field("release_pins", pinned), wool.Field("image", decision.image))
}
}
if b.world.RebuildImages {
return decision, nil
}
Expand All @@ -159,7 +182,7 @@ func (b *Builder) decideImageReuse(
// image, whichever environment, machine or checkout pushed it, and nothing is
// built. An unversioned service (0.0.0, never published) is not a release and
// is built as before.
if shouldPush && releasedVersionTag(recipe.GetImage()) {
if shouldPush && (releasedVersionTag(recipe.GetImage()) || decision.image != "") {
digest, err := resolveImageManifestDigest(ctx, recipe.GetImage(), probeBuilder)
if err != nil && !isImageNotFound(err) {
// Only the registry saying the tag does not exist means it is not
Expand All @@ -172,7 +195,8 @@ func (b *Builder) decideImageReuse(
// with its code would hand back another release's image. The published
// image records the inputs it was built from, and only those inputs
// adopt it.
published, _ := resolveImageBuildInputs(ctx, recipe.GetImage(), probeBuilder)
labels, _ := resolveImageBuildLabels(ctx, recipe.GetImage(), probeBuilder)
published := labels[buildInputsLabel]
switch {
case identity.Key != "" && published == identity.Key:
decision.entry = &imageCacheEntry{
Expand All @@ -181,9 +205,7 @@ func (b *Builder) decideImageReuse(
}
return decision, nil
case identity.Key != "" && published != "":
return decision, w.NewError(
"%s is already published from other inputs: its version was not moved with its code; release a new version (codefly publish) instead of rebuilding a published one",
recipe.GetImage())
return decision, w.Wrap(publishedFromOtherInputs(recipe.GetImage(), b.instance.Service.Agent, labels[buildAgentLabel]))
default:
w.Warn("the published image records no build inputs; building it again",
wool.Field("image", recipe.GetImage()), wool.Field("recipe", recipe.GetName()))
Expand Down Expand Up @@ -326,10 +348,6 @@ func (b *Builder) buildRecipe(
return w.Wrapf(err, "cannot build recipe %s of %s", recipe.GetName(), b.instance.Unique())
}

// A pushed build records the immutable manifest digest a snapshot pins and a
// targeted single-service build returns to its caller. A non-pushed build
// never lands in a registry, so there is no digest to capture.
captureDigest := shouldPush && (b.world.Mode == SnapshotMode || b.world.CaptureImageDigest)
// Image evidence is owed by every recipe, not only the one a snapshot pins,
// so asking for it needs the digest of each pushed image too.
resolveEvidence := b.world.CollectImageSBOM
Expand All @@ -343,6 +361,17 @@ func (b *Builder) buildRecipe(
}
reuse = &decided
}
if reuse.image != "" {
recipe = proto.CloneOf(recipe)
recipe.Image = reuse.image
}
// A pushed build records the immutable manifest digest a snapshot pins and a
// targeted single-service build returns to its caller. A non-pushed build
// never lands in a registry, so there is no digest to capture. A build pushed
// under a qualified tag is deployed by its digest whatever the mode: the
// agent names the deployed image by the release's tag, which holds another
// build.
captureDigest := shouldPush && (b.world.Mode == SnapshotMode || b.world.CaptureImageDigest || reuse.image != "")
if reuse.entry != nil {
w.Info("no image input changed; keeping the built image",
wool.Field("image", reuse.entry.Image), wool.Field("digest", reuse.entry.Digest),
Expand Down Expand Up @@ -408,7 +437,7 @@ func (b *Builder) buildRecipe(

args, err := cachedBuildxArgs(recipe, dockerfile, contextDir, shouldPush, multiArch, metadataFile, builderName, cache, private)
if err == nil {
args = withBuildInputsLabel(args, identity.Key)
args = withBuildAgentLabel(withBuildInputsLabel(args, identity.Key), b.instance.Service.Agent)
if shouldPush {
args = withSourceLabel(args, b.world.Env, workspaceSourceRepository(ctx, b.world.Workspace.Dir()), multiArch)
}
Expand Down Expand Up @@ -1092,10 +1121,10 @@ var workspaceSourceRepository = func(ctx context.Context, dir string) string {
return "https://github.com/" + match[1] + "/" + match[2]
}

// resolveImageBuildInputs reads the build-inputs label every platform of a
// published image carries, or "" when it carries none or its platforms disagree.
// A seam so a test needs no registry.
var resolveImageBuildInputs = func(ctx context.Context, reference, builderName string) (string, error) {
// resolveImageBuildLabels reads the build labels every platform of a published
// image agrees on (buildInputsLabel, buildAgentLabel); a label a platform lacks
// or disagrees on is absent. A seam so a test needs no registry.
var resolveImageBuildLabels = func(ctx context.Context, reference, builderName string) (map[string]string, error) {
probe, cancel := context.WithTimeout(ctx, imageRegistryProbeTimeout)
defer cancel()
args := []string{buildxCommand, "imagetools", "inspect"}
Expand All @@ -1105,35 +1134,48 @@ var resolveImageBuildInputs = func(ctx context.Context, reference, builderName s
args = append(args, "--format", "{{json .Image}}", reference)
out, err := exec.CommandContext(probe, "docker", args...).Output()
if err != nil {
return "", err
return nil, err
}
return buildInputsFromImageConfig(out), nil
return buildLabelsFromImageConfig(out), nil
}

// buildInputsFromImageConfig reads the label out of `imagetools inspect`'s image
// configuration: one configuration for a single-platform image, one per platform
// otherwise. Every platform must record the same value.
func buildInputsFromImageConfig(raw []byte) string {
// buildLabelsFromImageConfig reads the build labels out of `imagetools
// inspect`'s image configuration: one configuration for a single-platform image,
// one per platform otherwise. A label is kept only when every platform records
// the same value.
func buildLabelsFromImageConfig(raw []byte) map[string]string {
type config struct {
Config struct {
Labels map[string]string `json:"Labels"`
} `json:"config"`
}
labels := map[string]string{}
var single config
if err := json.Unmarshal(raw, &single); err == nil && single.Config.Labels != nil {
return single.Config.Labels[buildInputsLabel]
for _, key := range []string{buildInputsLabel, buildAgentLabel} {
if value := single.Config.Labels[key]; value != "" {
labels[key] = value
}
}
return labels
}
var platforms map[string]config
if err := json.Unmarshal(raw, &platforms); err != nil || len(platforms) == 0 {
return ""
}
value := ""
for _, platform := range platforms {
label := platform.Config.Labels[buildInputsLabel]
if label == "" || (value != "" && label != value) {
return ""
return labels
}
for _, key := range []string{buildInputsLabel, buildAgentLabel} {
value := ""
for _, platform := range platforms {
label := platform.Config.Labels[key]
if label == "" || (value != "" && label != value) {
value = ""
break
}
value = label
}
if value != "" {
labels[key] = value
}
value = label
}
return value
return labels
}
Loading
Loading