Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changes/unreleased/+multi-identity-deployment-overrides.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Added
body: Preserve typed identity mapping overrides in staged deployment input and resolve effective mapping values without changing blueprint build identity. Mapped execution remains unavailable.
163 changes: 163 additions & 0 deletions internal/blueprint/identity_mapping_override.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package blueprint

import (
"fmt"
"slices"
"strings"

"gopkg.in/yaml.v3"
)

// IdentityMappingOverrideV1 is sparse deployment input, never host authority.
// A supplied array replaces the complete corresponding declaration array.
type IdentityMappingOverrideV1 struct {
Schema *int `yaml:"schema,omitempty"`
Profile *MappingScalarV1 `yaml:"profile,omitempty"`
UIDs *[]MappingIntervalV1 `yaml:"uids,omitempty"`
GIDs *[]MappingIntervalV1 `yaml:"gids,omitempty"`
}

func (value MappingScalarV1) MarshalYAML() (any, error) {
if _, ok := value.integer(); ok {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: value.Value}, nil
}
if value.Kind == "string" || value.Kind == "missing" || value.Kind == "expression" {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value.Value}, nil
}
return nil, fmt.Errorf("%s cannot encode an invalid scalar", identityMappingPathV1)
}

func (override *IdentityMappingOverrideV1) UnmarshalYAML(node *yaml.Node) error {
if _, err := validateSyntaxMapping(node, identityMappingPathV1, map[string]bool{
"schema": true, "profile": true, "uids": true, "gids": true,
}); err != nil {
return err
}
for i := 0; i < len(node.Content); i += 2 {
name, value := node.Content[i].Value, node.Content[i+1]
if value.Tag == "!!null" || name == "schema" && value.Tag != "!!int" {
return fmt.Errorf("%s.%s has an invalid type", identityMappingPathV1, name)
}
if (name == "uids" || name == "gids") && value.Kind != yaml.SequenceNode {
return fmt.Errorf("%s.%s must be an array", identityMappingPathV1, name)
}
}
type plain IdentityMappingOverrideV1
if err := node.Decode((*plain)(override)); err != nil {
return err
}
return override.Validate()
}

func (override *IdentityMappingOverrideV1) Validate() error {
if override == nil {
return nil
}
missing := MappingScalarV1{Kind: "missing", Value: "???"}
placeholder := []MappingIntervalV1{{missing, missing, missing}}
_, err := (&IdentityMappingV1{Schema: 1, Profile: missing, UIDs: placeholder, GIDs: placeholder}).WithOverride(override)
return err
}

// WithOverride validates portable intent without requiring effective values.
// It does not modify either input or resolve a leaf that an override replaces.
func (mapping *IdentityMappingV1) WithOverride(override *IdentityMappingOverrideV1) (*IdentityMappingV1, error) {
if mapping == nil {
if override != nil {
return nil, fmt.Errorf("%s override requires a blueprint declaration", identityMappingPathV1)
}
return nil, nil
}
if err := mapping.validate(); err != nil {
return nil, err
}
result := *mapping
if override != nil {
if override.Schema != nil {
result.Schema = *override.Schema
}
if override.Profile != nil {
result.Profile = *override.Profile
}
if override.UIDs != nil {
result.UIDs = *override.UIDs
}
if override.GIDs != nil {
result.GIDs = *override.GIDs
}
}
if err := result.validate(); err != nil {
return nil, err
}
result.UIDs = slices.Clone(result.UIDs)
result.GIDs = slices.Clone(result.GIDs)
return &result, nil
}

