diff --git a/.changes/unreleased/+base-only-direct-install.yaml b/.changes/unreleased/+base-only-direct-install.yaml new file mode 100644 index 0000000..98ffd4e --- /dev/null +++ b/.changes/unreleased/+base-only-direct-install.yaml @@ -0,0 +1,2 @@ +kind: Fixed +body: Preserve resolved base-image identity when building an application runtime layer for environments without package-provider layers. diff --git a/internal/dockerdeploy/install_runtime_identity.go b/internal/dockerdeploy/install_runtime_identity.go index c771035..35d3fe1 100644 --- a/internal/dockerdeploy/install_runtime_identity.go +++ b/internal/dockerdeploy/install_runtime_identity.go @@ -63,6 +63,20 @@ func buildInstalledRuntimeIdentityWithV1( if err != nil { return installedRuntimeIdentityBuildV1{}, fmt.Errorf("inspect installed runtime identity upstream: %w", err) } + baseImage, err := realizedImageFromDescriptor(source.Lock.Base) + if err != nil { + return installedRuntimeIdentityBuildV1{}, fmt.Errorf("resolve installed runtime identity base: %w", err) + } + if source.Lock.RuntimeLayer.Upstream == baseImage { + if upstream.Image.ConfigDigest != baseImage.ConfigDigest || upstream.Image.RootFSSubject != baseImage.RootFSSubject { + return installedRuntimeIdentityBuildV1{}, fmt.Errorf("installed runtime identity upstream no longer matches the staged build") + } + // Inspection by config ID proves that the selected base still exists, + // but Docker reports it using a local config-ID descriptor. Restore the + // locked registry identity before the account-specific runtime rebuild. + upstream.Descriptor = source.Lock.Base + upstream.Image = baseImage + } if upstream.Image != source.Lock.RuntimeLayer.Upstream { return installedRuntimeIdentityBuildV1{}, fmt.Errorf("installed runtime identity upstream no longer matches the staged build") } diff --git a/internal/dockerdeploy/install_runtime_identity_test.go b/internal/dockerdeploy/install_runtime_identity_test.go index e5a47c4..3a63e98 100644 --- a/internal/dockerdeploy/install_runtime_identity_test.go +++ b/internal/dockerdeploy/install_runtime_identity_test.go @@ -51,6 +51,23 @@ func TestValidateInstalledRuntimeIdentityBuildV1RejectsWrongPlannedAccount(t *te func TestBuildInstalledRuntimeIdentityV1RebuildsChangedAccount(t *testing.T) { current, _ := currentBuildReuseFixture(t) + current.Lock.Base.AuthorReference = "debian:bookworm-slim" + current.Lock.Base.ImmutableReference = "debian@" + string(rendererDigest("a")) + current.Lock.Base.ManifestDigest = rendererDigest("a") + baseImage, err := realizedImageFromDescriptor(current.Lock.Base) + if err != nil { + t.Fatal(err) + } + current.Lock.RuntimeLayer.Upstream = baseImage + current.Lock.RuntimeLayer.TransactionDigest, err = deploy.ApplicationRuntimeLayerTransactionDigestV1( + current.Lock.RuntimeLayer.Verifier, + current.Lock.RuntimeLayer.Account, + baseImage, + current.Lock.Platform, + ) + if err != nil { + t.Fatal(err) + } store, err := providerstore.NewStore(t.TempDir()) if err != nil { t.Fatal(err) @@ -69,13 +86,17 @@ func TestBuildInstalledRuntimeIdentityV1RebuildsChangedAccount(t *testing.T) { if candidate.ImageID != current.Lock.RuntimeLayer.Upstream.ConfigDigest || platform != current.Lock.Platform { t.Fatalf("upstream inspection = %#v / %#v", candidate, platform) } - return InspectedImageCandidate{Image: current.Lock.RuntimeLayer.Upstream}, nil + localDescriptor := current.Lock.Base + localDescriptor.AuthorReference = string(localDescriptor.ConfigDigest) + localDescriptor.ImmutableReference = string(localDescriptor.ConfigDigest) + localDescriptor.ManifestDigest = "" + return inspectedValidationCandidate(t, localDescriptor), nil }, finalize: func(_ context.Context, gotStore providerstore.Store, layers []FullImageValidationInput, final FullImageValidationInput, validate providers.RequirementProfileOwnerValidator, run FullImageValidationRunner, verifier deploy.ApplicationStartupVerifierV1, account deploy.ApplicationLocalAccountV1, _ RunOptions) (FinalizedBuildValidationResult, error) { if gotStore.Root() != store.Root() || len(layers) != 0 || validate == nil || run == nil || verifier != current.Lock.RuntimeLayer.Verifier || account != wantAccount { t.Fatalf("finalization inputs were not preserved") } - if final.Image.Image != current.Lock.RuntimeLayer.Upstream || !reflect.DeepEqual(final.Outputs, current.Lock.Catalog) || !reflect.DeepEqual(final.RuntimePolicy, current.Lock.RuntimePolicy) { + if !reflect.DeepEqual(final.Image.Descriptor, current.Lock.Base) || final.Image.Image != current.Lock.RuntimeLayer.Upstream || !reflect.DeepEqual(final.Outputs, current.Lock.Catalog) || !reflect.DeepEqual(final.RuntimePolicy, current.Lock.RuntimePolicy) { t.Fatalf("final validation input = %#v", final) } layer := current.Lock.RuntimeLayer diff --git a/internal/dockerdeploy/materialization_layer.go b/internal/dockerdeploy/materialization_layer.go index f6409ef..3fae9c3 100644 --- a/internal/dockerdeploy/materialization_layer.go +++ b/internal/dockerdeploy/materialization_layer.go @@ -301,11 +301,11 @@ func ValidateInspectedImageCandidateIdentity(candidate InspectedImageCandidate) if err := candidate.Descriptor.Validate(); err != nil { return fmt.Errorf("validate inspected materialization descriptor: %w", err) } - rootFSSubject, err := deploy.RootFSSubject(candidate.Descriptor.RootFSDiffIDs) + expected, err := realizedImageFromDescriptor(candidate.Descriptor) if err != nil { - return fmt.Errorf("validate inspected materialization rootfs: %w", err) + return fmt.Errorf("validate inspected materialization descriptor image: %w", err) } - if candidate.Image.Digest != candidate.Descriptor.ConfigDigest || candidate.Image.ConfigDigest != candidate.Descriptor.ConfigDigest || candidate.Image.RootFSSubject != rootFSSubject { + if candidate.Image != expected { return fmt.Errorf("inspected materialization image identity does not match its Docker descriptor") } return nil diff --git a/internal/dockerdeploy/materialization_layer_test.go b/internal/dockerdeploy/materialization_layer_test.go index 9b1df5c..c5ce15b 100644 --- a/internal/dockerdeploy/materialization_layer_test.go +++ b/internal/dockerdeploy/materialization_layer_test.go @@ -330,6 +330,23 @@ func TestValidateInspectedMaterializationCandidateChecksControlledValuesOnly(t * } } +func TestValidateInspectedImageCandidateIdentityRequiresCanonicalDescriptorImage(t *testing.T) { + descriptor := providerBaseDescriptor(t, true) + image, err := realizedImageFromDescriptor(descriptor) + if err != nil { + t.Fatal(err) + } + candidate := InspectedImageCandidate{Descriptor: descriptor, Image: image} + if err := ValidateInspectedImageCandidateIdentity(candidate); err != nil { + t.Fatal(err) + } + + candidate.Image.Digest = descriptor.ConfigDigest + if err := ValidateInspectedImageCandidateIdentity(candidate); err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("noncanonical registry image error = %v", err) + } +} + func commandOption(t *testing.T, args []string, option string) string { t.Helper() for index := range args { diff --git a/internal/dockerdeploy/provider_graph_validation.go b/internal/dockerdeploy/provider_graph_validation.go index 692878a..1ba03e9 100644 --- a/internal/dockerdeploy/provider_graph_validation.go +++ b/internal/dockerdeploy/provider_graph_validation.go @@ -91,6 +91,13 @@ func prepareProviderGraphValidation( if candidate.Image.RootFSSubject != graph.PrefixImages[0].RootFSSubject || candidate.Image.ConfigDigest != graph.PrefixImages[0].ConfigDigest { return ProviderGraphValidationPlan{}, fmt.Errorf("provider graph base changed after resolution") } + // Inspection by config ID proves that the immutable filesystem and + // configuration still exist, but Docker reports a config-ID descriptor + // instead of the resolved registry descriptor. Restore the exact base + // identity so the inspected candidate remains internally consistent and + // the application runtime layer connects to the graph's base prefix. + candidate.Descriptor = base + candidate.Image = graph.PrefixImages[0] final = FullImageValidationInput{ Image: candidate, Profiles: []providers.RequirementProfile{}, Outputs: append([]providers.RealizedOutput{}, baseCatalog...), RuntimePolicy: policy, diff --git a/internal/dockerdeploy/provider_graph_validation_test.go b/internal/dockerdeploy/provider_graph_validation_test.go index 0256234..c6b05ce 100644 --- a/internal/dockerdeploy/provider_graph_validation_test.go +++ b/internal/dockerdeploy/provider_graph_validation_test.go @@ -73,7 +73,7 @@ func TestPrepareProviderGraphValidationInspectsBaseOnlyGraph(t *testing.T) { if err != nil { t.Fatal(err) } - descriptor := providerBaseDescriptor(t, false) + descriptor := providerBaseDescriptor(t, true) baseImage, err := realizedImageFromDescriptor(descriptor) if err != nil { t.Fatal(err) @@ -94,13 +94,17 @@ func TestPrepareProviderGraphValidationInspectsBaseOnlyGraph(t *testing.T) { if candidate.ImageID != descriptor.ConfigDigest { t.Fatalf("base inspection candidate = %#v", candidate) } - return inspectedValidationCandidate(t, descriptor), nil + inspectedDescriptor := descriptor + inspectedDescriptor.AuthorReference = string(descriptor.ConfigDigest) + inspectedDescriptor.ImmutableReference = string(descriptor.ConfigDigest) + inspectedDescriptor.ManifestDigest = "" + return inspectedValidationCandidate(t, inspectedDescriptor), nil }, ) if err != nil { t.Fatal(err) } - if len(result.Layers) != 0 || result.Final.Image.Image != baseImage { + if len(result.Layers) != 0 || result.Final.Image.Image != baseImage || !reflect.DeepEqual(result.Final.Image.Descriptor, descriptor) { t.Fatalf("base-only validation plan = %#v", result) } }