diff --git a/.changes/unreleased/+multi-identity-mapping-declarations.yaml b/.changes/unreleased/+multi-identity-mapping-declarations.yaml new file mode 100644 index 00000000..1c00bd38 --- /dev/null +++ b/.changes/unreleased/+multi-identity-mapping-declarations.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Accept portable identity mapping declarations in blueprints, including required values. Staging and explicit builds preserve them, and exact-owned staging cleanup remains available. Execution and installation reject these declarations until multi-identity runtime admission is available. diff --git a/internal/blueprint/component_syntax.go b/internal/blueprint/component_syntax.go index 5ace309a..ef60efc1 100644 --- a/internal/blueprint/component_syntax.go +++ b/internal/blueprint/component_syntax.go @@ -6,6 +6,31 @@ import ( "gopkg.in/yaml.v3" ) +func (runtime *EnvironmentRuntimeSyntax) UnmarshalYAML(node *yaml.Node) error { + if _, err := validateSyntaxMapping(node, "environment runtime", map[string]bool{ + "user": true, "network": true, "identity_mapping": true, + }); err != nil { + return err + } + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value == "identity_mapping" && node.Content[i+1].Kind != yaml.MappingNode { + return fmt.Errorf("%s must be a mapping", identityMappingPathV1) + } + } + type plain EnvironmentRuntimeSyntax + return node.Decode((*plain)(runtime)) +} + +func (network *RuntimeNetworkSyntax) UnmarshalYAML(node *yaml.Node) error { + if _, err := validateSyntaxMapping(node, "runtime network", map[string]bool{ + "public": true, "local": true, "ambiguous": true, + }); err != nil { + return err + } + type plain RuntimeNetworkSyntax + return node.Decode((*plain)(network)) +} + func (base *BaseSyntax) UnmarshalYAML(node *yaml.Node) error { if _, err := validateSyntaxMapping(node, "base", map[string]bool{ "image": true, "exports": true, diff --git a/internal/blueprint/identity_mapping.go b/internal/blueprint/identity_mapping.go new file mode 100644 index 00000000..dfc64347 --- /dev/null +++ b/internal/blueprint/identity_mapping.go @@ -0,0 +1,358 @@ +package blueprint + +import ( + "bytes" + "cmp" + "encoding/json" + "fmt" + "math" + "slices" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +const identityMappingPathV1 = "environment.runtime.identity_mapping" + +// MappingScalarV1 preserves source type through resolved blueprint JSON. +// A float variable whose value is 1 must never become an integer mapping ID. +type MappingScalarV1 struct { + Kind string `json:"kind"` + Value string `json:"value"` +} + +type MappingIntervalV1 struct { + ContainerStart MappingScalarV1 `yaml:"container_start"` + HostStart MappingScalarV1 `yaml:"host_start"` + Count MappingScalarV1 `yaml:"count"` +} + +// IdentityMappingV1 retains portable intent, not allocation or admission. +// Variables retain every original blueprint variable's scalar type. +type IdentityMappingV1 struct { + Schema int `yaml:"schema"` + Profile MappingScalarV1 `yaml:"profile"` + UIDs []MappingIntervalV1 `yaml:"uids"` + GIDs []MappingIntervalV1 `yaml:"gids"` + Variables map[string]MappingScalarV1 `yaml:"-" json:"Variables,omitempty"` +} + +func (value *MappingScalarV1) UnmarshalYAML(node *yaml.Node) error { + if node.Kind != yaml.ScalarNode { + return fmt.Errorf("%s values must be scalars", identityMappingPathV1) + } + switch node.Tag { + case "!!int": + var n uint64 + if err := node.Decode(&n); err != nil || n >= math.MaxUint32 { + return fmt.Errorf("%s IDs must be usable unsigned 32-bit integers", identityMappingPathV1) + } + *value = MappingScalarV1{"integer", strconv.FormatUint(n, 10)} + case "!!str": + *value = mappingStringV1(node.Value) + default: + return fmt.Errorf("%s values must be integers, profile strings, required values or expressions", identityMappingPathV1) + } + return nil +} + +func mappingStringV1(text string) MappingScalarV1 { + kind := "string" + if text == "???" { + kind = "missing" + } else if interpolationPattern.MatchString(text) { + kind = "expression" + } + return MappingScalarV1{kind, text} +} + +func (value MappingScalarV1) integer() (uint64, bool) { + n, err := strconv.ParseUint(value.Value, 10, 32) + return n, value.Kind == "integer" && err == nil && n < math.MaxUint32 && strconv.FormatUint(n, 10) == value.Value +} + +func (value MappingScalarV1) validate(path string, profile bool) error { + if value.Kind == "missing" && value.Value == "???" || + value.Kind == "expression" && interpolationPattern.MatchString(value.Value) { + return nil + } + if profile && value.Kind == "string" && (value.Value == "exact" || value.Value == "bounded") { + return nil + } + if !profile { + if _, ok := value.integer(); ok { + return nil + } + } + return fmt.Errorf("%s has an invalid type or value", path) +} + +func (mapping *IdentityMappingV1) UnmarshalYAML(node *yaml.Node) error { + present, err := validateSyntaxMapping(node, identityMappingPathV1, map[string]bool{ + "schema": true, "profile": true, "uids": true, "gids": true, + }) + if err != nil { + return err + } + for _, field := range []string{"schema", "profile", "uids", "gids"} { + if !present[field] { + return fmt.Errorf("%s.%s is required", identityMappingPathV1, field) + } + } + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value == "schema" && node.Content[i+1].Tag != "!!int" { + return fmt.Errorf("%s.schema must be the integer 1", identityMappingPathV1) + } + } + type plain IdentityMappingV1 + if err := node.Decode((*plain)(mapping)); err != nil { + return err + } + return mapping.validate() +} + +func (interval *MappingIntervalV1) UnmarshalYAML(node *yaml.Node) error { + present, err := validateSyntaxMapping(node, identityMappingPathV1+" interval", map[string]bool{ + "container_start": true, "host_start": true, "count": true, + }) + if err != nil { + return err + } + for _, field := range []string{"container_start", "host_start", "count"} { + if !present[field] { + return fmt.Errorf("%s interval.%s is required", identityMappingPathV1, field) + } + } + type plain MappingIntervalV1 + return node.Decode((*plain)(interval)) +} + +func (mapping *IdentityMappingV1) validate() error { + if mapping == nil { + return nil + } + if mapping.Schema != 1 { + return fmt.Errorf("%s.schema must be the integer 1", identityMappingPathV1) + } + if err := mapping.Profile.validate(identityMappingPathV1+".profile", true); err != nil { + return err + } + for i, values := range [][]MappingIntervalV1{mapping.UIDs, mapping.GIDs} { + path := identityMappingPathV1 + "." + []string{"uids", "gids"}[i] + if len(values) == 0 || mapping.Profile == (MappingScalarV1{"string", "bounded"}) && len(values) != 1 { + return fmt.Errorf("%s must be nonempty and bounded maps require exactly one interval", path) + } + if err := validateMappingLiteralsV1(path, values); err != nil { + return err + } + } + for name, value := range mapping.Variables { + if name == "" || strings.Contains(name, ".") { + return fmt.Errorf("%s has an invalid blueprint variable binding", identityMappingPathV1) + } + switch value.Kind { + case "integer": + signed, e1 := strconv.ParseInt(value.Value, 10, 64) + unsigned, e2 := strconv.ParseUint(value.Value, 10, 64) + if e1 == nil && strconv.FormatInt(signed, 10) == value.Value || e2 == nil && strconv.FormatUint(unsigned, 10) == value.Value { + continue + } + case "float": + n, err := strconv.ParseFloat(value.Value, 64) + if err == nil && !math.IsInf(n, 0) && !math.IsNaN(n) && strconv.FormatFloat(n, 'g', -1, 64) == value.Value { + continue + } + case "bool": + if value.Value == "true" || value.Value == "false" { + continue + } + case "null", "invalid": + if value.Value == "" { + continue + } + case "string": + continue + case "missing", "expression": + if value.validate(identityMappingPathV1, false) == nil { + continue + } + default: + } + return fmt.Errorf("%s has an invalid blueprint variable type", identityMappingPathV1) + } + return nil +} + +func validateMappingLiteralsV1(path string, values []MappingIntervalV1) error { + type interval struct{ start, end uint64 } + var container, host []interval + for i, value := range values { + fields := []MappingScalarV1{value.ContainerStart, value.HostStart, value.Count} + for j, field := range []string{"container_start", "host_start", "count"} { + if err := fields[j].validate(fmt.Sprintf("%s[%d].%s", path, i, field), false); err != nil { + return err + } + } + count, countKnown := value.Count.integer() + if countKnown && count == 0 { + return fmt.Errorf("%s[%d].count must be positive", path, i) + } + if !countKnown { + count = 1 // Every valid resolution must have a positive count. + } + for j, start := range []MappingScalarV1{value.ContainerStart, value.HostStart} { + n, known := start.integer() + if j == 1 && known && n == 0 { + return fmt.Errorf("%s[%d].host_start cannot include host root", path, i) + } + if !known { + continue + } + if n+count > math.MaxUint32 { + return fmt.Errorf("%s[%d] exceeds usable unsigned 32-bit IDs", path, i) + } + if j == 0 { + container = append(container, interval{n, n + count}) + } else { + host = append(host, interval{n, n + count}) + } + } + } + for _, intervals := range [][]interval{container, host} { + slices.SortFunc(intervals, func(a, b interval) int { + if a.start < b.start { + return -1 + } + if a.start > b.start { + return 1 + } + return 0 + }) + for i := 1; i < len(intervals); i++ { + if intervals[i].start < intervals[i-1].end { + return fmt.Errorf("%s literal intervals overlap", path) + } + } + } + return nil +} + +func mappingContainerKeyV1(value MappingScalarV1, variables map[string]MappingScalarV1) (uint64, bool) { + if value.Kind == "expression" { + if match := wholeInterpolationPattern.FindStringSubmatch(value.Value); match != nil { + value = variables[match[1]] + } + } + return value.integer() +} + +// Return canonical validated intervals without changing caller-owned data. +func canonicalIdentityMappingV1(mapping *IdentityMappingV1) *IdentityMappingV1 { + if mapping == nil { + return nil + } + normalized := *mapping + normalized.UIDs = slices.Clone(mapping.UIDs) + normalized.GIDs = slices.Clone(mapping.GIDs) + for _, values := range [][]MappingIntervalV1{normalized.UIDs, normalized.GIDs} { + slices.SortFunc(values, func(a, b MappingIntervalV1) int { + left, leftKnown := mappingContainerKeyV1(a.ContainerStart, mapping.Variables) + right, rightKnown := mappingContainerKeyV1(b.ContainerStart, mapping.Variables) + if leftKnown != rightKnown { + if leftKnown { + return -1 + } + return 1 + } + if leftKnown && left != right { + return cmp.Compare(left, right) + } + // Unknown keys and equal numeric keys keep one deterministic intent. + other := [3]MappingScalarV1{b.ContainerStart, b.HostStart, b.Count} + for i, field := range [3]MappingScalarV1{a.ContainerStart, a.HostStart, a.Count} { + if order := cmp.Compare(field.Kind, other[i].Kind); order != 0 { + return order + } + if order := cmp.Compare(field.Value, other[i].Value); order != 0 { + return order + } + } + return 0 + }) + } + return &normalized +} + +func prepareIdentityMappingV1(mapping *IdentityMappingV1, variables map[string]any) (*IdentityMappingV1, error) { + if err := mapping.validate(); err != nil || mapping == nil { + return mapping, err + } + prepared := *mapping + mapping = &prepared + mapping.Variables = nil + if len(variables) != 0 { + mapping.Variables = make(map[string]MappingScalarV1, len(variables)) + } + for name, item := range variables { + mapping.Variables[name] = mappingVariableV1(item) + } + return canonicalIdentityMappingV1(mapping), nil +} + +func mappingVariableV1(item any) MappingScalarV1 { + typed := MappingScalarV1{Value: fmt.Sprint(item)} + switch item.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + typed.Kind = "integer" + case float32, float64: + typed.Kind = "float" + case bool: + typed.Kind = "bool" + case nil: + typed = MappingScalarV1{Kind: "null"} + case string: + typed = mappingStringV1(item.(string)) + default: + typed = MappingScalarV1{Kind: "invalid"} + } + return typed +} + +func (mapping *IdentityMappingV1) validateVariables(variables map[string]any) error { + if mapping == nil { + return nil + } + inconsistent := fmt.Errorf("%s variable bindings do not match blueprint variables; restage from the original blueprint", identityMappingPathV1) + if len(mapping.Variables) != len(variables) { + return inconsistent + } + for name, item := range variables { + value, found := mapping.Variables[name] + wanted := mappingVariableV1(item) + if !found { + return inconsistent + } + if value == wanted { + continue + } + // JSON loses integral numeric kinds; the saved source kind stays authoritative. + if (value.Kind == "integer" || value.Kind == "float") && (wanted.Kind == "integer" || wanted.Kind == "float") { + var declared any = json.Number(value.Value) + if value.Kind == "float" { + number, err := strconv.ParseFloat(value.Value, 64) + if err != nil { + return inconsistent + } + declared = number + } + saved, e1 := json.Marshal(declared) + resolved, e2 := json.Marshal(item) + if e1 == nil && e2 == nil && bytes.Equal(saved, resolved) { + continue + } + } + return inconsistent + } + return nil +} diff --git a/internal/blueprint/identity_mapping_test.go b/internal/blueprint/identity_mapping_test.go new file mode 100644 index 00000000..042a0fe8 --- /dev/null +++ b/internal/blueprint/identity_mapping_test.go @@ -0,0 +1,666 @@ +package blueprint + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "reflect" + "strings" + "testing" +) + +const mappingDeclarationV1 = `{schema: 1, profile: exact, uids: [{container_start: 0, host_start: 100000, count: 16}], gids: [{container_start: 0, host_start: 200000, count: 16}]}` + +func mappingBlueprintV1(mapping, variables string) string { + return strings.Replace(minimalBlueprint, " id: demo\n", " id: demo\n"+variables+" runtime:\n identity_mapping: "+mapping+"\n", 1) +} + +func TestIdentityMappingV1StrictRealBlueprintDecode(t *testing.T) { + for _, tc := range []struct{ name, old, replacement string }{ + {"unknown mapping field", "schema: 1", "schema: 1, extra: true"}, + {"duplicate mapping field", "schema: 1", "schema: 1, schema: 1"}, + {"missing schema", "schema: 1, ", ""}, + {"missing profile", "profile: exact, ", ""}, + {"missing uids", "uids: [{container_start: 0, host_start: 100000, count: 16}], ", ""}, + {"missing gids", ", gids: [{container_start: 0, host_start: 200000, count: 16}]", ""}, + {"string schema", "schema: 1", `schema: "1"`}, + {"float schema", "schema: 1", "schema: 1.0"}, + {"boolean schema", "schema: 1", "schema: true"}, + {"null schema", "schema: 1", "schema: null"}, + {"required schema", "schema: 1", `schema: "???"`}, + {"wrong schema", "schema: 1", "schema: 2"}, + {"bad profile", "profile: exact", "profile: other"}, + {"integer profile", "profile: exact", "profile: 1"}, + {"null profile", "profile: exact", "profile: null"}, + {"empty uids", "[{container_start: 0, host_start: 100000, count: 16}]", "[]"}, + {"null uids", "[{container_start: 0, host_start: 100000, count: 16}]", "null"}, + {"object uids", "[{container_start: 0, host_start: 100000, count: 16}]", "{}"}, + {"scalar interval", "{container_start: 0, host_start: 100000, count: 16}", "17"}, + {"null interval", "{container_start: 0, host_start: 100000, count: 16}", "null"}, + {"missing interval leaf", "container_start: 0, host_start: 100000", "host_start: 100000"}, + {"unknown interval leaf", "host_start: 100000", "host_start: 100000, extra: 1"}, + {"duplicate interval leaf", "host_start: 100000", "host_start: 100000, host_start: 100000"}, + {"string integer", "host_start: 100000", `host_start: "100000"`}, + {"float integer", "host_start: 100000", "host_start: 100000.0"}, + {"boolean integer", "host_start: 100000", "host_start: true"}, + {"null integer", "host_start: 100000", "host_start: null"}, + {"array integer", "host_start: 100000", "host_start: [100000]"}, + {"negative start", "host_start: 100000", "host_start: -1"}, + {"host root", "host_start: 100000", "host_start: 0"}, + {"sentinel", "host_start: 100000", "host_start: 4294967295"}, + {"too large", "host_start: 100000", "host_start: 4294967296"}, + {"overflow", "host_start: 100000", "host_start: 4294967294"}, + {"zero count", "count: 16", "count: 0"}, + {"negative count", "count: 16", "count: -1"}, + {"string count", "count: 16", `count: "16"`}, + } { + t.Run(tc.name, func(t *testing.T) { + mapping := strings.Replace(mappingDeclarationV1, tc.old, tc.replacement, 1) + if mapping == mappingDeclarationV1 { + t.Fatal("fixture did not change") + } + if _, err := Decode([]byte(mappingBlueprintV1(mapping, ""))); err == nil { + t.Fatalf("accepted invalid mapping: %s", mapping) + } + }) + } + for _, mapping := range []string{"null", "[]", `"???"`, `"text"`, "true"} { + if _, err := Decode([]byte(mappingBlueprintV1(mapping, ""))); err == nil { + t.Fatalf("accepted non-mapping declaration %s", mapping) + } + } +} + +func TestIdentityMappingV1LiteralGeometryAndMissingLeaves(t *testing.T) { + for _, tc := range []struct { + name, profile, uids string + ok bool + }{ + {"adjacent exact", "exact", "[{container_start: 16, host_start: 100016, count: 16}, {container_start: 0, host_start: 100000, count: 16}]", true}, + {"container overlap", "exact", "[{container_start: 0, host_start: 100000, count: 16}, {container_start: 15, host_start: 100016, count: 16}]", false}, + {"host overlap", "exact", "[{container_start: 0, host_start: 100000, count: 16}, {container_start: 16, host_start: 100015, count: 16}]", false}, + {"duplicate assignment", "exact", "[{container_start: 0, host_start: 100000, count: 16}, {container_start: 0, host_start: 100000, count: 16}]", false}, + {"bounded multiple", "bounded", "[{container_start: 0, host_start: 100000, count: 16}, {container_start: 16, host_start: 100016, count: 16}]", false}, + {"bounded single", "bounded", "[{container_start: 0, host_start: 100000, count: 16}]", true}, + {"last usable ID", "exact", "[{container_start: 4294967294, host_start: 4294967294, count: 1}]", true}, + {"container overflow", "exact", "[{container_start: 4294967294, host_start: 100000, count: 2}]", false}, + {"required values", `"???"`, `[{container_start: "???", host_start: "???", count: "???"}]`, true}, + {"lazy unknown", `"{{ profile }}"`, `[{container_start: 0, host_start: "{{ absent }}", count: 16}]`, true}, + {"lazy namespace", "exact", `[{container_start: 0, host_start: "{{ user.uid }}", count: 16}]`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + mapping := fmt.Sprintf("{schema: 1, profile: %s, uids: %s, gids: [{container_start: 0, host_start: 200000, count: 16}]}", tc.profile, tc.uids) + source, err := Decode([]byte(mappingBlueprintV1(mapping, ""))) + if !tc.ok { + if err == nil { + t.Fatal("invalid literal geometry accepted") + } + return + } + 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) + } + decoded, err := DecodeResolvedDocumentV1(payload) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(decoded.Environment.Runtime.IdentityMapping, document.Environment.Runtime.IdentityMapping) { + t.Fatal("portable mapping changed during real resolved-document round trip") + } + }) + } +} + +func TestIdentityMappingV1RejectsInvalidPersistedVariableBindings(t *testing.T) { + source, err := Decode([]byte(mappingBlueprintV1(mappingDeclarationV1, ""))) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + value MappingScalarV1 + }{ + {"", MappingScalarV1{"integer", "1"}}, + {"user.uid", MappingScalarV1{"integer", "1"}}, + {"selected", MappingScalarV1{"integer", "01"}}, + {"selected", MappingScalarV1{"integer", "18446744073709551616"}}, + {"selected", MappingScalarV1{"float", "NaN"}}, + {"selected", MappingScalarV1{"float", "+Inf"}}, + {"selected", MappingScalarV1{"float", "text"}}, + {"selected", MappingScalarV1{"bool", "True"}}, + {"selected", MappingScalarV1{"null", "null"}}, + {"selected", MappingScalarV1{"invalid", "secret"}}, + {"selected", MappingScalarV1{"missing", "text"}}, + {"selected", MappingScalarV1{"expression", "literal"}}, + {"selected", MappingScalarV1{"other", ""}}, + } { + t.Run(tc.name+"/"+tc.value.Kind+"/"+tc.value.Value, func(t *testing.T) { + document.Environment.Runtime.IdentityMapping.Variables = map[string]MappingScalarV1{tc.name: tc.value} + if _, err := EncodeResolvedDocumentV1(document); err == nil { + t.Fatal("accepted invalid persisted type binding") + } + }) + } +} + +func TestIdentityMappingV1LiteralOrderHasOnePersistedIdentity(t *testing.T) { + first := "{container_start: 2, host_start: 100002, count: 1}" + second := "{container_start: 10, host_start: 100010, count: 1}" + var canonical ResolvedDocumentV1 + for i, entries := range []string{first + ", " + second, second + ", " + first} { + mapping := strings.Replace(mappingDeclarationV1, "[{container_start: 0, host_start: 100000, count: 16}]", "["+entries+"]", 1) + source, err := Decode([]byte(mappingBlueprintV1(mapping, ""))) + if err != nil { + t.Fatal(err) + } + before := source.Environment.Runtime.IdentityMapping.UIDs[0] + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + if source.Environment.Runtime.IdentityMapping.UIDs[0] != before { + t.Fatal("resolution changed original syntax order") + } + payload, err := EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + if i == 0 { + canonical = payload + } else if payload != canonical { + t.Fatal("literal interval order changed persisted blueprint authority") + } + if document.Environment.Runtime.IdentityMapping.UIDs[0].ContainerStart.Value != "2" { + t.Fatal("literal intervals were not sorted numerically") + } + } +} + +func TestIdentityMappingV1WireOrderHasOneAuthority(t *testing.T) { + mapping := strings.Replace(mappingDeclarationV1, "[{container_start: 0, host_start: 100000, count: 16}]", + "[{container_start: 2, host_start: 100002, count: 1}, {container_start: 10, host_start: 100010, count: 1}]", 1) + mapping = strings.Replace(mapping, "[{container_start: 0, host_start: 200000, count: 16}]", + "[{container_start: 2, host_start: 200002, count: 1}, {container_start: 10, host_start: 200010, count: 1}]", 1) + for _, tc := range []struct{ name, mapping string }{ + {"literal", mapping}, + {"required UID host", strings.ReplaceAll(mapping, "host_start: 100010", `host_start: "???"`)}, + {"expression GID host", strings.ReplaceAll(mapping, "host_start: 200010", `host_start: "{{ host }}"`)}, + {"required count", strings.ReplaceAll(mapping, "count: 1", `count: "???"`)}, + {"expression count", strings.ReplaceAll(mapping, "count: 1", `count: "{{ length }}"`)}, + {"bound expression starts", strings.NewReplacer("container_start: 2", `container_start: "{{ small }}"`, "container_start: 10", `container_start: "{{ large }}"`).Replace(mapping)}, + {"mixed known starts", strings.ReplaceAll(mapping, "container_start: 2", `container_start: "{{ small }}"`)}, + {"required start", strings.ReplaceAll(mapping, "container_start: 2", `container_start: "???"`)}, + {"unknown expression starts", strings.NewReplacer("container_start: 2", `container_start: "{{ first }}"`, "container_start: 10", `container_start: "{{ second }}"`).Replace(mapping)}, + {"mixed unknown starts", strings.NewReplacer("container_start: 2", `container_start: "{{ first }}"`, "container_start: 10", `container_start: "???"`).Replace(mapping)}, + {"equal unknown starts", strings.NewReplacer("container_start: 2", `container_start: "???"`, "container_start: 10", `container_start: "???"`).Replace(mapping)}, + } { + t.Run(tc.name, func(t *testing.T) { + checkIdentityMappingWireOrderV1(t, tc.mapping) + }) + } +} + +func checkIdentityMappingWireOrderV1(t *testing.T, mapping string) { + t.Helper() + source, err := Decode([]byte(mappingBlueprintV1(mapping, " vars:\n small: 2\n large: 10\n"))) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + canonical, err := EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + wantDigest, err := ResolvedDocumentDigestV1(canonical) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{"UIDs", "GIDs"} { + copy := *document.Environment.Runtime.IdentityMapping + values := copy.UIDs + if field == "GIDs" { + values = copy.GIDs + } + reversed := []MappingIntervalV1{values[1], values[0]} + before := append([]MappingIntervalV1{}, reversed...) + forwardJSON, _ := json.Marshal(values) + reversedJSON, _ := json.Marshal(reversed) + if field == "UIDs" { + copy.UIDs = reversed + } else { + copy.GIDs = reversed + } + input := document + input.Environment.Runtime.IdentityMapping = © + t.Run(field+"/source", func(t *testing.T) { + raw := source + rawMapping := *source.Environment.Runtime.IdentityMapping + if field == "UIDs" { + rawMapping.UIDs = reversed + } else { + rawMapping.GIDs = reversed + } + raw.Environment.Runtime.IdentityMapping = &rawMapping + resolved, err := Resolve(raw) + if err != nil { + t.Fatal(err) + } + if encoded, err := EncodeResolvedDocumentV1(resolved); err != nil || encoded != canonical { + t.Fatalf("source order changed persisted authority: %v", err) + } + if !reflect.DeepEqual(reversed, before) { + t.Fatal("resolution changed caller-owned intervals") + } + }) + t.Run(field+"/encoder", func(t *testing.T) { + encoded, err := EncodeResolvedDocumentV1(input) + if err != nil || encoded != canonical { + t.Fatalf("direct encoder retained noncanonical literal order: %v", err) + } + if !reflect.DeepEqual(reversed, before) { + t.Fatal("wire encoding changed caller-owned intervals") + } + if digest, err := DocumentDigestV1(input); err != nil || digest != wantDigest { + t.Fatalf("interval order changed document authority: %s %v", digest, err) + } + }) + t.Run(field+"/wire", func(t *testing.T) { + bad := strings.Replace(string(canonical), `"`+field+`":`+string(forwardJSON), `"`+field+`":`+string(reversedJSON), 1) + if bad == string(canonical) { + t.Fatal("wire fixture did not change interval order") + } + if _, err := DecodeResolvedDocumentV1(ResolvedDocumentV1(bad)); err == nil || !strings.Contains(err.Error(), "canonical wire form") { + t.Fatalf("reordered literal wire mapping was accepted: %v", err) + } + }) + } +} + +func TestIdentityMappingV1BoundStartsKeepSyntaxAndNumericOrder(t *testing.T) { + entries := `[{container_start: "{{ large }}", host_start: 100010, count: 1}, {container_start: " {{ small }} ", host_start: 100002, count: 1}]` + mapping := strings.Replace(mappingDeclarationV1, "[{container_start: 0, host_start: 100000, count: 16}]", entries, 1) + source, err := Decode([]byte(mappingBlueprintV1(mapping, " vars:\n small: 2\n large: 10\n"))) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + values := document.Environment.Runtime.IdentityMapping.UIDs + if values[0].ContainerStart != (MappingScalarV1{"expression", " {{ small }} "}) || values[1].ContainerStart != (MappingScalarV1{"expression", "{{ large }}"}) { + t.Fatalf("bound integer starts lost numeric ordering or original expression syntax: %#v", values) + } + if source.Environment.Runtime.IdentityMapping.UIDs[0].ContainerStart.Value != "{{ large }}" || source.Environment.Runtime.IdentityMapping.Variables != nil { + t.Fatal("resolution changed caller-owned syntax or bindings") + } +} + +func TestIdentityMappingV1RejectsDefiniteOverlapWithUnresolvedCount(t *testing.T) { + for _, ids := range []string{"uids", "gids"} { + for _, domain := range []string{"container", "host"} { + for _, count := range []string{`"???"`, `"{{ length }}"`} { + for _, reverse := range []bool{false, true} { + for _, overlap := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/%s/%s/reverse=%v/overlap=%v", ids, domain, count, reverse, overlap), func(t *testing.T) { + first := "{container_start: 0, host_start: 100000, count: 10}" + container, host := 5, 200000 + if !overlap { + container = 10 + } + if domain == "host" { + container, host = 20, 100005 + if !overlap { + host = 100010 + } + } + second := fmt.Sprintf("{container_start: %d, host_start: %d, count: %s}", container, host, count) + if reverse { + first, second = second, first + } + start := "100000" + if ids == "gids" { + start = "200000" + } + original := ids + ": [{container_start: 0, host_start: " + start + ", count: 16}]" + mapping := strings.Replace(mappingDeclarationV1, original, ids+": ["+first+", "+second+"]", 1) + if mapping == mappingDeclarationV1 { + t.Fatal("fixture did not replace mapping") + } + _, err := Decode([]byte(mappingBlueprintV1(mapping, ""))) + if overlap && (err == nil || !strings.Contains(err.Error(), "overlap")) { + t.Fatalf("accepted definite overlap with unresolved count: %v", err) + } + if !overlap && err != nil { + t.Fatalf("rejected adjacent unresolved interval: %v", err) + } + }) + } + } + } + } + } +} + +func TestIdentityMappingV1DuplicateKnownStartsRejectUnresolvedCounts(t *testing.T) { + for _, ids := range []string{"uids", "gids"} { + for _, field := range []string{"container_start", "host_start"} { + for _, count := range []string{`"???"`, `"{{ length }}"`} { + t.Run(ids+"/"+field+"/"+count, func(t *testing.T) { + entries := "[{container_start: 2, host_start: 100002, count: " + count + "}, {container_start: 10, host_start: 100010, count: " + count + "}]" + if field == "container_start" { + entries = strings.Replace(entries, "container_start: 10", "container_start: 2", 1) + } else { + entries = strings.Replace(entries, "host_start: 100010", "host_start: 100002", 1) + } + start := "100000" + if ids == "gids" { + start = "200000" + } + original := ids + ": [{container_start: 0, host_start: " + start + ", count: 16}]" + mapping := strings.Replace(mappingDeclarationV1, original, ids+": "+entries, 1) + if mapping == mappingDeclarationV1 { + t.Fatal("fixture did not replace mapping") + } + if _, err := Decode([]byte(mappingBlueprintV1(mapping, ""))); err == nil || !strings.Contains(err.Error(), "overlap") { + t.Fatalf("accepted known overlapping starts with unresolved counts: %v", err) + } + }) + } + } + } +} + +func TestIdentityMappingV1VariableTypesSurviveCanonicalRoundTrip(t *testing.T) { + for _, tc := range []struct{ name, yaml, kind, value string }{ + {"integer", "100000", "integer", "100000"}, + {"integral float", "1.0", "float", "1"}, + {"fractional float", "1.5", "float", "1.5"}, + {"numeric string", `"100000"`, "string", "100000"}, + {"boolean", "true", "bool", "true"}, + {"null", "null", "null", ""}, + {"required", `"???"`, "missing", "???"}, + {"sequence", "[100000]", "invalid", ""}, + {"object", "{nested: 100000}", "invalid", ""}, + {"negative integer", "-1", "integer", "-1"}, + {"integer beyond mapping IDs", "4294967296", "integer", "4294967296"}, + {"lazy value", `"{{ user.uid }}"`, "expression", "{{ user.uid }}"}, + } { + t.Run(tc.name, func(t *testing.T) { + mapping := strings.Replace(mappingDeclarationV1, "host_start: 100000", `host_start: "{{ selected }}"`, 1) + source, err := Decode([]byte(mappingBlueprintV1(mapping, " vars:\n selected: "+tc.yaml+"\n"))) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + if source.Environment.Runtime.IdentityMapping.Variables != nil { + t.Fatal("Resolve mutated input syntax") + } + payload, err := EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeResolvedDocumentV1(payload) + if err != nil { + t.Fatal(err) + } + want := MappingScalarV1{tc.kind, tc.value} + got := decoded.Environment.Runtime.IdentityMapping.Variables["selected"] + if got != want { + t.Fatalf("type binding = %#v, want %#v", got, want) + } + if decoded.Environment.Runtime.IdentityMapping.UIDs[0].HostStart != (MappingScalarV1{"expression", "{{ selected }}"}) { + t.Fatal("mapping expression evaluated before deployment overrides") + } + reencoded, err := EncodeResolvedDocumentV1(decoded) + if err != nil || reencoded != payload { + t.Fatal("noncanonical round trip", err) + } + }) + } +} + +func TestIdentityMappingV1RetainsAllOriginalVariableTypes(t *testing.T) { + variables := " vars:\n integer: 2\n floating: 1.0\n text: \"2\"\n flag: true\n required: \"???\"\n lazy: \"{{ user.uid }}\"\n absent: null\n object: {id: 2}\n sequence: [2]\n" + source, err := Decode([]byte(mappingBlueprintV1(mappingDeclarationV1, 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) + } + decoded, err := DecodeResolvedDocumentV1(payload) + if err != nil { + t.Fatal(err) + } + want := map[string]MappingScalarV1{ + "integer": {"integer", "2"}, "floating": {"float", "1"}, "text": {"string", "2"}, + "flag": {"bool", "true"}, "required": {"missing", "???"}, "lazy": {"expression", "{{ user.uid }}"}, + "absent": {"null", ""}, "object": {"invalid", ""}, "sequence": {"invalid", ""}, + } + if !reflect.DeepEqual(decoded.Environment.Runtime.IdentityMapping.Variables, want) { + t.Fatalf("unused original variable types were lost: %#v", decoded.Environment.Runtime.IdentityMapping.Variables) + } + if source.Environment.Runtime.IdentityMapping.Variables != nil { + t.Fatal("resolution modified caller-owned type bindings") + } +} + +func TestIdentityMappingV1RejectsInconsistentVariableBindings(t *testing.T) { + mapping := strings.Replace(mappingDeclarationV1, "host_start: 100000", `host_start: "{{ selected }}"`, 1) + source, err := Decode([]byte(mappingBlueprintV1(mapping, " vars:\n selected: 1\n"))) + 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) + } + originalBindings, _ := json.Marshal(document.Environment.Runtime.IdentityMapping.Variables) + originalVariables, _ := json.Marshal(document.Environment.Vars) + for _, tc := range []struct { + name string + mutate func(*Document) + }{ + {"missing binding", func(d *Document) { delete(d.Environment.Runtime.IdentityMapping.Variables, "selected") }}, + {"extra binding", func(d *Document) { + d.Environment.Runtime.IdentityMapping.Variables["foreign"] = MappingScalarV1{"integer", "2"} + }}, + {"binding value", func(d *Document) { + d.Environment.Runtime.IdentityMapping.Variables["selected"] = MappingScalarV1{"integer", "2"} + }}, + {"binding string", func(d *Document) { + d.Environment.Runtime.IdentityMapping.Variables["selected"] = MappingScalarV1{"string", "1"} + }}, + {"binding null", func(d *Document) { + d.Environment.Runtime.IdentityMapping.Variables["selected"] = MappingScalarV1{"null", ""} + }}, + {"binding required", func(d *Document) { + d.Environment.Runtime.IdentityMapping.Variables["selected"] = MappingScalarV1{"missing", "???"} + }}, + {"binding composite", func(d *Document) { + d.Environment.Runtime.IdentityMapping.Variables["selected"] = MappingScalarV1{"invalid", ""} + }}, + {"float spelling", func(d *Document) { + d.Environment.Runtime.IdentityMapping.Variables["selected"] = MappingScalarV1{"float", "1.0"} + }}, + {"environment value", func(d *Document) { d.Environment.Vars["selected"] = 2 }}, + {"environment type", func(d *Document) { d.Environment.Vars["selected"] = true }}, + {"environment missing", func(d *Document) { delete(d.Environment.Vars, "selected") }}, + {"environment extra", func(d *Document) { d.Environment.Vars["unused"] = 2 }}, + } { + t.Run(tc.name, func(t *testing.T) { + input := document + input.Environment.Vars = maps.Clone(document.Environment.Vars) + copy := *document.Environment.Runtime.IdentityMapping + copy.Variables = maps.Clone(copy.Variables) + input.Environment.Runtime.IdentityMapping = © + tc.mutate(&input) + if _, err := EncodeResolvedDocumentV1(input); err == nil || !strings.Contains(err.Error(), "variable") { + t.Fatalf("direct encoder accepted inconsistent variable authority: %v", err) + } + bindings, _ := json.Marshal(copy.Variables) + variables, _ := json.Marshal(input.Environment.Vars) + bad := strings.Replace(string(payload), `"Variables":`+string(originalBindings), `"Variables":`+string(bindings), 1) + bad = strings.Replace(bad, `"Vars":`+string(originalVariables), `"Vars":`+string(variables), 1) + if bad == string(payload) { + t.Fatal("wire fixture did not change bindings or resolved variables") + } + if _, err := DecodeResolvedDocumentV1(ResolvedDocumentV1(bad)); err == nil || !strings.Contains(err.Error(), "variable") { + t.Fatalf("wire decoder accepted inconsistent variable authority: %v", err) + } + }) + } + if unchanged, err := EncodeResolvedDocumentV1(document); err != nil || unchanged != payload { + t.Fatalf("checks mutated original authority: %v", err) + } +} + +func TestIdentityMappingV1NumericSourceKindsKeepDistinctAuthority(t *testing.T) { + var payloads []ResolvedDocumentV1 + var numbers [][]byte + var digests []string + for _, spelling := range []string{"1", "1.0"} { + mapping := strings.Replace(mappingDeclarationV1, "host_start: 100000", `host_start: "{{ selected }}"`, 1) + source, err := Decode([]byte(mappingBlueprintV1(mapping, " vars:\n selected: "+spelling+"\n"))) + 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) + } + decoded, err := DecodeResolvedDocumentV1(payload) + if err != nil { + t.Fatal(err) + } + value, _ := json.Marshal(decoded.Environment.Vars) + digest, err := DocumentDigestV1(decoded) + if err != nil { + t.Fatal(err) + } + payloads = append(payloads, payload) + numbers = append(numbers, value) + digests = append(digests, string(digest)) + } + if !bytes.Equal(numbers[0], numbers[1]) || payloads[0] == payloads[1] || digests[0] == digests[1] { + t.Fatal("JSON-equivalent numbers lost distinct source-type authority") + } +} + +func TestIdentityMappingV1KeepsOrdinaryRuntimeAndRequiredStringMeaning(t *testing.T) { + for _, runtime := range []string{"", " runtime: null\n", " runtime: {user: null, network: null}\n", " runtime: {network: {public: deny, local: deny, ambiguous: require-both}}\n"} { + text := strings.Replace(minimalBlueprint, " id: demo\n", " id: demo\n"+runtime, 1) + text = strings.Replace(text, "argv: [serve]", `argv: ["???"]`, 1) + source, err := Decode([]byte(text)) + if err != nil { + t.Fatal(err) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + if document.Environment.Commands["serve"].Argv[0] != "???" { + t.Fatal("ordinary marker spelling was reserved") + } + payload, err := EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(payload), "IdentityMapping") { + t.Fatal("ordinary wire form gained mapping metadata") + } + if document.Environment.Runtime.User != DefaultRuntimeUser { + t.Fatal("ordinary runtime default changed") + } + } + for _, extra := range []string{" unknown: true\n", " network: {unknown: allow}\n", " network: {public: allow, public: deny}\n"} { + text := strings.Replace(minimalBlueprint, " id: demo\n", " id: demo\n runtime:\n"+extra, 1) + if _, err := Decode([]byte(text)); err == nil { + t.Fatal("lost ordinary strict runtime/network field validation") + } + } +} + +func TestIdentityMappingV1StrictOldRuntimeReaderAndTamperRejection(t *testing.T) { + source, err := Decode([]byte(mappingBlueprintV1(mappingDeclarationV1, ""))) + 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) + } + var envelope struct { + Document struct { + Environment struct{ Runtime json.RawMessage } + } + } + if err := json.Unmarshal([]byte(payload), &envelope); err != nil { + t.Fatal(err) + } + var oldRuntime struct { + User string + Network RuntimeNetwork + } + oldReader := json.NewDecoder(bytes.NewReader(envelope.Document.Environment.Runtime)) + oldReader.DisallowUnknownFields() + if err := oldReader.Decode(&oldRuntime); err == nil || !strings.Contains(err.Error(), "IdentityMapping") { + t.Fatalf("strict old runtime reader silently accepted opt-in: %v", err) + } + for _, tc := range []struct{ old, replacement string }{ + {`"IdentityMapping":{"Schema":1`, `"IdentityMapping":{"Schema":2`}, + {`"kind":"integer"`, `"kind":"forged"`}, + {`"value":"16"`, `"value":"0"`}, + {`"Profile":{"kind":"string","value":"exact"}`, `"Profile":{"kind":"missing","value":"invalid"}`}, + {`"IdentityMapping":{"Schema":1`, `"IdentityMapping":{"Schema":1,"unknown":true`}, + {`"IdentityMapping":{"Schema":1`, `"IdentityMapping":{"Schema":1,"Schema":1`}, + } { + bad := strings.Replace(string(payload), tc.old, tc.replacement, 1) + if bad == string(payload) { + t.Fatal("tamper fixture did not change") + } + if _, err := DecodeResolvedDocumentV1(ResolvedDocumentV1(bad)); err == nil { + t.Fatalf("accepted tampered document: %s", tc.replacement) + } + } + if _, err := DecodeResolvedDocumentV1(ResolvedDocumentV1(strings.Replace(string(payload), string(envelope.Document.Environment.Runtime), `{"User":"reploy","Network":{"Public":"deny","Local":"deny","Ambiguous":"require-both"},"IdentityMapping":null}`, 1))); err == nil { + t.Fatal("accepted noncanonical explicit-null mapping") + } +} diff --git a/internal/blueprint/model.go b/internal/blueprint/model.go index 902290ab..8d79f87a 100644 --- a/internal/blueprint/model.go +++ b/internal/blueprint/model.go @@ -43,8 +43,9 @@ type Environment struct { } type EnvironmentRuntime struct { - User string - Network RuntimeNetwork + User string + Network RuntimeNetwork + IdentityMapping *IdentityMappingV1 `json:"IdentityMapping,omitempty"` } type NetworkAccess string diff --git a/internal/blueprint/resolve.go b/internal/blueprint/resolve.go index 7109017b..5b48f26a 100644 --- a/internal/blueprint/resolve.go +++ b/internal/blueprint/resolve.go @@ -48,6 +48,10 @@ func Resolve(source Syntax) (Document, error) { if err != nil { return Document{}, err } + mapping, err := prepareIdentityMappingV1(source.Environment.Runtime.IdentityMapping, variables) + if err != nil { + return Document{}, err + } extended, err := resolveExtends(source) if err != nil { return Document{}, err @@ -67,7 +71,7 @@ func Resolve(source Syntax) (Document, error) { Applications: map[string]Application{}, Components: map[string]Component{}, AllowConcurrent: allowConcurrent, - Runtime: EnvironmentRuntime{User: runtimeUser, Network: runtimeNetwork}, + Runtime: EnvironmentRuntime{User: runtimeUser, Network: runtimeNetwork, IdentityMapping: mapping}, Terminal: Terminal{ColorEnv: strings.TrimSpace(source.Environment.Terminal.ColorEnv)}, Install: resolveInstallSyntax(source.Environment.Install, variables), Mounts: map[string]EnvironmentMount{}, diff --git a/internal/blueprint/resolved_document_v1.go b/internal/blueprint/resolved_document_v1.go index 4a29b9ff..a383b656 100644 --- a/internal/blueprint/resolved_document_v1.go +++ b/internal/blueprint/resolved_document_v1.go @@ -23,6 +23,13 @@ type resolvedDocumentEnvelopeV1 struct { } func EncodeResolvedDocumentV1(document Document) (ResolvedDocumentV1, error) { + if err := document.Environment.Runtime.IdentityMapping.validate(); err != nil { + return "", err + } + if err := document.Environment.Runtime.IdentityMapping.validateVariables(document.Environment.Vars); err != nil { + return "", err + } + document.Environment.Runtime.IdentityMapping = canonicalIdentityMappingV1(document.Environment.Runtime.IdentityMapping) payload, err := json.Marshal(resolvedDocumentEnvelopeV1{Schema: ResolvedDocumentSchemaV1, Document: document}) if err != nil { return "", fmt.Errorf("encode resolved blueprint: %w", err) diff --git a/internal/blueprint/syntax.go b/internal/blueprint/syntax.go index 1e55d960..bac07b46 100644 --- a/internal/blueprint/syntax.go +++ b/internal/blueprint/syntax.go @@ -45,8 +45,9 @@ type EnvironmentSyntax struct { } type EnvironmentRuntimeSyntax struct { - User string `yaml:"user"` - Network RuntimeNetworkSyntax `yaml:"network"` + User string `yaml:"user"` + Network RuntimeNetworkSyntax `yaml:"network"` + IdentityMapping *IdentityMappingV1 `yaml:"identity_mapping,omitempty"` } type RuntimeNetworkSyntax struct { diff --git a/internal/blueprint/syntax_variables.go b/internal/blueprint/syntax_variables.go index d9d113b0..fd4adad3 100644 --- a/internal/blueprint/syntax_variables.go +++ b/internal/blueprint/syntax_variables.go @@ -12,12 +12,15 @@ import ( func resolveSyntaxVariables(source Syntax, variables map[string]any) (Syntax, error) { source = cloneSyntaxValue(reflect.ValueOf(source)).Interface().(Syntax) saved := source.Environment.Vars + mapping := source.Environment.Runtime.IdentityMapping source.Environment.Vars = nil + source.Environment.Runtime.IdentityMapping = nil value := reflect.ValueOf(&source).Elem() if err := resolveSyntaxVariableValue(value, variables, "blueprint"); err != nil { return Syntax{}, err } source.Environment.Vars = saved + source.Environment.Runtime.IdentityMapping = mapping return source, nil } diff --git a/internal/dockerdeploy/config.go b/internal/dockerdeploy/config.go index f06c9d13..f00a36d5 100644 --- a/internal/dockerdeploy/config.go +++ b/internal/dockerdeploy/config.go @@ -150,7 +150,7 @@ func validateCurrentAppCommandRequestV1(dir string, arguments []string, deployed if err != nil { return err } - document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + document, err := unmappedRuntimeDocumentV1(state) if err != nil { return fmt.Errorf("app command blueprint: %w", err) } diff --git a/internal/dockerdeploy/control_admission.go b/internal/dockerdeploy/control_admission.go index 316081cb..85b1ae84 100644 --- a/internal/dockerdeploy/control_admission.go +++ b/internal/dockerdeploy/control_admission.go @@ -87,6 +87,9 @@ func awaitControlAdmissionV1( if backend.acquire == nil || backend.wait == nil { return releaseControlAdmissionLockV1(operation, fmt.Errorf("control admission requires a complete backend")) } + if err := requireUnmappedControlDeploymentV1(operation, candidate.Operation); err != nil { + return releaseControlAdmissionLockV1(operation, err) + } if err := operation.RequireQueueEntryLeaseHeldV1(candidate.ID); err != nil { return releaseControlAdmissionLockV1(operation, fmt.Errorf("control admission ownership: %w", err)) } @@ -122,6 +125,9 @@ func awaitControlAdmissionV1( if err != nil { return cancelControlAdmissionV1(ctx, absoluteDir, nil, candidate, backend, err) } + if err := requireUnmappedControlDeploymentV1(operation, candidate.Operation); err != nil { + return cancelControlAdmissionV1(ctx, absoluteDir, operation, candidate, backend, err) + } if _, err := recoverLiveRunQueueV1(ctx, operation, notice, backend.removeContainer); err != nil { return cancelControlAdmissionV1(ctx, absoluteDir, operation, candidate, backend, err) } diff --git a/internal/dockerdeploy/control_admission_modes.go b/internal/dockerdeploy/control_admission_modes.go index 2461eed5..c3ddb40e 100644 --- a/internal/dockerdeploy/control_admission_modes.go +++ b/internal/dockerdeploy/control_admission_modes.go @@ -91,6 +91,10 @@ func admitControlOperationV1( _, err := releaseControlAdmissionLockV1(operation, fmt.Errorf("admit control operation requires a complete backend")) return result, err } + if err := requireUnmappedControlDeploymentV1(operation, input.Operation); err != nil { + _, releaseErr := releaseControlAdmissionLockV1(operation, err) + return result, releaseErr + } id, err := backend.newID() if err != nil { _, releaseErr := releaseControlAdmissionLockV1(operation, err) diff --git a/internal/dockerdeploy/current_app_command_run.go b/internal/dockerdeploy/current_app_command_run.go index b24c697f..1ceb1631 100644 --- a/internal/dockerdeploy/current_app_command_run.go +++ b/internal/dockerdeploy/current_app_command_run.go @@ -118,7 +118,7 @@ func runCurrentAppCommandV1(ctx context.Context, input CurrentAppCommandRunInput if input.DeployedOnly && state.Deployment == nil { return fmt.Errorf("deployed-only app commands require an installed deployment") } - document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + document, err := unmappedRuntimeDocumentV1(state) if err != nil { return fmt.Errorf("runtime blueprint: %w", err) } diff --git a/internal/dockerdeploy/current_controlled_session_run.go b/internal/dockerdeploy/current_controlled_session_run.go index 80341022..c3337008 100644 --- a/internal/dockerdeploy/current_controlled_session_run.go +++ b/internal/dockerdeploy/current_controlled_session_run.go @@ -426,7 +426,7 @@ func loadCurrentControlledSessionRuntimeV1( if !found { return currentControlledSessionRuntimeV1{}, fmt.Errorf("runtime state is missing; run `reploy stage` or `reploy install`") } - document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + document, err := unmappedRuntimeDocumentV1(state) if err != nil { return currentControlledSessionRuntimeV1{}, fmt.Errorf("runtime blueprint: %w", err) } diff --git a/internal/dockerdeploy/current_runtime_test_v1.go b/internal/dockerdeploy/current_runtime_test_v1.go index b4457118..5050ec71 100644 --- a/internal/dockerdeploy/current_runtime_test_v1.go +++ b/internal/dockerdeploy/current_runtime_test_v1.go @@ -8,7 +8,6 @@ import ( "sort" "time" - "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" "github.com/omry/reploy/internal/providerstore" ) @@ -89,7 +88,7 @@ func runCurrentRuntimeTestV1(ctx context.Context, input CurrentRuntimeTestInputV if !found { return fmt.Errorf("runtime state is missing; run `reploy stage` or `reploy install`") } - document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + document, err := unmappedRuntimeDocumentV1(state) if err != nil { return fmt.Errorf("runtime blueprint: %w", err) } diff --git a/internal/dockerdeploy/current_shell_run.go b/internal/dockerdeploy/current_shell_run.go index 261650fa..c74ad19e 100644 --- a/internal/dockerdeploy/current_shell_run.go +++ b/internal/dockerdeploy/current_shell_run.go @@ -103,7 +103,7 @@ func runCurrentShellV1(ctx context.Context, input CurrentShellRunInputV1, backen if !found { return fmt.Errorf("runtime state is missing; run `reploy stage` or `reploy install`") } - document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + document, err := unmappedRuntimeDocumentV1(state) if err != nil { return fmt.Errorf("runtime blueprint: %w", err) } diff --git a/internal/dockerdeploy/current_workload_run.go b/internal/dockerdeploy/current_workload_run.go index 0615cf4b..e0dd20dc 100644 --- a/internal/dockerdeploy/current_workload_run.go +++ b/internal/dockerdeploy/current_workload_run.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" "github.com/omry/reploy/internal/providerstore" ) @@ -127,6 +126,9 @@ func runCurrentWorkloadV1(ctx context.Context, input CurrentWorkloadRunInputV1, if !found { return fmt.Errorf("runtime state is missing; run `reploy stage` or `reploy install`") } + if _, err := unmappedRuntimeDocumentV1(state); err != nil { + return err + } recoverStop := func(cause error) error { if input.Action != "down" { return cause @@ -179,7 +181,7 @@ func runCurrentWorkloadV1(ctx context.Context, input CurrentWorkloadRunInputV1, if state.Current == nil { return recoverStop(fmt.Errorf("%s", currentBuildRecoveryMessageV1(state, "runtime build is missing"))) } - document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + document, err := unmappedRuntimeDocumentV1(state) if err != nil { return recoverStop(fmt.Errorf("runtime blueprint: %w", err)) } diff --git a/internal/dockerdeploy/identity_mapping_admission_test.go b/internal/dockerdeploy/identity_mapping_admission_test.go new file mode 100644 index 00000000..4b29d7f2 --- /dev/null +++ b/internal/dockerdeploy/identity_mapping_admission_test.go @@ -0,0 +1,373 @@ +package dockerdeploy + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/omry/reploy/internal/controlledsession" + "github.com/omry/reploy/internal/deploy" +) + +func identityGuardSystemServiceFixtureV1(t *testing.T, declared, missing bool) string { + t.Helper() + dir, _ := stageProviderBuildRunState(t, false) + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + state, found, err := operation.ReadStateV1() + if err != nil || !found { + _ = operation.Unlock() + t.Fatalf("missing fixture state: found=%t err=%v", found, err) + } + state = identityGuardDocumentV1(t, state, declared, missing) + state.Deployment = &deploy.DeploymentStateV1{ + Schema: deploy.DeploymentStateSchemaV1, + Installation: installedBuildPublicationInstallation(dir), + } + if err := operation.CommitStateV1(nil, state); err != nil { + _ = operation.Unlock() + t.Fatal(err) + } + if err := operation.Unlock(); err != nil { + t.Fatal(err) + } + return dir +} + +func TestIdentityMappingGuardV1InstalledServiceContainerRejectsCompleteAndUnresolvedWithoutEffects(t *testing.T) { + for _, missing := range []bool{false, true} { + missing := missing + t.Run(fmt.Sprintf("missing=%t", missing), func(t *testing.T) { + dir := identityGuardSystemServiceFixtureV1(t, true, missing) + before := identityGuardTreeV1(t, dir) + called := false + previous := bindInstalledServiceCommandRunner + bindInstalledServiceCommandRunner = func(context.Context, CommandSpec, time.Duration) (commandRunner, error) { + called = true + return nil, fmt.Errorf("installed service Docker runner reached") + } + defer func() { bindInstalledServiceCommandRunner = previous }() + + for _, entry := range []string{"start", "restart"} { + t.Run(entry, func(t *testing.T) { + err := RunInstalledServiceContainerV1(t.Context(), dir, "run", "/usr/bin/docker", RunOptions{}) + requireIdentityGuardErrorV1(t, err) + if called { + t.Fatal("mapped system-service entry reached the Docker runner") + } + if after := identityGuardTreeV1(t, dir); !reflect.DeepEqual(before, after) { + t.Fatalf("mapped system-service entry changed deployment/runtime files: before=%v after=%v", before, after) + } + }) + } + }) + } +} + +func TestIdentityMappingGuardV1InstalledServiceContainerAbsentDeclarationUsesOrdinaryPath(t *testing.T) { + dir := identityGuardSystemServiceFixtureV1(t, false, false) + before := identityGuardTreeV1(t, dir) + called := false + previous := bindInstalledServiceCommandRunner + bindInstalledServiceCommandRunner = func(context.Context, CommandSpec, time.Duration) (commandRunner, error) { + called = true + return nil, fmt.Errorf("installed service Docker runner reached") + } + defer func() { bindInstalledServiceCommandRunner = previous }() + + err := RunInstalledServiceContainerV1(t.Context(), dir, "run", filepath.Join(t.TempDir(), "docker"), RunOptions{}) + if err == nil || !strings.Contains(err.Error(), "installed service build is missing") { + t.Fatalf("ordinary system-service path error = %v", err) + } + if strings.Contains(err.Error(), "environment.runtime.identity_mapping") { + t.Fatalf("ordinary system-service path was treated as mapped: %v", err) + } + if called { + t.Fatal("ordinary system-service path reached the Docker runner before its missing-build error") + } + if after := identityGuardTreeV1(t, dir); !reflect.DeepEqual(before, after) { + t.Fatalf("ordinary system-service path changed deployment/runtime files: before=%v after=%v", before, after) + } +} + +func TestIdentityMappingGuardV1BothSessionRolesStopBeforePreparation(t *testing.T) { + for _, role := range []string{"controller", "workload"} { + for _, missing := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/missing=%t", role, missing), func(t *testing.T) { + mapped, _ := identityGuardFixtureV1(t, missing, false) + ordinary, _ := stageProviderBuildRunState(t, false) + controller, workload := mapped, ordinary + if role == "workload" { + controller, workload = ordinary, mapped + } + before := identityGuardTreeV1(t, mapped) + fixture, _ := controlledSessionPlanFixtureV1(t) + _, err := runCurrentControlledSessionV1(t.Context(), CurrentControlledSessionRunInputV1{ + ControllerDeploymentDir: controller, WorkloadDeploymentDir: workload, + OutputDir: filepath.Join(t.TempDir(), "not-created"), + }, currentControlledSessionRunBackendV1{ + acquire: deploy.AcquireOperationLock, + loadRuntime: func(ctx context.Context, operation *deploy.OperationLock, dir string, runtime StagedProviderBuildRuntimeV1) (currentControlledSessionRuntimeV1, error) { + if dir == mapped { + return loadCurrentControlledSessionRuntimeV1(ctx, operation, dir, runtime) + } + return currentControlledSessionRuntimeV1{current: fixture.ControllerCurrent, plan: fixture.ControllerRuntime}, nil + }, + privateEnv: func(string) (privateWorkloadEnvironmentV1, error) { + t.Fatal("session reached private environment preparation") + return privateWorkloadEnvironmentV1{}, nil + }, + prepareController: prepareControlledSessionControllerPackageV1, + cleanupController: cleanupControlledSessionControllerPackageV1, + prepareOutput: prepareOneShotOutput, + abortOutput: func(output *oneShotOutputSession) error { return output.abort() }, + publishOutput: func(output *oneShotOutputSession) error { return output.publish() }, + concurrency: PlanLiveRunConcurrencyV1, + newRunID: deploy.NewLiveRunIDV1, newHandle: controlledsession.NewHandleV1, + acquireLease: func(operation *deploy.OperationLock, id string) (*deploy.QueueEntryLeaseV1, error) { + return operation.AcquireLiveRunLeaseV1(id) + }, + await: AwaitLiveRunAdmissionWithNoticeV1, plan: PlanControlledSessionV1, + run: runControlledSessionWithControllerReservationV1, + }) + requireIdentityGuardErrorV1(t, err) + if !strings.Contains(err.Error(), role+" runtime") { + t.Fatalf("wrong session role rejected: %v", err) + } + if !reflect.DeepEqual(before, identityGuardTreeV1(t, mapped)) { + t.Fatal("session changed mapped deployment") + } + }) + } + } +} + +func TestIdentityMappingGuardV1DirectInstallStagesPortableIntentThenRejects(t *testing.T) { + for _, missing := range []bool{false, true} { + ref, path := writeDesiredStateStagePack(t, "0.1.0") + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + start := "100000" + if missing { + start = `"???"` + } + mapping := fmt.Sprintf("\n identity_mapping: {schema: 1, profile: exact, uids: [{container_start: 0, host_start: %s, count: 16}], gids: [{container_start: 0, host_start: 200000, count: 16}]}", start) + text := strings.ReplaceAll(string(content), "\r\n", "\n") + updated := strings.Replace(text, "environment:\n", "environment:\n runtime:"+mapping+"\n", 1) + if updated == text { + t.Fatal("fixture did not insert the mapping declaration") + } + content = []byte(updated) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + workspace := "" + _, err = runDirectProviderInstallEntryV1(t.Context(), DirectProviderInstallInputV1{ + Pack: ref, ExplicitPlatform: "linux/amd64", Scope: InstallScopeUser, Runtime: blueprintRuntimeFixtureV1(), + }, directProviderInstallEntryBackendV1{ + effectiveUID: func() int { return 1000 }, + withSource: func(ctx context.Context, input directProviderInstallSourceInputV1, run func(context.Context, string) error) error { + return withDirectProviderInstallSourceV1(ctx, input, func(ctx context.Context, dir string) error { + workspace = filepath.Dir(dir) + state, err := readProviderInstallSourceStateV1(ctx, dir) + if err != nil { + return err + } + _, guardErr := unmappedRuntimeDocumentV1(state) + requireIdentityGuardErrorV1(t, guardErr) + return run(ctx, dir) + }) + }, + readState: readProviderInstallSourceStateV1, + roots: func(string) (installTargetRootsV1, error) { + t.Fatal("direct install resolved destination roots") + return installTargetRootsV1{}, nil + }, + install: func(context.Context, ProviderInstallInputV1) (deploy.StateV1, error) { + t.Fatal("direct install prepared providers or destination") + return deploy.StateV1{}, nil + }, + }) + requireIdentityGuardErrorV1(t, err) + if workspace == "" { + t.Fatal("portable staging was not exercised") + } + if _, err := os.Stat(workspace); !os.IsNotExist(err) { + t.Fatal("private staging survived", err) + } + } +} + +func TestIdentityMappingGuardV1RechecksAfterAdmissionWait(t *testing.T) { + for _, control := range []bool{false, true} { + for _, missing := range []bool{false, true} { + t.Run(fmt.Sprintf("control=%t/missing=%t", control, missing), func(t *testing.T) { + dir, _ := stageProviderBuildRunState(t, false) + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + first := liveRunAdmissionFixtureV1("run-0000000000000001", true) + holdLiveRunLeaseV1(t, operation, first.ID) + if _, err := operation.AdmitLiveRunV1(first, false); err != nil { + t.Fatal(err) + } + waits := 0 + wait := func(ctx context.Context) error { + waits++ + if waits > 1 { + t.Fatal("mapped candidate waited again") + } + writer, err := deploy.AcquireOperationLock(ctx, dir) + if err != nil { + return err + } + defer writer.Unlock() + state, _, err := writer.ReadStateV1() + if err != nil { + return err + } + state = identityGuardDocumentV1(t, state, true, missing) + return writer.CommitStateV1(nil, state) + } + remove := func(CommandSpec, RunOptions) error { + t.Fatal("mapping guard performed container cleanup") + return nil + } + var returned *deploy.OperationLock + if control { + marker := controlAdmissionFixtureV1("control-0000000000000001", deploy.ControlOperationInstallV1) + holdControlLeaseV1(t, operation, marker.ID) + returned, err = awaitControlAdmissionV1(t.Context(), dir, operation, marker, true, io.Discard, controlAdmissionBackendV1{acquire: deploy.AcquireOperationLock, wait: wait, removeContainer: remove}) + } else { + candidate := liveRunAdmissionFixtureV1("run-0000000000000002", true) + holdLiveRunLeaseV1(t, operation, candidate.ID) + returned, err = awaitLiveRunAdmissionV1(t.Context(), dir, operation, candidate, true, io.Discard, liveRunAdmissionBackendV1{acquire: deploy.AcquireOperationLock, wait: wait, removeContainer: remove}) + } + requireIdentityGuardErrorV1(t, err) + if returned != nil || waits != 1 { + t.Fatal("returned admission after mapping change") + } + inspection, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer inspection.Unlock() + queue, _, err := inspection.ReadLiveRunQueueV1() + if err != nil || len(queue.Runs) != 1 || queue.Runs[0].ID != first.ID || queue.Runs[0].Status != deploy.LiveRunStatusActiveV1 { + t.Fatalf("guard changed an existing run or retained its own candidate: %#v %v", queue, err) + } + requireIdentityGuardErrorV1(t, requireUnmappedDeploymentV1(inspection)) + }) + } + } +} + +func TestIdentityMappingGuardV1AllControlModesRejectBeforeQueueOrCleanup(t *testing.T) { + for _, mode := range []ControlAdmissionModeV1{ControlAdmissionImmediateV1, ControlAdmissionWaitV1, ControlAdmissionDrainV1, ControlAdmissionForceV1} { + t.Run(string(mode), func(t *testing.T) { + dir, _ := identityGuardFixtureV1(t, true, false) + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + first := liveRunAdmissionFixtureV1("run-0000000000000001", true) + holdLiveRunLeaseV1(t, operation, first.ID) + if _, err := operation.AdmitLiveRunV1(first, false); err != nil { + t.Fatal(err) + } + before := identityGuardTreeV1(t, dir) + _, err = admitControlOperationV1(t.Context(), dir, operation, ControlAdmissionInputV1{Mode: mode, Operation: deploy.ControlOperationInstallV1}, controlOperationAdmissionBackendV1{ + newID: func() (string, error) { t.Fatal("mapping guard generated control marker"); return "", nil }, + pause: func(context.Context, time.Duration) error { t.Fatal("mapping guard started disruption"); return nil }, + await: func(context.Context, string, *deploy.OperationLock, deploy.ControlMarkerV1, bool, io.Writer) (*deploy.OperationLock, error) { + t.Fatal("mapping guard reached admission") + return nil, nil + }, + removeContainer: func(CommandSpec, RunOptions) error { t.Fatal("mapping guard removed container"); return nil }, + }) + requireIdentityGuardErrorV1(t, err) + if !reflect.DeepEqual(before, identityGuardTreeV1(t, dir)) { + t.Fatal("mapping guard changed active control state") + } + }) + } +} + +func TestIdentityMappingGuardV1PublicStagingRemovalRemainsAvailable(t *testing.T) { + for _, missing := range []bool{false, true} { + for _, mode := range []ControlAdmissionModeV1{ControlAdmissionImmediateV1, ControlAdmissionWaitV1, ControlAdmissionDrainV1, ControlAdmissionForceV1} { + t.Run(fmt.Sprintf("missing=%t/%s", missing, mode), func(t *testing.T) { + dir, _ := identityGuardFixtureV1(t, missing, false) + if _, err := RemoveStagedDeploymentV1(t.Context(), StagedDeploymentRemoveInputV1{DeploymentDir: dir, ControlMode: mode}); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(dir); !os.IsNotExist(err) { + t.Fatalf("mapped staging directory remains: %v", err) + } + }) + } + } +} + +func TestIdentityMappingGuardV1ForceReplacementRetiresOnlyOwnedResources(t *testing.T) { + for _, missing := range []bool{false, true} { + t.Run(fmt.Sprintf("missing=%t", missing), func(t *testing.T) { + // Existing fixture uses real state, retirement inventory and admission. + // Only Docker aliases and the existing workload stop are substituted. + dir, images, references, backend, input := forceRetirementFixtureV1(t) + unrelated := images.images[references[0]] + images.images["unrelated/keep:fixture"] = unrelated + operation, err := deploy.AcquireExistingOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer operation.Unlock() + state, _, err := operation.ReadStateV1() + if err != nil { + t.Fatal(err) + } + state = identityGuardDocumentV1(t, state, true, missing) + if err := operation.CommitStateV1(state.Current, state); err != nil { + t.Fatal(err) + } + if err := operation.Unlock(); err != nil { + t.Fatal(err) + } + result, err := forceReplaceStagedDesiredStateV1(t.Context(), input, backend) + if err != nil || !result.Changed { + t.Fatalf("mapped staging replacement: changed=%t err=%v", result.Changed, err) + } + if !reflect.DeepEqual(images.images["unrelated/keep:fixture"], unrelated) { + t.Fatal("mapped staging replacement changed an unrelated image alias") + } + inspection, err := deploy.AcquireExistingOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer inspection.Unlock() + state, _, err = inspection.ReadStateV1() + if err != nil { + t.Fatal(err) + } + document, err := unmappedRuntimeDocumentV1(state) + if err != nil || document.Environment.ID != "replacement" || state.Current != nil { + t.Fatalf("replacement did not retain fresh portable intent: %#v %v", state, err) + } + queue, _, err := inspection.ReadLiveRunQueueV1() + if err != nil || len(queue.Runs) != 0 { + t.Fatalf("replacement retained control admission: %#v %v", queue, err) + } + }) + } +} diff --git a/internal/dockerdeploy/identity_mapping_guard.go b/internal/dockerdeploy/identity_mapping_guard.go new file mode 100644 index 00000000..cdde22a0 --- /dev/null +++ b/internal/dockerdeploy/identity_mapping_guard.go @@ -0,0 +1,41 @@ +package dockerdeploy + +import ( + "fmt" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" +) + +// A portable declaration is intent, including when its leaves are unresolved. +// Later reviewed runtime admission must replace this guard as one complete cut. +func unmappedRuntimeDocumentV1(state deploy.StateV1) (blueprint.Document, error) { + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + return blueprint.Document{}, err + } + if document.Environment.Runtime.IdentityMapping != nil { + return blueprint.Document{}, fmt.Errorf("environment %q: environment.runtime.identity_mapping requires multi-identity runtime admission, which is not yet available; stage and explicit build remain available", document.Environment.ID) + } + return document, nil +} + +// Queue fixtures and exact-owned cleanup can have no deployment state. Actual +// payload entry points independently require it before reaching admission. +func requireUnmappedDeploymentV1(operation *deploy.OperationLock) error { + state, found, err := operation.ReadStateV1() + if err != nil || !found { + return err + } + _, err = unmappedRuntimeDocumentV1(state) + return err +} + +// Staging cleanup owns no mapping reservations and admits no payload. +// B4 must check retained identity authority before this declaration exception. +func requireUnmappedControlDeploymentV1(operation *deploy.OperationLock, kind deploy.ControlOperationV1) error { + if kind == deploy.ControlOperationStageV1 { + return nil + } + return requireUnmappedDeploymentV1(operation) +} diff --git a/internal/dockerdeploy/identity_mapping_guard_test.go b/internal/dockerdeploy/identity_mapping_guard_test.go new file mode 100644 index 00000000..edf62ed3 --- /dev/null +++ b/internal/dockerdeploy/identity_mapping_guard_test.go @@ -0,0 +1,326 @@ +package dockerdeploy + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/fsowner" + "github.com/omry/reploy/internal/providerstore" +) + +func identityGuardDocumentV1(t *testing.T, state deploy.StateV1, declared, missing bool) deploy.StateV1 { + t.Helper() + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + document.Environment.Runtime.IdentityMapping = nil + if declared { + start := "100000" + if missing { + start = `"???"` + } + source, err := blueprint.Decode([]byte(fmt.Sprintf("blueprint: {schema: 1}\nenvironment:\n runtime:\n identity_mapping: {schema: 1, profile: exact, uids: [{container_start: 0, host_start: %s, count: 16}], gids: [{container_start: 0, host_start: 200000, count: 16}]}\n", start))) + if err != nil { + t.Fatal(err) + } + document.Environment.Runtime.IdentityMapping = source.Environment.Runtime.IdentityMapping + } + document.Environment.Commands = map[string]blueprint.Command{ + "config": {Executable: "application.main", Trigger: []string{"config"}, NativeCommand: true, DeployedCommand: true}, + } + state.Blueprint, err = blueprint.EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + return state +} + +func identityGuardFixtureV1(t *testing.T, missing, installed bool) (string, deploy.StateV1) { + t.Helper() + dir, _ := stageProviderBuildRunState(t, false) + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + state, found, err := operation.ReadStateV1() + if err != nil || !found { + t.Fatal("missing fixture state", err) + } + state = identityGuardDocumentV1(t, state, true, missing) + state.Staging = &deploy.StagingStateV1{Schema: deploy.StagingStateSchemaV1} + state.BlueprintSource = "blueprint.yaml" + if installed { + installation := installedBuildPublicationInstallation(dir) + installation.Scope = "user" + state.Staging = nil + state.Deployment = &deploy.DeploymentStateV1{Schema: deploy.DeploymentStateSchemaV1, Installation: installation} + } + if err := operation.CommitStateV1(nil, state); err != nil { + t.Fatal(err) + } + if err := operation.Unlock(); err != nil { + t.Fatal(err) + } + return dir, state +} + +func identityGuardTreeV1(t *testing.T, root string) map[string]string { + t.Helper() + result := map[string]string{} + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + if info.IsDir() { + return nil + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + if filepath.Dir(relative) == ".reploy" && (entry.Name() == "operation.lock" || strings.HasSuffix(entry.Name(), ".lease")) { + // Windows denies reads through a held byte-range lock. These files + // carry no data; retain their metadata and reject any content. + if info.Size() != 0 { + return fmt.Errorf("lock fixture contains unexpected data: %s", relative) + } + uid, gid := fsowner.FromInfo(info).IDs() + result[strings.TrimPrefix(path, root)] = fmt.Sprintf("%o:%d:%d:%d:empty-lock", info.Mode(), uid, gid, info.ModTime().UnixNano()) + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + uid, gid := fsowner.FromInfo(info).IDs() + result[strings.TrimPrefix(path, root)] = fmt.Sprintf("%o:%d:%d:%d:%x", info.Mode(), uid, gid, info.ModTime().UnixNano(), sha256.Sum256(content)) + return nil + }) + if err != nil { + t.Fatal(err) + } + return result +} + +func requireIdentityGuardErrorV1(t *testing.T, err error) { + t.Helper() + if err == nil || !strings.Contains(err.Error(), "environment.runtime.identity_mapping") || !strings.Contains(err.Error(), "not yet available") { + t.Fatalf("expected explicit mapping admission inhibition, got %v", err) + } +} + +func TestIdentityMappingGuardV1RealPayloadEntriesPreserveMetadata(t *testing.T) { + for _, missing := range []bool{false, true} { + for _, installed := range []bool{false, true} { + for _, entry := range []string{"shell", "shell wait", "app native", "app deployed", "runtime test", "up", "restart", "down", "public shell", "public app", "public up", "public restart"} { + t.Run(fmt.Sprintf("missing=%t/installed=%t/%s", missing, installed, entry), func(t *testing.T) { + if entry == "app deployed" && !installed { + return + } + dir, _ := identityGuardFixtureV1(t, missing, installed) + before := identityGuardTreeV1(t, dir) + var err error + switch entry { + case "shell", "shell wait": + err = RunCurrentShellV1(t.Context(), CurrentShellRunInputV1{DeploymentDir: dir, Wait: entry == "shell wait"}) + case "app native", "app deployed": + err = RunCurrentAppCommandV1(t.Context(), CurrentAppCommandRunInputV1{DeploymentDir: dir, Arguments: []string{"config", "init"}, DeployedOnly: entry == "app deployed"}) + case "runtime test": + err = RunCurrentRuntimeTestV1(t.Context(), CurrentRuntimeTestInputV1{DeploymentDir: dir}) + case "up", "restart", "down": + err = RunCurrentWorkloadV1(t.Context(), CurrentWorkloadRunInputV1{DeploymentDir: dir, Action: entry, ControlMode: ControlAdmissionForceV1}) + case "public shell": + err = Shell(ShellOptions{Dir: dir, Stdout: io.Discard}) + case "public app": + err = AppCommand(AppCommandOptions{Dir: dir, CommandArgs: []string{"config", "init"}, Stdout: io.Discard}) + case "public up", "public restart": + err = Runtime(RuntimeOptions{Dir: dir, Action: strings.TrimPrefix(entry, "public "), Stdout: io.Discard, Stderr: io.Discard}) + } + requireIdentityGuardErrorV1(t, err) + if after := identityGuardTreeV1(t, dir); !reflect.DeepEqual(before, after) { + t.Fatalf("entry changed deployment metadata: before=%v after=%v", before, after) + } + }) + } + } + } +} + +func TestIdentityMappingGuardV1EntryCallbacksStopBeforeRuntimeWork(t *testing.T) { + for _, missing := range []bool{false, true} { + for _, entry := range []string{"shell", "app", "test", "workload"} { + t.Run(fmt.Sprintf("%s/missing=%t", entry, missing), func(t *testing.T) { + dir := t.TempDir() + current, _ := runtimeCurrentBuildFixture(t) + current.State = identityGuardDocumentV1(t, current.State, true, missing) + order := []string{} + var err error + switch entry { + case "shell": + err = runCurrentShellV1(t.Context(), CurrentShellRunInputV1{DeploymentDir: dir}, currentShellRunTestBackend(t, dir, current, CurrentRuntimePlanV1{}, &order)) + case "app": + err = runCurrentAppCommandV1(t.Context(), CurrentAppCommandRunInputV1{DeploymentDir: dir, Arguments: []string{"config"}}, currentAppCommandRunTestBackend(t, dir, current, CurrentRuntimePlanV1{}, &order)) + case "test": + err = runCurrentRuntimeTestV1(t.Context(), CurrentRuntimeTestInputV1{DeploymentDir: dir}, currentRuntimeTestBackend(t, dir, current, CurrentRuntimePlanV1{}, &order)) + case "workload": + err = runCurrentWorkloadV1(t.Context(), CurrentWorkloadRunInputV1{DeploymentDir: dir, Action: "up"}, currentWorkloadRunTestBackend(t, dir, current, CurrentRuntimePlanV1{}, &order)) + } + requireIdentityGuardErrorV1(t, err) + if !reflect.DeepEqual(order, []string{"acquire", "store", "state"}) { + t.Fatalf("mapped entry reached runtime or admission callback: %v", order) + } + }) + } + } +} + +func TestIdentityMappingGuardV1AutomaticBuildStopsBeforeRecoveryAndExplicitBuildContinues(t *testing.T) { + for _, missing := range []bool{false, true} { + for _, automatic := range []bool{false, true} { + t.Run(fmt.Sprintf("missing=%t/automatic=%t", missing, automatic), func(t *testing.T) { + dir, _ := identityGuardFixtureV1(t, missing, false) + before := identityGuardTreeV1(t, dir) + recovered := false + reached := fmt.Errorf("portable build reached preparation") + _, err := runProviderBuildV1(t.Context(), ProviderBuildRunInputV1{DeploymentDir: dir, Automatic: automatic, Runtime: blueprintRuntimeFixtureV1()}, providerBuildRunBackend{ + acquire: deploy.AcquireOperationLock, newStore: providerstore.NewStore, + recoverValidated: func(context.Context, *deploy.OperationLock, providerstore.Store, string, string) (bool, error) { + recovered = true + return false, reached + }, + prepare: func(context.Context, LockedProviderBuildPreparationInputV1) (LockedProviderBuildPreparationV1, error) { + t.Fatal("unexpected provider preparation") + return LockedProviderBuildPreparationV1{}, nil + }, + execute: func(context.Context, LockedProviderBuildExecutionInputV1) (LockedProviderBuildExecutionResultV1, error) { + t.Fatal("unexpected provider execution") + return LockedProviderBuildExecutionResultV1{}, nil + }, + }) + if automatic { + requireIdentityGuardErrorV1(t, err) + if recovered { + t.Fatal("automatic mapped build recovered provider state") + } + } else if err == nil || !strings.Contains(err.Error(), reached.Error()) || !recovered { + t.Fatalf("explicit portable build was inhibited: %v", err) + } + if !reflect.DeepEqual(before, identityGuardTreeV1(t, dir)) { + t.Fatal("build guard changed source state") + } + }) + } + } +} + +func TestIdentityMappingGuardV1StagedInstallRejectsBeforeBuildAndDestinationCreation(t *testing.T) { + for _, missing := range []bool{false, true} { + source, _ := identityGuardFixtureV1(t, missing, false) + target := filepath.Join(t.TempDir(), "must-not-exist") + before := identityGuardTreeV1(t, source) + _, err := RunProviderInstallV1(t.Context(), ProviderInstallInputV1{SourceDeploymentDir: source, DestinationDeploymentDir: target, Scope: InstallScopeUser, Runtime: blueprintRuntimeFixtureV1(), Start: true}) + requireIdentityGuardErrorV1(t, err) + if !reflect.DeepEqual(before, identityGuardTreeV1(t, source)) { + t.Fatal("mapped install changed its source") + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatal("mapped install prepared destination", err) + } + } +} + +func TestIdentityMappingGuardV1DestinationPreflightAndLockedCheckReject(t *testing.T) { + for _, missing := range []bool{false, true} { + dir, _ := identityGuardFixtureV1(t, missing, true) + before := identityGuardTreeV1(t, dir) + requireIdentityGuardErrorV1(t, preflightProviderInstallDestinationV1(dir, "")) + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + requireIdentityGuardErrorV1(t, validateProviderInstallDestinationServiceV1(operation, "")) + if err := operation.Unlock(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, identityGuardTreeV1(t, dir)) { + t.Fatal("destination inspection changed metadata") + } + } +} + +func TestIdentityMappingGuardV1ExplicitBuildPlansAndExecutesPortableInputs(t *testing.T) { + for _, missing := range []bool{false, true} { + dir, state := identityGuardFixtureV1(t, missing, false) + before := identityGuardTreeV1(t, dir) + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + wantInputs, err := ValidatedBuildInputs(document, state.Overlay, deploy.EmptyPackageOverridesV1(document.Environment.ID), dir, state.Platform) + if err != nil { + t.Fatal(err) + } + 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 !reflect.DeepEqual(input.ValidatedInputs, wantInputs) { + t.Fatal("mapping changed canonical build authority") + } + 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 + if err := input.Preparation.Operation.RequireHeld(); err != nil { + t.Fatal(err) + } + return LockedProviderBuildExecutionResultV1{Reused: true}, nil + }, + }) + if err != nil || !prepared || !executed { + t.Fatalf("explicit portable build: prepared=%t executed=%t err=%v", prepared, executed, err) + } + if !reflect.DeepEqual(before, identityGuardTreeV1(t, dir)) { + t.Fatal("portable build altered declaration authority") + } + } +} + +func TestIdentityMappingGuardV1InvalidAuthorityFailsClosed(t *testing.T) { + if _, err := unmappedRuntimeDocumentV1(deploy.StateV1{Blueprint: "broken"}); err == nil { + t.Fatal("guard accepted an invalid resolved blueprint") + } + dir := t.TempDir() + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer operation.Unlock() + if err := requireUnmappedDeploymentV1(operation); err != nil { + t.Fatal("queue-only owner requires no state", err) + } + if err := os.WriteFile(filepath.Join(filepath.Dir(operation.Path()), "state.json"), []byte("broken"), 0o600); err != nil { + t.Fatal(err) + } + if err := requireUnmappedDeploymentV1(operation); err == nil { + t.Fatal("admission ignored invalid persisted state") + } +} diff --git a/internal/dockerdeploy/installed_service_container.go b/internal/dockerdeploy/installed_service_container.go index d83e2d26..b494dfe1 100644 --- a/internal/dockerdeploy/installed_service_container.go +++ b/internal/dockerdeploy/installed_service_container.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" "github.com/omry/reploy/internal/providerstore" ) @@ -42,14 +41,14 @@ func RunInstalledServiceContainerV1(ctx context.Context, deploymentDir string, a if !found || state.Deployment == nil || state.Deployment.Installation.Scope != string(InstallScopeSystem) { return fmt.Errorf("installed service container requires a system installation") } + document, err := unmappedRuntimeDocumentV1(state) + if err != nil { + return fmt.Errorf("installed service blueprint: %w", err) + } installation := state.Deployment.Installation if dockerPath == "" || !filepath.IsAbs(dockerPath) || filepath.Clean(dockerPath) != dockerPath { return fmt.Errorf("installed service container requires an absolute Docker path") } - document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) - if err != nil { - return fmt.Errorf("installed service blueprint: %w", err) - } store, err := providerstore.NewStore(deploymentDir) if err != nil { return err diff --git a/internal/dockerdeploy/live_run_admission.go b/internal/dockerdeploy/live_run_admission.go index d6273c6a..0a737c5a 100644 --- a/internal/dockerdeploy/live_run_admission.go +++ b/internal/dockerdeploy/live_run_admission.go @@ -90,6 +90,9 @@ func awaitLiveRunAdmissionV1( if backend.acquire == nil || backend.wait == nil { return releaseLiveRunAdmissionLockV1(operation, fmt.Errorf("live run admission requires a complete backend")) } + if err := requireUnmappedDeploymentV1(operation); err != nil { + return releaseLiveRunAdmissionLockV1(operation, err) + } if err := operation.RequireQueueEntryLeaseHeldV1(candidate.ID); err != nil { return releaseLiveRunAdmissionLockV1(operation, fmt.Errorf("live run admission ownership: %w", err)) } @@ -125,6 +128,9 @@ func awaitLiveRunAdmissionV1( if err != nil { return cancelLiveRunAdmissionV1(ctx, deploymentDir, nil, candidate, backend, err) } + if err := requireUnmappedDeploymentV1(operation); err != nil { + return cancelLiveRunAdmissionV1(ctx, deploymentDir, operation, candidate, backend, err) + } if _, err := recoverLiveRunQueueV1(ctx, operation, notice, backend.removeContainer); err != nil { return cancelLiveRunAdmissionV1(ctx, deploymentDir, operation, candidate, backend, err) } diff --git a/internal/dockerdeploy/provider_build_run.go b/internal/dockerdeploy/provider_build_run.go index 53ce4983..66839597 100644 --- a/internal/dockerdeploy/provider_build_run.go +++ b/internal/dockerdeploy/provider_build_run.go @@ -260,6 +260,11 @@ func runLockedProviderBuildV1( if err != nil { return LockedProviderBuildExecutionResultV1{}, fmt.Errorf("run provider build blueprint: %w", err) } + if input.Automatic { + if _, err := unmappedRuntimeDocumentV1(state); err != nil { + return LockedProviderBuildExecutionResultV1{}, err + } + } if state.Deployment != nil { return LockedProviderBuildExecutionResultV1{}, fmt.Errorf("provider build requires a staged deployment; an installed deployment cannot be used as a build source") } diff --git a/internal/dockerdeploy/provider_direct_install_entry.go b/internal/dockerdeploy/provider_direct_install_entry.go index a17ac1cb..427962f3 100644 --- a/internal/dockerdeploy/provider_direct_install_entry.go +++ b/internal/dockerdeploy/provider_direct_install_entry.go @@ -72,7 +72,7 @@ func runDirectProviderInstallEntryV1( if err != nil { return fmt.Errorf("read direct install source: %w", err) } - document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + document, err := unmappedRuntimeDocumentV1(state) if err != nil { return fmt.Errorf("decode direct install blueprint: %w", err) } diff --git a/internal/dockerdeploy/provider_install_preflight.go b/internal/dockerdeploy/provider_install_preflight.go index e47ab8db..62bb1b0f 100644 --- a/internal/dockerdeploy/provider_install_preflight.go +++ b/internal/dockerdeploy/provider_install_preflight.go @@ -20,6 +20,9 @@ func preflightProviderInstallDestinationV1(destination, service string) error { if !found { return nil } + if _, err := unmappedRuntimeDocumentV1(state); err != nil { + return err + } if state.Deployment == nil { return fmt.Errorf("destination contains a staging deployment at %s; choose a different install target", statePath) } diff --git a/internal/dockerdeploy/provider_install_run.go b/internal/dockerdeploy/provider_install_run.go index 4856b928..253cb243 100644 --- a/internal/dockerdeploy/provider_install_run.go +++ b/internal/dockerdeploy/provider_install_run.go @@ -173,7 +173,7 @@ func runProviderInstallV1( if !found || sourceState.Deployment != nil || sourceState.TerminalRemoval { return deploy.StateV1{}, fmt.Errorf("install source %s must contain active staging state", filepath.Join(sourceDir, StateFileName)) } - sourceDocument, err := blueprint.DecodeResolvedDocumentV1(sourceState.Blueprint) + sourceDocument, err := unmappedRuntimeDocumentV1(sourceState) if err != nil { return deploy.StateV1{}, fmt.Errorf("install source blueprint: %w", err) } @@ -230,7 +230,7 @@ func runProviderInstallV1( if !reflect.DeepEqual(sourceBuild.State.Blueprint, sourceState.Blueprint) { return deploy.StateV1{}, fmt.Errorf("install source blueprint changed after destination preflight") } - document, err := blueprint.DecodeResolvedDocumentV1(sourceBuild.State.Blueprint) + document, err := unmappedRuntimeDocumentV1(sourceBuild.State) if err != nil { return deploy.StateV1{}, fmt.Errorf("provider install source blueprint: %w", err) } @@ -569,6 +569,9 @@ func validateProviderInstallDestinationServiceV1(operation *deploy.OperationLock if !found { return nil } + if _, err := unmappedRuntimeDocumentV1(state); err != nil { + return err + } if state.Deployment == nil { return fmt.Errorf("destination contains a staging deployment; choose a different install target") }