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-mapping-geometry.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Added
body: Check numeric identity mapping geometry and protect retained ID assignments against reassignment. Mapped execution remains unavailable.
43 changes: 43 additions & 0 deletions internal/deploy/identity_mapping_geometry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package deploy

import (
"fmt"
"strconv"

"github.com/omry/reploy/internal/blueprint"
"github.com/omry/reploy/internal/identitymapping"
)

// ResolveIdentityMappingGeometryV1 checks one operation's effective input. The
// numeric result grants neither protected reservations nor execution admission.
func ResolveIdentityMappingGeometryV1(document blueprint.Document, override *blueprint.IdentityMappingOverrideV1) (*identitymapping.Geometry, error) {
resolved, err := blueprint.ResolveIdentityMappingV1(document, override)
if err != nil || resolved == nil {
return nil, err
}
geometry := identitymapping.Geometry{Profile: resolved.Profile.Value}
for i, values := range [][]blueprint.MappingIntervalV1{resolved.UIDs, resolved.GIDs} {
intervals := make([]identitymapping.Interval, len(values))
for j, value := range values {
fields := [3]uint32{}
for k, scalar := range []blueprint.MappingScalarV1{value.ContainerStart, value.HostStart, value.Count} {
number, err := strconv.ParseUint(scalar.Value, 10, 32)
if err != nil || scalar.Kind != "integer" || strconv.FormatUint(number, 10) != scalar.Value {
return nil, fmt.Errorf("mapping %s[%d] requires resolved integer IDs", []string{"uids", "gids"}[i], j)
}
fields[k] = uint32(number)
}
intervals[j] = identitymapping.Interval{ContainerStart: fields[0], HostStart: fields[1], Count: fields[2]}
}
if i == 0 {
geometry.UIDs = intervals
} else {
geometry.GIDs = intervals
}
}
checked, err := identitymapping.ValidateGeometry(geometry)
if err != nil {
return nil, fmt.Errorf("environment %q mapping geometry: %w", document.Environment.ID, err)
}
return &checked, nil
}
87 changes: 87 additions & 0 deletions internal/deploy/identity_mapping_geometry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package deploy

import (
"reflect"
"strings"
"testing"

"github.com/omry/reploy/internal/blueprint"
"github.com/omry/reploy/internal/identitymapping"
"gopkg.in/yaml.v3"
)

func geometryDocumentForTest(t *testing.T, input string) blueprint.Document {
t.Helper()
var mapping blueprint.IdentityMappingV1
if err := yaml.Unmarshal([]byte(input), &mapping); err != nil {
t.Fatal(err)
}
return blueprint.Document{Environment: blueprint.Environment{ID: "demo", Runtime: blueprint.EnvironmentRuntime{IdentityMapping: &mapping}}}
}

func TestResolveMappingGeometryUsesTypedOverrideFirstInput(t *testing.T) {
document := geometryDocumentForTest(t, `{schema: 1, profile: "???", uids: [{container_start: 0, host_start: "???", count: 1}], gids: [{container_start: 0, host_start: 400000, count: 2}]}`)
overrides := decodeMappingSidecarV1(t, mappingSidecarV1(`{profile: exact, uids: [{container_start: 5, host_start: 300005, count: 2}, {container_start: 3, host_start: 300003, count: 2}]}`))
before, err := yaml.Marshal(document)
if err != nil {
t.Fatal(err)
}
got, err := ResolveIdentityMappingGeometryV1(document, overrides.IdentityMappingOverride())
want := identitymapping.Geometry{Profile: "exact", UIDs: []identitymapping.Interval{{ContainerStart: 3, HostStart: 300003, Count: 4}}, GIDs: []identitymapping.Interval{{ContainerStart: 0, HostStart: 400000, Count: 2}}}
if err != nil || got == nil || !reflect.DeepEqual(*got, want) {
t.Fatalf("numeric operation candidate = %#v / %v", got, err)
}
after, err := yaml.Marshal(document)
if err != nil || string(before) != string(after) {
t.Fatalf("numeric resolution mutated source authority: %v", err)
}
// Converting input does not accept or replace retained assignments.
retained := want
retained.UIDs = []identitymapping.Interval{{ContainerStart: 3, HostStart: 500003, Count: 4}}
if _, err := identitymapping.RetainGeometry(*got, &retained); err == nil {
t.Fatal("resolved input silently replaced established assignment")
}
}