// ResolveIdentityMappingV1 supplies a pure operation-specific candidate. It
// grants no reservation or execution admission and leaves portable intent intact.
func ResolveIdentityMappingV1(document Document, override *IdentityMappingOverrideV1) (*IdentityMappingV1, error) {
mapping := document.Environment.Runtime.IdentityMapping
if err := mapping.validateVariables(document.Environment.Vars); err != nil {
return nil, fmt.Errorf("environment %q: %w", document.Environment.ID, err)
}
result, err := mapping.WithOverride(override)
if err != nil {
return nil, fmt.Errorf("environment %q: %w", document.Environment.ID, err)
}
if result == nil {
return nil, nil
}
var missing []string
resolve := func(value *MappingScalarV1, path string) {
if value.Kind == "expression" {
if match := wholeInterpolationPattern.FindStringSubmatch(value.Value); match != nil {
binding, found := result.Variables[match[1]]
if !found {
binding = MappingScalarV1{Kind: "missing", Value: "???"}
}
*value = binding
} else {
unresolved := false
invalid := false
text := interpolationPattern.ReplaceAllStringFunc(value.Value, func(token string) string {
name := interpolationPattern.FindStringSubmatch(token)[1]
binding, found := result.Variables[name]
if !found || binding.Kind == "missing" || binding.Kind == "expression" {
unresolved = true
}
invalid = invalid || binding.Kind == "invalid" || binding.Kind == "null"
return binding.Value
})
*value = MappingScalarV1{Kind: "string", Value: text}
if invalid {
value.Kind = "invalid"
}
if unresolved {
value.Kind = "missing"
}
}
}
if value.Kind == "missing" || value.Kind == "expression" {
missing = append(missing, path)
}
}
resolve(&result.Profile, identityMappingPathV1+".profile")
for i, values := range [][]MappingIntervalV1{result.UIDs, result.GIDs} {
for j := range values {
prefix := fmt.Sprintf("%s.%s[%d].", identityMappingPathV1, []string{"uids", "gids"}[i], j)
resolve(&values[j].ContainerStart, prefix+"container_start")
resolve(&values[j].HostStart, prefix+"host_start")
resolve(&values[j].Count, prefix+"count")
}
}
if len(missing) != 0 {
slices.Sort(missing)
return nil, fmt.Errorf("environment %q: required mapping values unresolved at %s; supply deployment overrides", document.Environment.ID, strings.Join(missing, ", "))
}
result.Variables = nil
if err := result.validate(); err != nil {
return nil, fmt.Errorf("environment %q: %w", document.Environment.ID, err)
}
return canonicalIdentityMappingV1(result), nil
}
189 changes: 189 additions & 0 deletions internal/blueprint/identity_mapping_override_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package blueprint

import (
"fmt"
"reflect"
"strings"
"testing"

"gopkg.in/yaml.v3"
)

func mappingOverrideDocumentV1(t *testing.T, mapping, variables string) Document {
t.Helper()
source, err := Decode([]byte(mappingBlueprintV1(mapping, variables)))
if err != nil {
t.Fatal(err)
}
document, err := Resolve(source)
if err != nil {
t.Fatal(err)
}
payload, err := EncodeResolvedDocumentV1(document)
if err != nil {
t.Fatal(err)
}
document, err = DecodeResolvedDocumentV1(payload)
if err != nil {
t.Fatal(err)
}
return document
}

func decodeMappingOverrideV1(t *testing.T, text string) *IdentityMappingOverrideV1 {
t.Helper()
var override IdentityMappingOverrideV1
if err := yaml.Unmarshal([]byte(text), &override); err != nil {
t.Fatal(err)
}
return &override
}

func TestMappingOverrideStrictSparseInput(t *testing.T) {
for _, text := range []string{
`{}`, `{schema: 1}`, `{profile: exact}`, `{profile: "???"}`,
`{uids: [{container_start: 0, host_start: "{{ host }}", count: 1}]}`,
} {
override := decodeMappingOverrideV1(t, text)
content, err := yaml.Marshal(override)
if err != nil {
t.Fatal(err)
}
decoded := decodeMappingOverrideV1(t, string(content))
if !reflect.DeepEqual(override, decoded) {
t.Fatalf("sparse input changed across YAML: %s", text)
}
}
for _, text := range []string{
`[]`, `{extra: 1}`, `{schema: 1, schema: 1}`,
`{schema: null}`, `{schema: "1"}`, `{schema: 1.0}`, `{schema: true}`, `{schema: 2}`,
`{profile: null}`, `{profile: false}`, `{profile: 1}`, `{profile: other}`,
`{uids: null}`, `{uids: {}}`, `{uids: []}`, `{uids: [null]}`,
`{uids: [{container_start: 0, count: 1}]}`,
`{uids: [{container_start: 0, host_start: 1, count: null}]}`,
`{uids: [{container_start: 0, host_start: "1", count: 1}]}`,
`{uids: [{container_start: 0, host_start: 1.0, count: 1}]}`,
`{uids: [{container_start: 0, host_start: 0, count: 1}]}`,
`{uids: [{container_start: 0, host_start: 1, count: 0}]}`,
`{uids: [{container_start: 0, host_start: 1, count: 1, extra: 0}]}`,
`{gids: [{container_start: 0, host_start: 1, count: 1, count: 1}]}`,
`{uids: [{container_start: 4294967294, host_start: 1, count: 2}]}`,
`{uids: [{container_start: 0, host_start: 10, count: 2}, {container_start: 1, host_start: 20, count: 1}]}`,
`{profile: bounded, uids: [{container_start: 0, host_start: 10, count: 1}, {container_start: 2, host_start: 20, count: 1}]}`,
} {
t.Run(text, func(t *testing.T) {
var override IdentityMappingOverrideV1
if err := yaml.Unmarshal([]byte(text), &override); err == nil {
t.Fatalf("accepted malformed sparse override %s", text)
}
})
}
}

