diff --git a/.changes/unreleased/+multi-identity-deployment-overrides.yaml b/.changes/unreleased/+multi-identity-deployment-overrides.yaml new file mode 100644 index 00000000..8d89aa66 --- /dev/null +++ b/.changes/unreleased/+multi-identity-deployment-overrides.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Preserve typed identity mapping overrides in staged deployment input and resolve effective mapping values without changing blueprint build identity. Mapped execution remains unavailable. diff --git a/internal/blueprint/identity_mapping_override.go b/internal/blueprint/identity_mapping_override.go new file mode 100644 index 00000000..a9b2dfc2 --- /dev/null +++ b/internal/blueprint/identity_mapping_override.go @@ -0,0 +1,163 @@ +package blueprint + +import ( + "fmt" + "slices" + "strings" + + "gopkg.in/yaml.v3" +) + +// IdentityMappingOverrideV1 is sparse deployment input, never host authority. +// A supplied array replaces the complete corresponding declaration array. +type IdentityMappingOverrideV1 struct { + Schema *int `yaml:"schema,omitempty"` + Profile *MappingScalarV1 `yaml:"profile,omitempty"` + UIDs *[]MappingIntervalV1 `yaml:"uids,omitempty"` + GIDs *[]MappingIntervalV1 `yaml:"gids,omitempty"` +} + +func (value MappingScalarV1) MarshalYAML() (any, error) { + if _, ok := value.integer(); ok { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: value.Value}, nil + } + if value.Kind == "string" || value.Kind == "missing" || value.Kind == "expression" { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value.Value}, nil + } + return nil, fmt.Errorf("%s cannot encode an invalid scalar", identityMappingPathV1) +} + +func (override *IdentityMappingOverrideV1) UnmarshalYAML(node *yaml.Node) error { + if _, err := validateSyntaxMapping(node, identityMappingPathV1, map[string]bool{ + "schema": true, "profile": true, "uids": true, "gids": true, + }); err != nil { + return err + } + for i := 0; i < len(node.Content); i += 2 { + name, value := node.Content[i].Value, node.Content[i+1] + if value.Tag == "!!null" || name == "schema" && value.Tag != "!!int" { + return fmt.Errorf("%s.%s has an invalid type", identityMappingPathV1, name) + } + if (name == "uids" || name == "gids") && value.Kind != yaml.SequenceNode { + return fmt.Errorf("%s.%s must be an array", identityMappingPathV1, name) + } + } + type plain IdentityMappingOverrideV1 + if err := node.Decode((*plain)(override)); err != nil { + return err + } + return override.Validate() +} + +func (override *IdentityMappingOverrideV1) Validate() error { + if override == nil { + return nil + } + missing := MappingScalarV1{Kind: "missing", Value: "???"} + placeholder := []MappingIntervalV1{{missing, missing, missing}} + _, err := (&IdentityMappingV1{Schema: 1, Profile: missing, UIDs: placeholder, GIDs: placeholder}).WithOverride(override) + return err +} + +// WithOverride validates portable intent without requiring effective values. +// It does not modify either input or resolve a leaf that an override replaces. +func (mapping *IdentityMappingV1) WithOverride(override *IdentityMappingOverrideV1) (*IdentityMappingV1, error) { + if mapping == nil { + if override != nil { + return nil, fmt.Errorf("%s override requires a blueprint declaration", identityMappingPathV1) + } + return nil, nil + } + if err := mapping.validate(); err != nil { + return nil, err + } + result := *mapping + if override != nil { + if override.Schema != nil { + result.Schema = *override.Schema + } + if override.Profile != nil { + result.Profile = *override.Profile + } + if override.UIDs != nil { + result.UIDs = *override.UIDs + } + if override.GIDs != nil { + result.GIDs = *override.GIDs + } + } + if err := result.validate(); err != nil { + return nil, err + } + result.UIDs = slices.Clone(result.UIDs) + result.GIDs = slices.Clone(result.GIDs) + return &result, nil +} + +// ResolveIdentityMappingV1 supplies a pure operation-specific candidate. It +// grants no reservation or execution admission and leaves portable intent intact. +func ResolveIdentityMappingV1(document Document, override *IdentityMappingOverrideV1) (*IdentityMappingV1, error) { + mapping := document.Environment.Runtime.IdentityMapping + if err := mapping.validateVariables(document.Environment.Vars); err != nil { + return nil, fmt.Errorf("environment %q: %w", document.Environment.ID, err) + } + result, err := mapping.WithOverride(override) + if err != nil { + return nil, fmt.Errorf("environment %q: %w", document.Environment.ID, err) + } + if result == nil { + return nil, nil + } + var missing []string + resolve := func(value *MappingScalarV1, path string) { + if value.Kind == "expression" { + if match := wholeInterpolationPattern.FindStringSubmatch(value.Value); match != nil { + binding, found := result.Variables[match[1]] + if !found { + binding = MappingScalarV1{Kind: "missing", Value: "???"} + } + *value = binding + } else { + unresolved := false + invalid := false + text := interpolationPattern.ReplaceAllStringFunc(value.Value, func(token string) string { + name := interpolationPattern.FindStringSubmatch(token)[1] + binding, found := result.Variables[name] + if !found || binding.Kind == "missing" || binding.Kind == "expression" { + unresolved = true + } + invalid = invalid || binding.Kind == "invalid" || binding.Kind == "null" + return binding.Value + }) + *value = MappingScalarV1{Kind: "string", Value: text} + if invalid { + value.Kind = "invalid" + } + if unresolved { + value.Kind = "missing" + } + } + } + if value.Kind == "missing" || value.Kind == "expression" { + missing = append(missing, path) + } + } + resolve(&result.Profile, identityMappingPathV1+".profile") + for i, values := range [][]MappingIntervalV1{result.UIDs, result.GIDs} { + for j := range values { + prefix := fmt.Sprintf("%s.%s[%d].", identityMappingPathV1, []string{"uids", "gids"}[i], j) + resolve(&values[j].ContainerStart, prefix+"container_start") + resolve(&values[j].HostStart, prefix+"host_start") + resolve(&values[j].Count, prefix+"count") + } + } + if len(missing) != 0 { + slices.Sort(missing) + return nil, fmt.Errorf("environment %q: required mapping values unresolved at %s; supply deployment overrides", document.Environment.ID, strings.Join(missing, ", ")) + } + result.Variables = nil + if err := result.validate(); err != nil { + return nil, fmt.Errorf("environment %q: %w", document.Environment.ID, err) + } + return canonicalIdentityMappingV1(result), nil +} diff --git a/internal/blueprint/identity_mapping_override_test.go b/internal/blueprint/identity_mapping_override_test.go new file mode 100644 index 00000000..b014a37e --- /dev/null +++ b/internal/blueprint/identity_mapping_override_test.go @@ -0,0 +1,189 @@ +package blueprint + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func mappingOverrideDocumentV1(t *testing.T, mapping, variables string) Document { + t.Helper() + source, err := Decode([]byte(mappingBlueprintV1(mapping, variables))) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + payload, err := EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + document, err = DecodeResolvedDocumentV1(payload) + if err != nil { + t.Fatal(err) + } + return document +} + +func decodeMappingOverrideV1(t *testing.T, text string) *IdentityMappingOverrideV1 { + t.Helper() + var override IdentityMappingOverrideV1 + if err := yaml.Unmarshal([]byte(text), &override); err != nil { + t.Fatal(err) + } + return &override +} + +func TestMappingOverrideStrictSparseInput(t *testing.T) { + for _, text := range []string{ + `{}`, `{schema: 1}`, `{profile: exact}`, `{profile: "???"}`, + `{uids: [{container_start: 0, host_start: "{{ host }}", count: 1}]}`, + } { + override := decodeMappingOverrideV1(t, text) + content, err := yaml.Marshal(override) + if err != nil { + t.Fatal(err) + } + decoded := decodeMappingOverrideV1(t, string(content)) + if !reflect.DeepEqual(override, decoded) { + t.Fatalf("sparse input changed across YAML: %s", text) + } + } + for _, text := range []string{ + `[]`, `{extra: 1}`, `{schema: 1, schema: 1}`, + `{schema: null}`, `{schema: "1"}`, `{schema: 1.0}`, `{schema: true}`, `{schema: 2}`, + `{profile: null}`, `{profile: false}`, `{profile: 1}`, `{profile: other}`, + `{uids: null}`, `{uids: {}}`, `{uids: []}`, `{uids: [null]}`, + `{uids: [{container_start: 0, count: 1}]}`, + `{uids: [{container_start: 0, host_start: 1, count: null}]}`, + `{uids: [{container_start: 0, host_start: "1", count: 1}]}`, + `{uids: [{container_start: 0, host_start: 1.0, count: 1}]}`, + `{uids: [{container_start: 0, host_start: 0, count: 1}]}`, + `{uids: [{container_start: 0, host_start: 1, count: 0}]}`, + `{uids: [{container_start: 0, host_start: 1, count: 1, extra: 0}]}`, + `{gids: [{container_start: 0, host_start: 1, count: 1, count: 1}]}`, + `{uids: [{container_start: 4294967294, host_start: 1, count: 2}]}`, + `{uids: [{container_start: 0, host_start: 10, count: 2}, {container_start: 1, host_start: 20, count: 1}]}`, + `{profile: bounded, uids: [{container_start: 0, host_start: 10, count: 1}, {container_start: 2, host_start: 20, count: 1}]}`, + } { + t.Run(text, func(t *testing.T) { + var override IdentityMappingOverrideV1 + if err := yaml.Unmarshal([]byte(text), &override); err == nil { + t.Fatalf("accepted malformed sparse override %s", text) + } + }) + } +} + +func TestMappingOverrideReplacesBeforeResolvingAndPreservesPortableAuthority(t *testing.T) { + mapping := `{schema: 1, profile: "???", uids: [{container_start: "{{ nonexistent }}", host_start: "???", count: "???"}], gids: [{container_start: 0, host_start: "???", count: 1}]}` + document := mappingOverrideDocumentV1(t, mapping, " vars:\n unused_host: 100000\n") + before, err := EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + override := decodeMappingOverrideV1(t, `{profile: exact, uids: [{container_start: 4, host_start: " {{ unused_host }} ", count: 2}], gids: [{container_start: 0, host_start: 200000, count: 1}]}`) + savedOverride, _ := yaml.Marshal(override) + result, err := ResolveIdentityMappingV1(document, override) + if err != nil { + t.Fatal(err) + } + if len(result.UIDs) != 1 || result.UIDs[0].ContainerStart.Value != "4" || result.UIDs[0].HostStart != (MappingScalarV1{"integer", "100000"}) || result.Variables != nil { + t.Fatalf("incorrect complete-array replacement/resolution: %#v", result) + } + result.UIDs[0].Count.Value = "999" + after, _ := EncodeResolvedDocumentV1(document) + afterOverride, _ := yaml.Marshal(override) + if before != after || string(savedOverride) != string(afterOverride) { + t.Fatal("candidate modified blueprint or caller-owned override") + } + firstDigest, _ := ResolvedDocumentDigestV1(before) + secondDigest, _ := DocumentDigestV1(document) + if firstDigest != secondDigest { + t.Fatal("deployment host selection changed portable authority") + } +} + +func TestMappingOverrideOriginalVariableTypesAfterWireRoundTrip(t *testing.T) { + for _, tc := range []struct { + value string + ok bool + }{ + {"100000", true}, {`"100000"`, false}, {"100000.0", false}, {"true", false}, + {"null", false}, {`"???"`, false}, {`[100000]`, false}, {`{id: 100000}`, false}, + } { + t.Run(tc.value, func(t *testing.T) { + document := mappingOverrideDocumentV1(t, mappingDeclarationV1, " vars:\n unused: "+tc.value+"\n") + override := decodeMappingOverrideV1(t, `{uids: [{container_start: 0, host_start: "{{ unused }}", count: 1}]}`) + _, err := ResolveIdentityMappingV1(document, override) + if (err == nil) != tc.ok { + t.Fatalf("original %s type: error = %v", tc.value, err) + } + if err != nil && !strings.Contains(err.Error(), "environment.runtime.identity_mapping.uids[0].host_start") { + t.Fatalf("error lacks exact consumer path: %v", err) + } + }) + } +} + +func TestMappingOverrideCollectsMissingPathsWithoutPrivateValues(t *testing.T) { + document := mappingOverrideDocumentV1(t, `{schema: 1, profile: "{{ missing_profile }}", uids: [{container_start: 0, host_start: "???", count: "{{ count }}"}], gids: [{container_start: 0, host_start: "{{ user.gid }}", count: 1}]}`, " vars:\n count: '???'\n private: do-not-print-this\n") + _, err := ResolveIdentityMappingV1(document, nil) + if err == nil { + t.Fatal("accepted unresolved mapping") + } + for _, path := range []string{".profile", ".uids[0].host_start", ".uids[0].count", ".gids[0].host_start"} { + if !strings.Contains(err.Error(), identityMappingPathV1+path) { + t.Errorf("missing path %s in %v", path, err) + } + } + if !strings.Contains(err.Error(), `environment "demo"`) || strings.Contains(err.Error(), "do-not-print-this") { + t.Fatalf("incorrect diagnostic context/privacy: %v", err) + } +} + +func TestMappingOverrideEffectiveValidationAndCanonicalOrder(t *testing.T) { + document := mappingOverrideDocumentV1(t, mappingDeclarationV1, " vars:\n first: 2\n second: 10\n") + for _, order := range []string{ + `[{container_start: "{{ second }}", host_start: 200000, count: 1}, {container_start: "{{ first }}", host_start: 100000, count: 1}]`, + `[{container_start: "{{ first }}", host_start: 100000, count: 1}, {container_start: "{{ second }}", host_start: 200000, count: 1}]`, + } { + mapping, err := ResolveIdentityMappingV1(document, decodeMappingOverrideV1(t, "{uids: "+order+"}")) + if err != nil || mapping.UIDs[0].ContainerStart.Value != "2" || mapping.UIDs[1].ContainerStart.Value != "10" { + t.Fatalf("canonical effective numeric order: %#v / %v", mapping, err) + } + } + for _, leaf := range []string{`"{{ first }}0"`, `"{{ missing }}"`, `"{{ runtime.uid }}"`} { + _, err := ResolveIdentityMappingV1(document, decodeMappingOverrideV1(t, fmt.Sprintf(`{uids: [{container_start: 0, host_start: %s, count: 1}]}`, leaf))) + if err == nil || !strings.Contains(err.Error(), ".uids[0].host_start") { + t.Fatalf("accepted coerced or unresolved ID %s: %v", leaf, err) + } + } + // Literal starts do not overlap, but their original typed bindings do. + _, err := ResolveIdentityMappingV1(document, decodeMappingOverrideV1(t, `{uids: [{container_start: "{{ first }}", host_start: 100000, count: 10}, {container_start: "{{ second }}", host_start: 200000, count: 1}]}`)) + if err == nil || !strings.Contains(err.Error(), "overlap") { + t.Fatalf("accepted effective overlap: %v", err) + } +} + +func TestMappingOverrideRequiresDeclarationAndRetainsOrdinaryBehavior(t *testing.T) { + source, err := Decode([]byte(minimalBlueprint)) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + if mapping, err := ResolveIdentityMappingV1(document, nil); err != nil || mapping != nil { + t.Fatalf("ordinary document changed: %#v / %v", mapping, err) + } + if _, err := ResolveIdentityMappingV1(document, decodeMappingOverrideV1(t, `{}`)); err == nil || !strings.Contains(err.Error(), "declaration") { + t.Fatalf("undeclared override accepted: %v", err) + } +} diff --git a/internal/deploy/desired_state.go b/internal/deploy/desired_state.go index 93d74e0c..0119670d 100644 --- a/internal/deploy/desired_state.go +++ b/internal/deploy/desired_state.go @@ -130,6 +130,9 @@ func setDesiredStateV1( document.Environment.ID, ) } + if err := initialOverrides.CheckIdentityMappingDeclaration(document); err != nil { + return DesiredStateUpdateResult{}, err + } if _, sidecarFound, readErr := ReadPackageOverridesV1(deploymentDir); readErr != nil { return DesiredStateUpdateResult{}, readErr } else if sidecarFound { @@ -138,6 +141,16 @@ func setDesiredStateV1( PackageOverridesFilename, ) } + } else if !found || current.Deployment == nil { + overrides, sidecarFound, readErr := ReadPackageOverridesV1(deploymentDir) + if readErr != nil { + return DesiredStateUpdateResult{}, readErr + } + if sidecarFound && overrides.IdentityMappingOverride() != nil { + if err := overrides.CheckIdentityMappingDeclaration(document); err != nil { + return DesiredStateUpdateResult{}, err + } + } } overlay := EmptyRequestOverlayV1() var generation *EnvironmentGenerationState diff --git a/internal/deploy/package_overrides.go b/internal/deploy/package_overrides.go index 1e1c14e1..1773645b 100644 --- a/internal/deploy/package_overrides.go +++ b/internal/deploy/package_overrides.go @@ -32,6 +32,7 @@ type PackageOverridesV1 struct { type PackageOverridesEnvironmentV1 struct { ID string `yaml:"id"` + Runtime *PackageOverridesRuntimeV1 `yaml:"runtime,omitempty"` Vars map[string]any `yaml:"vars,omitempty"` Base *BaseImageOverrideV1 `yaml:"base,omitempty"` PackageAdditions map[string][]string `yaml:"package_additions,omitempty"` @@ -117,6 +118,9 @@ func DecodePackageOverridesV1(content []byte) (PackageOverridesV1, error) { } return PackageOverridesV1{}, fmt.Errorf("decode package overrides: multiple YAML documents are not supported") } + if err := validateMappingOverridePresenceV1(content, overrides.IdentityMappingOverride() != nil); err != nil { + return PackageOverridesV1{}, err + } if err := ValidatePackageOverridesV1(overrides); err != nil { return PackageOverridesV1{}, err } @@ -176,7 +180,10 @@ func ValidatePackageOverridesV1(overrides PackageOverridesV1) error { return fmt.Errorf("overrides environment.base.image: %w", err) } } - if environment.PackageOverrides == nil { + if err := overrides.IdentityMappingOverride().Validate(); err != nil { + return err + } + if environment.PackageOverrides == nil && overrides.IdentityMappingOverride() == nil { return fmt.Errorf("package overrides environment.package_overrides must use a mapping") } for _, provider := range sortedKeys(environment.PackageAdditions) { diff --git a/internal/deploy/package_overrides_identity.go b/internal/deploy/package_overrides_identity.go new file mode 100644 index 00000000..039813f3 --- /dev/null +++ b/internal/deploy/package_overrides_identity.go @@ -0,0 +1,116 @@ +package deploy + +import ( + "fmt" + + "github.com/omry/reploy/internal/blueprint" + "gopkg.in/yaml.v3" +) + +type PackageOverridesRuntimeV1 struct { + IdentityMapping *blueprint.IdentityMappingOverrideV1 `yaml:"identity_mapping,omitempty"` +} + +func (overrides PackageOverridesV1) IdentityMappingOverride() *blueprint.IdentityMappingOverrideV1 { + if overrides.Environment.Runtime == nil { + return nil + } + return overrides.Environment.Runtime.IdentityMapping +} + +// CheckIdentityMappingDeclaration preserves unresolved portable inputs. It +// neither resolves effective values nor grants reservations or admission. +func (overrides PackageOverridesV1) CheckIdentityMappingDeclaration(document blueprint.Document) error { + if overrides.Environment.ID != document.Environment.ID { + return fmt.Errorf("mapping overrides target environment %q, want %q", overrides.Environment.ID, document.Environment.ID) + } + _, err := document.Environment.Runtime.IdentityMapping.WithOverride(overrides.IdentityMappingOverride()) + return err +} + +func (environment PackageOverridesEnvironmentV1) MarshalYAML() (any, error) { + type plain PackageOverridesEnvironmentV1 + var node yaml.Node + if err := node.Encode(plain(environment)); err != nil { + return nil, err + } + if environment.Runtime != nil && environment.Runtime.IdentityMapping != nil && len(environment.PackageOverrides) == 0 { + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value == "package_overrides" { + node.Content = append(node.Content[:i], node.Content[i+2:]...) + break + } + } + } + return &node, nil +} + +// Inspect presence over the same captured bytes as the strict decoder. YAML +// pointer fields alone cannot distinguish an omitted field from explicit null. +func validateMappingOverridePresenceV1(content []byte, mappingInput bool) error { + var root yaml.Node + if err := yaml.Unmarshal(content, &root); err != nil { + return err + } + var inspect func(*yaml.Node, string) error + type visit struct { + node *yaml.Node + path string + } + seen := map[visit]bool{} + inspect = func(node *yaml.Node, path string) error { + key := visit{node, path} + if seen[key] { + return nil + } + seen[key] = true + if node.Kind == yaml.AliasNode { + return inspect(node.Alias, path) + } + if node.Kind == yaml.SequenceNode { + for _, source := range node.Content { + if err := inspect(source, path); err != nil { + return err + } + } + return nil + } + if node.Kind != yaml.MappingNode { + return nil // The existing strict decoder owns the ordinary schema. + } + for i := 0; i < len(node.Content); i += 2 { + name, value := node.Content[i].Value, node.Content[i+1] + if node.Content[i].Tag == "!!merge" { + if err := inspect(value, path); err != nil { + return err + } + continue + } + for value.Kind == yaml.AliasNode { + value = value.Alias + } + child := path + "." + name + if path == "" { + child = name + } + if mappingInput && child == "environment.package_overrides" && value.Tag == "!!null" { + return fmt.Errorf("overrides %s must use a mapping", child) + } + if child == "environment.runtime" || child == "environment.runtime.identity_mapping" { + if value.Kind != yaml.MappingNode { + return fmt.Errorf("overrides %s must use a mapping", child) + } + } + if child == "environment" || child == "environment.runtime" { + if err := inspect(value, child); err != nil { + return err + } + } + } + return nil + } + if len(root.Content) == 0 { + return nil + } + return inspect(root.Content[0], "") +} diff --git a/internal/deploy/package_overrides_identity_test.go b/internal/deploy/package_overrides_identity_test.go new file mode 100644 index 00000000..96823501 --- /dev/null +++ b/internal/deploy/package_overrides_identity_test.go @@ -0,0 +1,206 @@ +package deploy + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" +) + +func mappingSidecarV1(mapping string) string { + return "environment:\n id: demo\n runtime:\n identity_mapping: " + mapping + "\n" +} + +func decodeMappingSidecarV1(t *testing.T, text string) PackageOverridesV1 { + t.Helper() + overrides, err := DecodePackageOverridesV1([]byte(text)) + if err != nil { + t.Fatal(err) + } + return overrides +} + +func TestMappingSidecarStrictSchemaAndNullPresence(t *testing.T) { + for _, text := range []string{ + mappingSidecarV1(`{}`), + mappingSidecarV1(`{schema: 1, profile: exact}`), + mappingSidecarV1(`{uids: [{container_start: 0, host_start: "???", count: 1}]}`), + } { + overrides := decodeMappingSidecarV1(t, text) + content, err := EncodePackageOverridesV1(overrides) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(content), "package_overrides") || strings.Contains(string(content), "kind:") { + t.Fatalf("mapping-only sidecar has artificial packages or internal scalar encoding: %s", content) + } + decoded := decodeMappingSidecarV1(t, string(content)) + second, err := EncodePackageOverridesV1(decoded) + if err != nil || !bytes.Equal(content, second) { + t.Fatalf("unstable mapping sidecar: %s / %s / %v", content, second, err) + } + } + for _, text := range []string{ + mappingSidecarV1("null"), mappingSidecarV1("[]"), mappingSidecarV1("{profile: null}"), + mappingSidecarV1("{schema: 1.0}"), mappingSidecarV1("{schema: 1, schema: 1}"), + mappingSidecarV1("{gids: null}"), mappingSidecarV1("{gids: []}"), + mappingSidecarV1("{extra: true}"), + mappingSidecarV1("{}") + " package_overrides: null\n", + mappingSidecarV1("{}") + " base: {image: debian:13, typo: true}\n", + mappingSidecarV1("{}") + " package_overrides: {python: {demo: {path: ., typo: true}}}\n", + mappingSidecarV1("{}") + " typo: true\n", + "environment: {id: demo, runtime: null, package_overrides: {}}\n", + "environment: {id: demo, runtime: {typo: true}, package_overrides: {}}\n", + "environment: {id: demo, runtime: {identity_mapping: {}, identity_mapping: {}}, package_overrides: {}}\n", + "environment: {id: demo, runtime: {identity_mapping: {}}, runtime: {identity_mapping: {}}}\n", + "environment: {id: demo}\n", + "environment: {<<: {id: demo, runtime: null}, package_overrides: {}}\n", + "environment: {<<: {id: demo, runtime: {identity_mapping: null}}, package_overrides: {}}\n", + "environment: {id: demo, runtime: {<<: {identity_mapping: null}}, package_overrides: {}}\n", + "environment: {<<: [{id: demo}, {runtime: {identity_mapping: null}}], package_overrides: {}}\n", + "environment: {id: demo, vars: {unset: &unset null}, runtime: {identity_mapping: {}}, package_overrides: *unset}\n", + } { + t.Run(text, func(t *testing.T) { + if _, err := DecodePackageOverridesV1([]byte(text)); err == nil { + t.Fatalf("accepted invalid mapping sidecar: %s", text) + } + }) + } +} + +func TestMappingSidecarPreservesOrdinaryBytesAndBuildIdentity(t *testing.T) { + empty := EmptyPackageOverridesV1("demo") + content, err := EncodePackageOverridesV1(empty) + if err != nil || string(content) != "environment:\n id: demo\n package_overrides: {}\n" { + t.Fatalf("ordinary empty sidecar bytes changed: %s / %v", content, err) + } + // Existing ordinary merges keep their previous effective-field semantics. + ordinaryMerge := decodeMappingSidecarV1(t, "environment: {<<: {id: demo, package_overrides: null}, package_overrides: {}}\n") + mergedBytes, err := EncodePackageOverridesV1(ordinaryMerge) + if err != nil || !bytes.Equal(content, mergedBytes) { + t.Fatalf("ordinary merge semantics changed: %s / %v", mergedBytes, err) + } + want, err := PackageOverridesDigestV1(empty) + if err != nil { + t.Fatal(err) + } + first := decodeMappingSidecarV1(t, mappingSidecarV1(`{uids: [{container_start: 0, host_start: 100000, count: 1}]}`)) + second := decodeMappingSidecarV1(t, mappingSidecarV1(`{uids: [{container_start: 0, host_start: 300000, count: 1}]}`)) + for _, mapping := range []PackageOverridesV1{first, second} { + got, err := PackageOverridesDigestV1(mapping) + if err != nil || got != want { + t.Fatalf("deployment-only host mapping invalidated empty build intent: %s / %v", got, err) + } + } + before, _ := EncodePackageOverridesV1(first) + first.Environment.Base = &BaseImageOverrideV1{Image: "debian:13"} + baseDigest, err := PackageOverridesDigestV1(first) + if err != nil || baseDigest == want { + t.Fatalf("base image ceased to be a build input: %v", err) + } + first.Environment.Base = nil + first.Environment.Vars = map[string]any{"name": "private"} + varsDigest, err := PackageOverridesDigestV1(first) + if err != nil || varsDigest == want { + t.Fatalf("existing sidecar variables ceased to be build inputs: %v", err) + } + first.Environment.Vars = nil + after, _ := EncodePackageOverridesV1(first) + if !bytes.Equal(before, after) || first.IdentityMappingOverride() == nil { + t.Fatal("build projection modified caller-owned deployment input") + } + invalid := decodeMappingSidecarV1(t, mappingSidecarV1(`{}`)) + badSchema := 2 + invalid.IdentityMappingOverride().Schema = &badSchema + if _, err := PackageOverridesDigestV1(invalid); err == nil { + t.Fatal("projection hid an invalid mapping override") + } +} + +func TestMappingSidecarExistingChoicesRoundTrip(t *testing.T) { + text := mappingSidecarV1(`{profile: bounded, uids: [{container_start: 0, host_start: 100000, count: 1}]}`) + + " vars: {root: /source}\n base: {image: debian:13}\n package_additions: {os: [curl]}\n package_overrides: {python: {demo: {path: '{{ root }}/demo', exclude: [cache, dist]}}}\n" + original := decodeMappingSidecarV1(t, text) + encoded, err := EncodePackageOverridesV1(original) + if err != nil { + t.Fatal(err) + } + decoded := decodeMappingSidecarV1(t, string(encoded)) + if decoded.Environment.Base.Image != "debian:13" || decoded.Environment.Vars["root"] != "/source" || decoded.Environment.PackageOverrides["python"]["demo"].Path != "{{ root }}/demo" || decoded.IdentityMappingOverride().Profile.Value != "bounded" { + t.Fatalf("unrelated existing choices lost: %#v", decoded) + } + // Mapping variables come only from the portable blueprint, not this sidecar. + document := overlayTestDocument() + document.Environment.ID = "demo" + document.Environment.Runtime.IdentityMapping = &blueprint.IdentityMappingV1{ + Schema: 1, Profile: blueprint.MappingScalarV1{Kind: "string", Value: "exact"}, + UIDs: []blueprint.MappingIntervalV1{{ContainerStart: blueprint.MappingScalarV1{Kind: "integer", Value: "0"}, HostStart: blueprint.MappingScalarV1{Kind: "integer", Value: "100000"}, Count: blueprint.MappingScalarV1{Kind: "integer", Value: "1"}}}, + GIDs: []blueprint.MappingIntervalV1{{ContainerStart: blueprint.MappingScalarV1{Kind: "integer", Value: "0"}, HostStart: blueprint.MappingScalarV1{Kind: "integer", Value: "200000"}, Count: blueprint.MappingScalarV1{Kind: "integer", Value: "1"}}}, + } + shadow := decodeMappingSidecarV1(t, mappingSidecarV1(`{uids: [{container_start: 0, host_start: "{{ selected }}", count: 1}]}`)+" vars: {selected: 300000}\n") + if _, err := blueprint.ResolveIdentityMappingV1(document, shadow.IdentityMappingOverride()); err == nil || !strings.Contains(err.Error(), ".uids[0].host_start") { + t.Fatalf("sidecar variable shadowed original blueprint authority: %v", err) + } +} + +func TestMappingSidecarStageUpdatePreservesOrRejectsBeforeStateWrite(t *testing.T) { + dir := t.TempDir() + document := overlayTestDocument() + document.Environment.ID = "demo" + platform := desiredStateTestPlatform(t, "linux/amd64") + if _, err := SetDesiredStateV1(t.Context(), dir, document, platform, overlayTestPackageValidator); err != nil { + t.Fatal(err) + } + statePath := filepath.Join(dir, ".reploy", "state.json") + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + content := []byte(mappingSidecarV1(`{profile: exact}`)) + if err := os.WriteFile(filepath.Join(dir, PackageOverridesFilename), content, 0o600); err != nil { + t.Fatal(err) + } + if _, err := SetDesiredStateV1(t.Context(), dir, document, platform, overlayTestPackageValidator); err == nil || !strings.Contains(err.Error(), "declaration") { + t.Fatalf("stage accepted override without a declaration: %v", err) + } + after, _ := os.ReadFile(statePath) + retained, _ := os.ReadFile(filepath.Join(dir, PackageOverridesFilename)) + if !bytes.Equal(before, after) || !bytes.Equal(content, retained) { + t.Fatal("rejected stage update changed state or deployment choices") + } +} + +func TestMappingSidecarInstalledStateDoesNotDiscoverStagingInput(t *testing.T) { + dir := t.TempDir() + path := writeOverlayTestState(t, dir) + state := readOverlayTestState(t, path) + state.Deployment = &DeploymentStateV1{Schema: DeploymentStateSchemaV1, Installation: installationStateV1Fixture(dir)} + encoded, err := EncodeStateV1(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, encoded, 0o600); err != nil { + t.Fatal(err) + } + // This file is deliberately invalid. Installed operations must not discover + // deployment-side staging choices, even when such a file exists. + unselected := []byte("environment: [not, installed, authority]\n") + sidecar := filepath.Join(dir, PackageOverridesFilename) + if err := os.WriteFile(sidecar, unselected, 0o600); err != nil { + t.Fatal(err) + } + document := overlayTestDocument() + document.Blueprint.Version = "updated" + result, err := SetDesiredStateV1(t.Context(), dir, document, state.Platform, overlayTestPackageValidator) + if err != nil || !reflect.DeepEqual(result.State.Deployment, state.Deployment) { + t.Fatalf("installed update discovered staging input or changed installation facts: %v", err) + } + after, _ := os.ReadFile(sidecar) + if !bytes.Equal(unselected, after) || result.State.Deployment.Installation.TargetDir != dir { + t.Fatal("installed update modified or adopted unselected staging input") + } +} diff --git a/internal/deploy/validated_build.go b/internal/deploy/validated_build.go index 1cd63f71..caaa2a76 100644 --- a/internal/deploy/validated_build.go +++ b/internal/deploy/validated_build.go @@ -53,6 +53,15 @@ type ValidatedBuildReferenceV1 struct { } func PackageOverridesDigestV1(overrides PackageOverridesV1) (canonical.Digest, error) { + if err := ValidatePackageOverridesV1(overrides); err != nil { + return "", err + } + // Deployment host mappings are not build inputs. Validate before projecting + // them out, and retain the historical empty package intent representation. + overrides.Environment.Runtime = nil + if overrides.Environment.PackageOverrides == nil { + overrides.Environment.PackageOverrides = map[string]map[string]PackageOverrideChoiceV1{} + } content, err := EncodePackageOverridesV1(overrides) if err != nil { return "", err diff --git a/internal/dockerdeploy/identity_mapping_input.go b/internal/dockerdeploy/identity_mapping_input.go new file mode 100644 index 00000000..832ae151 --- /dev/null +++ b/internal/dockerdeploy/identity_mapping_input.go @@ -0,0 +1,32 @@ +package dockerdeploy + +import ( + "fmt" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" +) + +// This private metadata seam does not allocate IDs or admit a workload. +// B5 will bind its candidate to the selected generation and mapping revision. +func loadStagedIdentityMappingV1(operation *deploy.OperationLock, dir string) (*blueprint.IdentityMappingV1, error) { + if operation == nil { + return nil, fmt.Errorf("mapping input requires a deployment operation lock") + } + state, found, err := operation.ReadStateV1() + if err != nil { + return nil, err + } + if !found || state.Deployment != nil || state.Staging == nil { + return nil, fmt.Errorf("mapping input requires selected staging state") + } + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + return nil, err + } + overrides, _, _, err := LoadStagedPackageOverridesV1(operation, dir, document) + if err != nil { + return nil, err + } + return blueprint.ResolveIdentityMappingV1(document, overrides.IdentityMappingOverride()) +} diff --git a/internal/dockerdeploy/identity_mapping_input_test.go b/internal/dockerdeploy/identity_mapping_input_test.go new file mode 100644 index 00000000..10a2747d --- /dev/null +++ b/internal/dockerdeploy/identity_mapping_input_test.go @@ -0,0 +1,257 @@ +package dockerdeploy + +import ( + "bytes" + "context" + "fmt" + "maps" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/providerstore" +) + +func mappingInputOverridesV1(t *testing.T, id, host string) deploy.PackageOverridesV1 { + t.Helper() + raw, err := deploy.DecodePackageOverridesV1([]byte(fmt.Sprintf("environment:\n id: %s\n runtime:\n identity_mapping:\n uids: [{container_start: 0, host_start: %s, count: 16}]\n", id, host))) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestMappingInputUsesLockedSelectedStateAndHasNoEffects(t *testing.T) { + dir, state := identityGuardFixtureV1(t, true, false) + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer operation.Unlock() + _, err = loadStagedIdentityMappingV1(operation, dir) + if err == nil || !strings.Contains(err.Error(), ".uids[0].host_start") { + t.Fatalf("missing input did not fail at its consumer: %v", err) + } + raw := mappingInputOverridesV1(t, document.Environment.ID, "300000") + if err := operation.CommitPackageOverridesV1(raw); err != nil { + t.Fatal(err) + } + before := identityGuardTreeV1(t, dir) + mapping, err := loadStagedIdentityMappingV1(operation, dir) + if err != nil || mapping.UIDs[0].HostStart.Value != "300000" { + t.Fatalf("effective selected input = %#v / %v", mapping, err) + } + after := identityGuardTreeV1(t, dir) + if !maps.Equal(before, after) { + t.Fatal("pure input resolution changed deployment files") + } + current, found, err := operation.ReadStateV1() + if err != nil || !found || current.Blueprint != state.Blueprint { + t.Fatalf("host override changed portable blueprint: %v", err) + } + if _, err := unmappedRuntimeDocumentV1(current); err == nil { + t.Fatal("effective metadata candidate granted execution admission") + } + if _, err := loadStagedIdentityMappingV1(operation, t.TempDir()); err == nil || !strings.Contains(err.Error(), "does not belong") { + t.Fatalf("wrong deployment lock accepted: %v", err) + } + if err := operation.Unlock(); err != nil { + t.Fatal(err) + } + if _, err := loadStagedIdentityMappingV1(operation, dir); err == nil { + t.Fatal("released operation lock accepted") + } +} + +func TestMappingInputCapturedSidecarDoesNotRereadChangedFile(t *testing.T) { + dir, state := identityGuardFixtureV1(t, true, false) + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer operation.Unlock() + first := mappingInputOverridesV1(t, document.Environment.ID, "300000") + if err := operation.CommitPackageOverridesV1(first); err != nil { + t.Fatal(err) + } + captured, _, _, err := LoadStagedPackageOverridesV1(operation, dir, document) + if err != nil { + t.Fatal(err) + } + second := mappingInputOverridesV1(t, document.Environment.ID, "400000") + content, err := deploy.EncodePackageOverridesV1(second) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, deploy.PackageOverridesFilename), content, 0o600); err != nil { + t.Fatal(err) + } + oldCandidate, err := blueprint.ResolveIdentityMappingV1(document, captured.IdentityMappingOverride()) + if err != nil || oldCandidate.UIDs[0].HostStart.Value != "300000" { + t.Fatalf("captured bytes were replaced during resolution: %#v / %v", oldCandidate, err) + } + newCandidate, err := loadStagedIdentityMappingV1(operation, dir) + if err != nil || newCandidate.UIDs[0].HostStart.Value != "400000" { + t.Fatalf("new operation did not capture current input: %#v / %v", newCandidate, err) + } +} + +func TestMappingInputRejectsInstalledAndUndeclaredOwners(t *testing.T) { + for _, installed := range []bool{false, true} { + dir, state := identityGuardFixtureV1(t, false, installed) + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + if installed { + if _, err := loadStagedIdentityMappingV1(operation, dir); err == nil || !strings.Contains(err.Error(), "staging") { + t.Fatalf("installed input silently used staging sidecar: %v", err) + } + } else { + state = identityGuardDocumentV1(t, state, false, false) + if err := operation.CommitStateV1(nil, state); err != nil { + t.Fatal(err) + } + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + if mapping, err := loadStagedIdentityMappingV1(operation, dir); err != nil || mapping != nil { + t.Fatalf("ordinary owner changed: %#v / %v", mapping, err) + } + if err := operation.CommitPackageOverridesV1(mappingInputOverridesV1(t, document.Environment.ID, "300000")); err != nil { + t.Fatal(err) + } + if _, _, _, err := LoadStagedPackageOverridesV1(operation, dir, document); err == nil || !strings.Contains(err.Error(), "declaration") { + t.Fatalf("build input accepted undeclared mapping: %v", err) + } + } + if err := operation.Unlock(); err != nil { + t.Fatal(err) + } + } +} + +func TestMappingInputRealStageImportsAndPreservesChoices(t *testing.T) { + ref, manifestPath := writeDesiredStateStagePack(t, "0.1.0") + original, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + declaration := " runtime:\n identity_mapping: {schema: 1, profile: exact, uids: [{container_start: 0, host_start: '???', count: 16}], gids: [{container_start: 0, host_start: 200000, count: 16}]}\n" + originalText := strings.ReplaceAll(string(original), "\r\n", "\n") + source := strings.Replace(originalText, " id: omegaconf-inspector\n", " id: omegaconf-inspector\n"+declaration, 1) + if source == originalText { + t.Fatal("fixture did not add a declaration") + } + if err := os.WriteFile(manifestPath, []byte(source), 0o600); err != nil { + t.Fatal(err) + } + local := mappingInputOverridesV1(t, "omegaconf-inspector", "300000") + encoded, err := deploy.EncodePackageOverridesV1(local) + if err != nil { + t.Fatal(err) + } + localPath := filepath.Join(filepath.Dir(manifestPath), deploy.PackageOverridesFilename) + if err := os.WriteFile(localPath, encoded, 0o600); err != nil { + t.Fatal(err) + } + dir := filepath.Join(t.TempDir(), "staged") + input := PackDesiredStateStageInputV1{DeploymentDir: dir, Pack: ref, ExplicitPlatform: "linux/amd64", Create: true} + staged, err := StagePackDesiredStateV1(t.Context(), input) + if err != nil { + t.Fatal(err) + } + imported, found, err := deploy.ReadPackageOverridesV1(dir) + if err != nil || !found || imported.IdentityMappingOverride() == nil || (*imported.IdentityMappingOverride().UIDs)[0].HostStart.Value != "300000" { + t.Fatalf("real stage dropped mapping input: %#v / %v", imported, err) + } + portable, err := blueprint.DecodeResolvedDocumentV1(staged.DesiredState.State.Blueprint) + if err != nil || portable.Environment.Runtime.IdentityMapping.UIDs[0].HostStart.Kind != "missing" { + t.Fatalf("host override replaced original portable intent: %#v / %v", portable, err) + } + before, _ := os.ReadFile(filepath.Join(dir, deploy.PackageOverridesFilename)) + if err := os.WriteFile(localPath, []byte(strings.Replace(string(encoded), "300000", "400000", 1)), 0o600); err != nil { + t.Fatal(err) + } + input.Create = false + if _, err := StagePackDesiredStateV1(t.Context(), input); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(filepath.Join(dir, deploy.PackageOverridesFilename)) + if !bytes.Equal(before, after) { + t.Fatal("update rediscovered source sidecar and replaced selected choices") + } + // The same local sidecar cannot opt a new ordinary blueprint into mappings. + if err := os.WriteFile(manifestPath, original, 0o600); err != nil { + t.Fatal(err) + } + input.Create = true + input.DeploymentDir = filepath.Join(t.TempDir(), "undeclared") + if _, err := StagePackDesiredStateV1(t.Context(), input); err == nil || !strings.Contains(err.Error(), "declaration") { + t.Fatalf("stage imported mapping without a declaration: %v", err) + } + if _, err := os.Stat(filepath.Join(input.DeploymentDir, ".reploy", "state.json")); !os.IsNotExist(err) { + t.Fatalf("rejected import published state: %v", err) + } +} + +func TestMappingInputRealProviderBuildPreservesValidatedInputs(t *testing.T) { + dir, state := identityGuardFixtureV1(t, true, false) + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + want, err := ValidatedBuildInputs(document, state.Overlay, deploy.EmptyPackageOverridesV1(document.Environment.ID), dir, state.Platform) + if err != nil { + t.Fatal(err) + } + for _, host := range []string{"100000", "300000"} { + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + if err := operation.CommitPackageOverridesV1(mappingInputOverridesV1(t, document.Environment.ID, host)); err != nil { + t.Fatal(err) + } + if err := operation.Unlock(); err != nil { + t.Fatal(err) + } + before := identityGuardTreeV1(t, dir) + prepared, executed := false, false + _, err = runProviderBuildV1(t.Context(), ProviderBuildRunInputV1{DeploymentDir: dir, NoCache: true, Runtime: blueprintRuntimeFixtureV1()}, providerBuildRunBackend{ + acquire: deploy.AcquireOperationLock, newStore: providerstore.NewStore, + prepare: func(_ context.Context, input LockedProviderBuildPreparationInputV1) (LockedProviderBuildPreparationV1, error) { + prepared = true + if input.ValidatedInputs.BlueprintDigest != want.BlueprintDigest || input.ValidatedInputs.PackageOverridesDigest != want.PackageOverridesDigest || input.ValidatedInputs.OverlayDigest != want.OverlayDigest || input.ValidatedInputs.BaseImage != want.BaseImage { + t.Fatal("deployment-only host change invalidated original build inputs") + } + if err := input.Operation.RequireHeld(); err != nil { + t.Fatal(err) + } + return LockedProviderBuildPreparationV1{Operation: input.Operation, Store: input.Store}, nil + }, + execute: func(_ context.Context, input LockedProviderBuildExecutionInputV1) (LockedProviderBuildExecutionResultV1, error) { + executed = true + return LockedProviderBuildExecutionResultV1{Reused: true}, nil + }, + }) + if err != nil || !prepared || !executed { + t.Fatalf("portable explicit build under mapping override: %v", err) + } + if !maps.Equal(before, identityGuardTreeV1(t, dir)) { + t.Fatal("portable build modified mapping or blueprint authority") + } + } +} diff --git a/internal/dockerdeploy/pack_stage_v1.go b/internal/dockerdeploy/pack_stage_v1.go index c0308c46..efc5145b 100644 --- a/internal/dockerdeploy/pack_stage_v1.go +++ b/internal/dockerdeploy/pack_stage_v1.go @@ -146,6 +146,9 @@ func localBlueprintInitialPackageOverridesV1( overrides.Environment.ID, ) } + if err := overrides.CheckIdentityMappingDeclaration(loaded.Document); err != nil { + return nil, err + } workspace, hasWorkspace, err := localBlueprintOverridesWorkspaceV1(&overrides, sourceDir) if err != nil { return nil, fmt.Errorf("resolve local blueprint package overrides workspace: %w", err) @@ -164,6 +167,7 @@ func localBlueprintInitialPackageOverridesV1( return nil, nil } staged := deploy.EmptyPackageOverridesV1(resolved.EnvironmentID) + staged.Environment.Runtime = overrides.Environment.Runtime if overrides.Environment.Base != nil { base := *overrides.Environment.Base staged.Environment.Base = &base diff --git a/internal/dockerdeploy/package_overrides.go b/internal/dockerdeploy/package_overrides.go index 93586466..ee401fb1 100644 --- a/internal/dockerdeploy/package_overrides.go +++ b/internal/dockerdeploy/package_overrides.go @@ -46,6 +46,9 @@ func LoadStagedPackageOverridesV1( raw.Environment.ID, document.Environment.ID, ) } + if err := raw.CheckIdentityMappingDeclaration(document); err != nil { + return deploy.PackageOverridesV1{}, deploy.ResolvedPackageOverridesV1{}, deploy.PackageOverrideIntentV1{}, err + } resolved, err := deploy.ResolvePackageOverridesV1(raw, absoluteDir, registry.NormalizePackageOverrideV1) if err != nil { return deploy.PackageOverridesV1{}, deploy.ResolvedPackageOverridesV1{}, deploy.PackageOverrideIntentV1{}, err diff --git a/internal/dockerdeploy/validated_build_candidate.go b/internal/dockerdeploy/validated_build_candidate.go index 45728341..9c93f982 100644 --- a/internal/dockerdeploy/validated_build_candidate.go +++ b/internal/dockerdeploy/validated_build_candidate.go @@ -50,6 +50,9 @@ func ValidatedBuildInputs( deploymentDir string, platform blueprint.Platform, ) (ValidatedBuildInputsV1, error) { + if err := overrides.CheckIdentityMappingDeclaration(document); err != nil { + return ValidatedBuildInputsV1{}, err + } blueprintDigest, err := blueprint.DocumentDigestV1(document) if err != nil { return ValidatedBuildInputsV1{}, err diff --git a/internal/overrideui/editor.go b/internal/overrideui/editor.go index 99c8b812..cd7ac488 100644 --- a/internal/overrideui/editor.go +++ b/internal/overrideui/editor.go @@ -2732,6 +2732,9 @@ func saveSidecarAt( document.Environment.ID, ) } + if err := overrides.CheckIdentityMappingDeclaration(document); err != nil { + return editorSnapshot{}, err + } content, err := deploy.EncodePackageOverridesV1(overrides) if err != nil { return editorSnapshot{}, err diff --git a/internal/overrideui/identity_mapping_test.go b/internal/overrideui/identity_mapping_test.go new file mode 100644 index 00000000..bf853a9d --- /dev/null +++ b/internal/overrideui/identity_mapping_test.go @@ -0,0 +1,72 @@ +package overrideui + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" +) + +func editorMappingInputV1(t *testing.T) deploy.PackageOverridesV1 { + t.Helper() + raw, err := deploy.DecodePackageOverridesV1([]byte("environment:\n id: example\n runtime:\n identity_mapping:\n uids: [{container_start: 0, host_start: 300000, count: 1}]\n")) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestMappingEditorPreservesInputThroughRealSaveAndConflict(t *testing.T) { + dir := stagedEditorDir(t) + document := editorDocument("demo>=1") + source, err := blueprint.Decode([]byte("blueprint: {schema: 1}\nenvironment:\n runtime:\n identity_mapping: {schema: 1, profile: exact, uids: [{container_start: 0, host_start: '???', count: 1}], gids: [{container_start: 0, host_start: 200000, count: 1}]}\n")) + if err != nil { + t.Fatal(err) + } + document.Environment.Runtime.IdentityMapping = source.Environment.Runtime.IdentityMapping + writeEditorState(t, dir, document) + raw := editorMappingInputV1(t) + commitOverrides(t, dir, raw) + m, err := newModel(Config{Context: t.Context(), DeploymentDir: dir, Document: document, Versions: func(context.Context, string) ([]string, error) { return []string{"1.0"}, nil }}) + if err != nil { + t.Fatal(err) + } + m.baseImage = "python:3.13-slim" + updated := m.buildRaw() + if !reflect.DeepEqual(updated.IdentityMappingOverride(), raw.IdentityMappingOverride()) { + t.Fatal("package editor dropped unrelated identity mapping") + } + snapshot, err := readEditorSnapshot(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + if _, err := saveSidecarAt(t.Context(), dir, snapshot, updated); err != nil { + t.Fatal(err) + } + retained, found, err := deploy.ReadPackageOverridesV1(dir) + if err != nil || !found || retained.Environment.Base.Image != "python:3.13-slim" || !reflect.DeepEqual(retained.IdentityMappingOverride(), raw.IdentityMappingOverride()) { + t.Fatalf("saved package/base change lost mapping input: %#v / %v", retained, err) + } + if _, err := saveSidecarAt(t.Context(), dir, snapshot, raw); err == nil || !strings.Contains(err.Error(), "changed while the editor was open") { + t.Fatalf("mapping-aware save bypassed existing source-race guard: %v", err) + } +} + +func TestMappingEditorRejectsUndeclaredOverrideBeforeSaving(t *testing.T) { + dir := stagedEditorDir(t) + snapshot, err := readEditorSnapshot(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + if _, err := saveSidecarAt(t.Context(), dir, snapshot, editorMappingInputV1(t)); err == nil || !strings.Contains(err.Error(), "declaration") { + t.Fatalf("editor saved mapping without a blueprint declaration: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, deploy.PackageOverridesFilename)); !os.IsNotExist(err) { + t.Fatalf("rejected editor save wrote a sidecar: %v", err) + } +}