func TestResolveMappingGeometryPreservesVariableKindsAndMissingPaths(t *testing.T) {
for _, tc := range []struct {
name string
value any
kind string
text string
valid bool
}{
{"integer", 9, "integer", "9", true},
{"integral float", 9.0, "float", "9", false},
{"numeric string", "9", "string", "9", false},
{"boolean", true, "bool", "true", false},
{"missing", "???", "missing", "???", false},
} {
t.Run(tc.name, func(t *testing.T) {
document := geometryDocumentForTest(t, `{schema: 1, profile: exact, uids: [{container_start: "{{ start }}", host_start: 300000, count: 2}], gids: [{container_start: 0, host_start: 400000, count: 2}]}`)
document.Environment.Vars = map[string]any{"start": tc.value}
document.Environment.Runtime.IdentityMapping.Variables = map[string]blueprint.MappingScalarV1{"start": {Kind: tc.kind, Value: tc.text}}
got, err := ResolveIdentityMappingGeometryV1(document, nil)
if (err == nil) != tc.valid || !tc.valid && got != nil {
t.Fatalf("incorrect type acceptance: %#v / %v", got, err)
}
if tc.valid && got.UIDs[0].ContainerStart != 9 {
t.Fatalf("integer binding lost: %#v", got)
}
if tc.kind == "missing" && !strings.Contains(err.Error(), "uids[0].container_start") {
t.Fatalf("missing consumer path lost: %v", err)
}
})
}
}

func TestResolveMappingGeometryOrdinaryAndUndeclaredControls(t *testing.T) {
document := blueprint.Document{Environment: blueprint.Environment{ID: "demo"}}
if got, err := ResolveIdentityMappingGeometryV1(document, nil); err != nil || got != nil {
t.Fatalf("ordinary input changed: %#v / %v", got, err)
}
overrides := decodeMappingSidecarV1(t, mappingSidecarV1(`{profile: exact}`))
if got, err := ResolveIdentityMappingGeometryV1(document, overrides.IdentityMappingOverride()); err == nil || got != nil {
t.Fatalf("undeclared input accepted: %#v / %v", got, err)
}
}
5 changes: 3 additions & 2 deletions internal/dockerdeploy/identity_mapping_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import (

"github.com/omry/reploy/internal/blueprint"
"github.com/omry/reploy/internal/deploy"
"github.com/omry/reploy/internal/identitymapping"
)