func TestMappingOverrideReplacesBeforeResolvingAndPreservesPortableAuthority(t *testing.T) {
mapping := `{schema: 1, profile: "???", uids: [{container_start: "{{ nonexistent }}", host_start: "???", count: "???"}], gids: [{container_start: 0, host_start: "???", count: 1}]}`
document := mappingOverrideDocumentV1(t, mapping, " vars:\n unused_host: 100000\n")
before, err := EncodeResolvedDocumentV1(document)
if err != nil {
t.Fatal(err)
}
override := decodeMappingOverrideV1(t, `{profile: exact, uids: [{container_start: 4, host_start: " {{ unused_host }} ", count: 2}], gids: [{container_start: 0, host_start: 200000, count: 1}]}`)
savedOverride, _ := yaml.Marshal(override)
result, err := ResolveIdentityMappingV1(document, override)
if err != nil {
t.Fatal(err)
}
if len(result.UIDs) != 1 || result.UIDs[0].ContainerStart.Value != "4" || result.UIDs[0].HostStart != (MappingScalarV1{"integer", "100000"}) || result.Variables != nil {
t.Fatalf("incorrect complete-array replacement/resolution: %#v", result)
}
result.UIDs[0].Count.Value = "999"
after, _ := EncodeResolvedDocumentV1(document)
afterOverride, _ := yaml.Marshal(override)
if before != after || string(savedOverride) != string(afterOverride) {
t.Fatal("candidate modified blueprint or caller-owned override")
}
firstDigest, _ := ResolvedDocumentDigestV1(before)
secondDigest, _ := DocumentDigestV1(document)
if firstDigest != secondDigest {
t.Fatal("deployment host selection changed portable authority")
}
}

func TestMappingOverrideOriginalVariableTypesAfterWireRoundTrip(t *testing.T) {
for _, tc := range []struct {
value string
ok bool
}{
{"100000", true}, {`"100000"`, false}, {"100000.0", false}, {"true", false},
{"null", false}, {`"???"`, false}, {`[100000]`, false}, {`{id: 100000}`, false},
} {
t.Run(tc.value, func(t *testing.T) {
document := mappingOverrideDocumentV1(t, mappingDeclarationV1, " vars:\n unused: "+tc.value+"\n")
override := decodeMappingOverrideV1(t, `{uids: [{container_start: 0, host_start: "{{ unused }}", count: 1}]}`)
_, err := ResolveIdentityMappingV1(document, override)
if (err == nil) != tc.ok {
t.Fatalf("original %s type: error = %v", tc.value, err)
}
if err != nil && !strings.Contains(err.Error(), "environment.runtime.identity_mapping.uids[0].host_start") {
t.Fatalf("error lacks exact consumer path: %v", err)
}
})
}
}

