From 43d188418d0d6082ea061fb22ca65f1df80a4681 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Thu, 22 Oct 2026 21:21:17 +0800 Subject: [PATCH] B2.1: preserve mapping declarations and guard execution Add strict optional blueprint identity mappings with typed unresolved values and canonical persistence. Preserve required markers and original variable types through portable staging and explicit builds. Reject payload execution and installation for complete or unresolved mapping declarations before runtime preparation or launch. Reuse the existing operation lock and admission boundaries, including autonomous system-service startup and rechecks after admission waits. Effective overrides and mapped runtime admission remain in later slices. Validation: complete Go/Python checks, Docker CLI smoke, release and documentation builds, focused race/coverage checks, and fresh independent full review. --- .../+multi-identity-mapping-declarations.yaml | 2 + internal/blueprint/component_syntax.go | 25 + internal/blueprint/identity_mapping.go | 358 ++++++++++ internal/blueprint/identity_mapping_test.go | 666 ++++++++++++++++++ internal/blueprint/model.go | 5 +- internal/blueprint/resolve.go | 6 +- internal/blueprint/resolved_document_v1.go | 7 + internal/blueprint/syntax.go | 5 +- internal/blueprint/syntax_variables.go | 3 + internal/dockerdeploy/config.go | 2 +- internal/dockerdeploy/control_admission.go | 6 + .../dockerdeploy/control_admission_modes.go | 4 + .../dockerdeploy/current_app_command_run.go | 2 +- .../current_controlled_session_run.go | 2 +- .../dockerdeploy/current_runtime_test_v1.go | 3 +- internal/dockerdeploy/current_shell_run.go | 2 +- internal/dockerdeploy/current_workload_run.go | 6 +- .../identity_mapping_admission_test.go | 373 ++++++++++ .../dockerdeploy/identity_mapping_guard.go | 41 ++ .../identity_mapping_guard_test.go | 326 +++++++++ .../installed_service_container.go | 9 +- internal/dockerdeploy/live_run_admission.go | 6 + internal/dockerdeploy/provider_build_run.go | 5 + .../provider_direct_install_entry.go | 2 +- .../provider_install_preflight.go | 3 + internal/dockerdeploy/provider_install_run.go | 7 +- 26 files changed, 1855 insertions(+), 21 deletions(-) create mode 100644 .changes/unreleased/+multi-identity-mapping-declarations.yaml create mode 100644 internal/blueprint/identity_mapping.go create mode 100644 internal/blueprint/identity_mapping_test.go create mode 100644 internal/dockerdeploy/identity_mapping_admission_test.go create mode 100644 internal/dockerdeploy/identity_mapping_guard.go create mode 100644 internal/dockerdeploy/identity_mapping_guard_test.go 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") }