// This private metadata seam does not allocate IDs or admit a workload.
// B5 will bind its candidate to the selected generation and mapping revision.
func loadStagedIdentityMappingV1(operation *deploy.OperationLock, dir string) (*blueprint.IdentityMappingV1, error) {
func loadStagedIdentityMappingV1(operation *deploy.OperationLock, dir string) (*identitymapping.Geometry, error) {
if operation == nil {
return nil, fmt.Errorf("mapping input requires a deployment operation lock")
}
Expand All @@ -28,5 +29,5 @@ func loadStagedIdentityMappingV1(operation *deploy.OperationLock, dir string) (*
if err != nil {
return nil, err
}
return blueprint.ResolveIdentityMappingV1(document, overrides.IdentityMappingOverride())
return deploy.ResolveIdentityMappingGeometryV1(document, overrides.IdentityMappingOverride())
}
45 changes: 43 additions & 2 deletions internal/dockerdeploy/identity_mapping_input_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/omry/reploy/internal/blueprint"
"github.com/omry/reploy/internal/deploy"
"github.com/omry/reploy/internal/identitymapping"
"github.com/omry/reploy/internal/providerstore"
)

Expand Down Expand Up @@ -45,7 +46,7 @@ func TestMappingInputUsesLockedSelectedStateAndHasNoEffects(t *testing.T) {
}
before := identityGuardTreeV1(t, dir)
mapping, err := loadStagedIdentityMappingV1(operation, dir)
if err != nil || mapping.UIDs[0].HostStart.Value != "300000" {
if err != nil || mapping.UIDs[0].HostStart != 300000 {
t.Fatalf("effective selected input = %#v / %v", mapping, err)
}
after := identityGuardTreeV1(t, dir)
Expand Down Expand Up @@ -102,11 +103,51 @@ func TestMappingInputCapturedSidecarDoesNotRereadChangedFile(t *testing.T) {
t.Fatalf("captured bytes were replaced during resolution: %#v / %v", oldCandidate, err)
}
newCandidate, err := loadStagedIdentityMappingV1(operation, dir)
if err != nil || newCandidate.UIDs[0].HostStart.Value != "400000" {
if err != nil || newCandidate.UIDs[0].HostStart != 400000 {
t.Fatalf("new operation did not capture current input: %#v / %v", newCandidate, err)
}
}

func TestMappingInputNumericGeometryKeepsProtectedReservationsSeparate(t *testing.T) {
dir, state := identityGuardFixtureV1(t, true, false)
document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint)
if err != nil {
t.Fatal(err)
}
operation, err := deploy.AcquireOperationLock(t.Context(), dir)
if err != nil {
t.Fatal(err)
}
defer operation.Unlock()
if err := operation.CommitPackageOverridesV1(mappingInputOverridesV1(t, document.Environment.ID, "300000")); err != nil {
t.Fatal(err)
}
before := identityGuardTreeV1(t, dir)
candidate, err := loadStagedIdentityMappingV1(operation, dir)
if err != nil || candidate == nil || candidate.Profile != "exact" {
t.Fatalf("numeric staging candidate unavailable: %#v / %v", candidate, err)
}
protected := identitymapping.Geometry{Profile: "exact", UIDs: []identitymapping.Interval{{ContainerStart: 0, HostStart: 300000, Count: 32}}, GIDs: []identitymapping.Interval{{ContainerStart: 0, HostStart: 200000, Count: 32}}}
reserved, err := identitymapping.RetainGeometry(*candidate, &protected)
if err != nil || reserved.UIDs[0].Count != 32 || reserved.GIDs[0].Count != 32 || candidate.UIDs[0].Count != 16 {
t.Fatalf("candidate omitted protected IDs or gained authority: %#v / %#v / %v", reserved, candidate, err)
}
protected.UIDs[0].HostStart = 500000
if _, err := identitymapping.RetainGeometry(*candidate, &protected); err == nil {
t.Fatal("new input silently replaced protected host assignments")
}
if !maps.Equal(before, identityGuardTreeV1(t, dir)) {
t.Fatal("geometry comparison changed deployment state or files")
}
current, found, err := operation.ReadStateV1()
if err != nil || !found {
t.Fatalf("selected state lost: %v", err)
}
if _, err := unmappedRuntimeDocumentV1(current); err == nil {
t.Fatal("numeric mapping validation granted payload admission")
}
}

func TestMappingInputRejectsInstalledAndUndeclaredOwners(t *testing.T) {
for _, installed := range []bool{false, true} {
dir, state := identityGuardFixtureV1(t, false, installed)
Expand Down
146 changes: 146 additions & 0 deletions internal/identitymapping/geometry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package identitymapping

import (
"cmp"
"fmt"
"slices"
)

// Geometry is numeric mapping input, not host authority or execution admission.
// UID and GID assignments occupy separate collision domains.
type Geometry struct {
Profile string
UIDs []Interval
GIDs []Interval
}

// ValidateGeometry returns an independent canonical copy. Work scales with
// explicit intervals, never the number of IDs represented by an interval.
func ValidateGeometry(value Geometry) (Geometry, error) {
if value.Profile != "exact" && value.Profile != "bounded" {
return Geometry{}, fmt.Errorf("mapping profile must be exact or bounded")
}
result := Geometry{Profile: value.Profile}
for i, values := range [][]Interval{value.UIDs, value.GIDs} {
kind := []string{"UID", "GID"}[i]
if len(values) == 0 || value.Profile == "bounded" && len(values) != 1 {
return Geometry{}, fmt.Errorf("%s mapping requires nonempty intervals; bounded requires exactly one", kind)
}
ordered := slices.Clone(values)
sortContainerIntervals(ordered)
for j, interval := range ordered {
if err := ValidateInterval(interval); err != nil {
return Geometry{}, fmt.Errorf("%s geometry: %w", kind, err)
}
if j > 0 && uint64(interval.ContainerStart) < containerEnd(ordered[j-1]) {
return Geometry{}, fmt.Errorf("%s container intervals overlap", kind)
}
}
if _, err := hostSpans(ordered); err != nil {
return Geometry{}, fmt.Errorf("%s geometry: %w", kind, err)
}
// Non-overlapping adjacent intervals may describe one affine binding.
canonical, err := combineReservations(ordered)
if err != nil {
return Geometry{}, err
}
if i == 0 {
result.UIDs = canonical
} else {
result.GIDs = canonical
}
}
return result, nil
}

// RetainGeometry checks a proposed mapping against protected reservations and
// returns their union. Omitting an assignment never releases its reservation.
// The result does not classify IDs as active or dormant. In particular, a
// never-admitted candidate is not evidence of a previously admitted identity.
// Callers must retain admission and pending-operation provenance separately.
func RetainGeometry(proposed Geometry, retained *Geometry) (Geometry, error) {
candidate, err := ValidateGeometry(proposed)
if err != nil {
return Geometry{}, fmt.Errorf("proposed mapping: %w", err)
}
if retained == nil {
return candidate, nil
}
previous, err := ValidateGeometry(*retained)
if err != nil {
return Geometry{}, fmt.Errorf("retained mapping: %w", err)
}
if candidate.Profile != previous.Profile {
return Geometry{}, fmt.Errorf("retained mapping profile cannot change")
}
if candidate.Profile == "bounded" {
if !slices.Equal(candidate.UIDs, previous.UIDs) || !slices.Equal(candidate.GIDs, previous.GIDs) {
return Geometry{}, fmt.Errorf("retained bounded mapping geometry cannot change")
}
return previous, nil
}
result := Geometry{Profile: candidate.Profile}
for i, values := range [][]Interval{candidate.UIDs, candidate.GIDs} {
protected := previous.UIDs
if i == 1 {
protected = previous.GIDs
}
combined := append(slices.Clone(protected), values...)
sortContainerIntervals(combined)
reserved, err := combineReservations(combined)
if err != nil {
return Geometry{}, fmt.Errorf("%s retained mapping: %w", []string{"UID", "GID"}[i], err)
}
// A different container ID cannot take a retained host ID, including
// one omitted by the new candidate. UID and GID checks stay separate.
if _, err := hostSpans(reserved); err != nil {
return Geometry{}, fmt.Errorf("%s retained mapping: %w", []string{"UID", "GID"}[i], err)
}
if i == 0 {
result.UIDs = reserved
} else {
result.GIDs = reserved
}
}
return result, nil
}

func containerEnd(value Interval) uint64 {
return uint64(value.ContainerStart) + uint64(value.Count)
}

func sortContainerIntervals(values []Interval) {
slices.SortFunc(values, func(a, b Interval) int {
return cmp.Compare(a.ContainerStart, b.ContainerStart)
})
}

func bindingOffset(value Interval) int64 {
return int64(value.HostStart) - int64(value.ContainerStart)
}

// Inputs have individually validated arithmetic and are container-sorted.
// Compare affine bindings on intersections, including partial intersections.
func combineReservations(values []Interval) ([]Interval, error) {
result := make([]Interval, 0, len(values))
for _, value := range values {
if len(result) == 0 {
result = append(result, value)
continue
}
last := &result[len(result)-1]
overlaps := uint64(value.ContainerStart) < containerEnd(*last)
adjacent := uint64(value.ContainerStart) == containerEnd(*last)
sameBinding := bindingOffset(value) == bindingOffset(*last)
if overlaps && !sameBinding {
return nil, fmt.Errorf("established container-to-host assignment cannot change")
}
if sameBinding && (overlaps || adjacent) {
end := max(containerEnd(*last), containerEnd(value))
last.Count = uint32(end - uint64(last.ContainerStart))
} else {
result = append(result, value)
}
}
return result, nil
}
Loading
Loading