func TestMappingOverrideCollectsMissingPathsWithoutPrivateValues(t *testing.T) {
document := mappingOverrideDocumentV1(t, `{schema: 1, profile: "{{ missing_profile }}", uids: [{container_start: 0, host_start: "???", count: "{{ count }}"}], gids: [{container_start: 0, host_start: "{{ user.gid }}", count: 1}]}`, " vars:\n count: '???'\n private: do-not-print-this\n")
_, err := ResolveIdentityMappingV1(document, nil)
if err == nil {
t.Fatal("accepted unresolved mapping")
}
for _, path := range []string{".profile", ".uids[0].host_start", ".uids[0].count", ".gids[0].host_start"} {
if !strings.Contains(err.Error(), identityMappingPathV1+path) {
t.Errorf("missing path %s in %v", path, err)
}
}
if !strings.Contains(err.Error(), `environment "demo"`) || strings.Contains(err.Error(), "do-not-print-this") {
t.Fatalf("incorrect diagnostic context/privacy: %v", err)
}
}

func TestMappingOverrideEffectiveValidationAndCanonicalOrder(t *testing.T) {
document := mappingOverrideDocumentV1(t, mappingDeclarationV1, " vars:\n first: 2\n second: 10\n")
for _, order := range []string{
`[{container_start: "{{ second }}", host_start: 200000, count: 1}, {container_start: "{{ first }}", host_start: 100000, count: 1}]`,
`[{container_start: "{{ first }}", host_start: 100000, count: 1}, {container_start: "{{ second }}", host_start: 200000, count: 1}]`,
} {
mapping, err := ResolveIdentityMappingV1(document, decodeMappingOverrideV1(t, "{uids: "+order+"}"))
if err != nil || mapping.UIDs[0].ContainerStart.Value != "2" || mapping.UIDs[1].ContainerStart.Value != "10" {
t.Fatalf("canonical effective numeric order: %#v / %v", mapping, err)
}
}
for _, leaf := range []string{`"{{ first }}0"`, `"{{ missing }}"`, `"{{ runtime.uid }}"`} {
_, err := ResolveIdentityMappingV1(document, decodeMappingOverrideV1(t, fmt.Sprintf(`{uids: [{container_start: 0, host_start: %s, count: 1}]}`, leaf)))
if err == nil || !strings.Contains(err.Error(), ".uids[0].host_start") {
t.Fatalf("accepted coerced or unresolved ID %s: %v", leaf, err)
}
}
// Literal starts do not overlap, but their original typed bindings do.
_, err := ResolveIdentityMappingV1(document, decodeMappingOverrideV1(t, `{uids: [{container_start: "{{ first }}", host_start: 100000, count: 10}, {container_start: "{{ second }}", host_start: 200000, count: 1}]}`))
if err == nil || !strings.Contains(err.Error(), "overlap") {
t.Fatalf("accepted effective overlap: %v", err)
}
}

func TestMappingOverrideRequiresDeclarationAndRetainsOrdinaryBehavior(t *testing.T) {
source, err := Decode([]byte(minimalBlueprint))
if err != nil {
t.Fatal(err)
}
document, err := Resolve(source)
if err != nil {
t.Fatal(err)
}
if mapping, err := ResolveIdentityMappingV1(document, nil); err != nil || mapping != nil {
t.Fatalf("ordinary document changed: %#v / %v", mapping, err)
}
if _, err := ResolveIdentityMappingV1(document, decodeMappingOverrideV1(t, `{}`)); err == nil || !strings.Contains(err.Error(), "declaration") {
t.Fatalf("undeclared override accepted: %v", err)
}
}
13 changes: 13 additions & 0 deletions internal/deploy/desired_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ func setDesiredStateV1(
document.Environment.ID,
)
}
if err := initialOverrides.CheckIdentityMappingDeclaration(document); err != nil {
return DesiredStateUpdateResult{}, err
}
if _, sidecarFound, readErr := ReadPackageOverridesV1(deploymentDir); readErr != nil {
return DesiredStateUpdateResult{}, readErr
} else if sidecarFound {
Expand All @@ -138,6 +141,16 @@ func setDesiredStateV1(
PackageOverridesFilename,
)
}
} else if !found || current.Deployment == nil {
overrides, sidecarFound, readErr := ReadPackageOverridesV1(deploymentDir)
if readErr != nil {
return DesiredStateUpdateResult{}, readErr
}
if sidecarFound && overrides.IdentityMappingOverride() != nil {
if err := overrides.CheckIdentityMappingDeclaration(document); err != nil {
return DesiredStateUpdateResult{}, err
}
}
}
overlay := EmptyRequestOverlayV1()
var generation *EnvironmentGenerationState
Expand Down
Loading
Loading