Skip to content

Commit 41d13bf

Browse files
authored
Merge pull request #203 from NDDev-OpenNetwork/feat/owner-tenancy-placement-20261003
feat: trait-based placement and provider-account tenancy
2 parents ee48ad5 + 3f80cb9 commit 41d13bf

39 files changed

Lines changed: 406 additions & 82 deletions

‎.gds/bundle.lock.yaml‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ schema_version: 1
44
bundle:
55
version: "0.9.7-dev"
66
release_sequence: 0
7-
source_tree_digest: "sha256:f57aabc1e0980ecfd7b40d7728812a9c03af9f844c2b9ef7f8765b7720f29952"
8-
digest: "sha256:d7e3c5193688aece7f043ee30612f68992af8af3c8fae18e035a6d8050903cc5"
7+
source_tree_digest: "sha256:ba259a2260cfc55a79b76c69ea895ab446ea9ae6fe2892a016688713f903e162"
8+
digest: "sha256:9b2dd4f5e413021932c2454c7e482fd790482ec9246e5adaf783dc92780672d9"
99

1010
projection:
11-
input_digest: "sha256:902dc4eb06c1608f4f3303bdead643efc1d540d578f65a3d6f65408dbd92355f"
12-
output_digest: "sha256:3bc62bba5a5405bedb483ffa1e03dc74ff22991ba153035a41306f53f368c3ed"
11+
input_digest: "sha256:4145cec9f3f37a5c3930907936c97ebab0dd5d3e96a3ee480220aa32255c2ffe"
12+
output_digest: "sha256:2572b8c1c82ccf29b5f7950c788f29cc5504bebf64b141e6b60b743d65366717"
1313
files:
1414
- path: ".gds/compiled-policy.json"
1515
digest: "sha256:9f498788bdc34e52a0ab793c536e0e6a7b360c2e1a20446cbf03ed51986cdc6f"
1616
- path: ".github/workflows/gds-ci.yml"
17-
digest: "sha256:d5fe07fdc246ebe55b9c243db242342fb6f09b4c265a0d26a89838be33c7a7a5"
17+
digest: "sha256:439aac0176476d26063ff3799233a79a1032b905b737aa1b1db56d12f5c9b658"

‎.gds/repository.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ provider:
2222

2323
classification:
2424
portfolios:
25-
- "portfolio:opennetwork-projects"
25+
- "portfolio:nddev-opennetwork"
2626
visibility_contract: "public"
2727
data_classification: "public"
2828

‎.github/workflows/gds-ci.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# GENERATED FILE - DO NOT EDIT DIRECTLY
22
# generator: gds
33
# bundle: 0.9.7-dev
4-
# source-tree-digest: sha256:f57aabc1e0980ecfd7b40d7728812a9c03af9f844c2b9ef7f8765b7720f29952
5-
# input-digest: sha256:902dc4eb06c1608f4f3303bdead643efc1d540d578f65a3d6f65408dbd92355f
4+
# source-tree-digest: sha256:ba259a2260cfc55a79b76c69ea895ab446ea9ae6fe2892a016688713f903e162
5+
# input-digest: sha256:4145cec9f3f37a5c3930907936c97ebab0dd5d3e96a3ee480220aa32255c2ffe
66
# output-digest: sha256:4ef1ee2fcc42927eaedef9c85f7b421f87e5cc75ff7055ef520216a00f7d4b74
77
# edit-source:
88
# - .gds/repository.yaml

‎core/compiler/compiler.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,9 @@ func matchFailure(
577577
if len(match.Portfolios) != 0 && !intersects(match.Portfolios, anchor.Classification.Portfolios) {
578578
return "repository portfolios do not match"
579579
}
580+
if len(match.NamePrefixes) != 0 && !nameHasPrefix(anchor.Provider.Name, match.NamePrefixes) {
581+
return "repository name does not match"
582+
}
580583
if len(match.VisibilityContract) != 0 &&
581584
!contains(match.VisibilityContract, anchor.Classification.VisibilityContract) {
582585
return "visibility contract does not match"
@@ -807,6 +810,16 @@ func contains(values []string, expected string) bool {
807810
return false
808811
}
809812

813+
func nameHasPrefix(name string, prefixes []string) bool {
814+
lowered := strings.ToLower(name)
815+
for _, prefix := range prefixes {
816+
if strings.HasPrefix(lowered, strings.ToLower(prefix)) {
817+
return true
818+
}
819+
}
820+
return false
821+
}
822+
810823
func policyFinding(code string, source PolicySource, message, path string) domain.Finding {
811824
return domain.Finding{
812825
Code: code, Severity: domain.SeverityHigh, Message: message + ".",

‎core/compiler/types.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type PolicyMatch struct {
3535
Owner string `json:"owner,omitempty"`
3636
Roles []string `json:"roles,omitempty"`
3737
Portfolios []string `json:"portfolios,omitempty"`
38+
NamePrefixes []string `json:"name_prefixes,omitempty"`
3839
VisibilityContract []string `json:"visibility_contract,omitempty"`
3940
Lifecycle []string `json:"lifecycle,omitempty"`
4041
}

‎core/estate/compiler_test.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,20 +141,21 @@ func TestCompileRoutesServerRepositoriesByNamePrefix(t *testing.T) {
141141
byID[assignment.ProviderID] = assignment
142142
}
143143
if got := byID[10]; got.MatchedSelector != "organization-servers" ||
144-
len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:servers" {
144+
len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:example-org" {
145145
t.Fatalf("organization server repository = %#v", got)
146146
}
147147
if got := byID[11]; got.MatchedSelector != "organization-sources" ||
148-
!containsString(got.Portfolios, "portfolio:organization-projects") {
148+
!containsString(got.Portfolios, "portfolio:example-org") {
149149
t.Fatalf("organization non-server repository = %#v", got)
150150
}
151151
if got := byID[12]; got.MatchedSelector != "personal-servers" ||
152-
len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:servers" {
152+
len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:example-user" {
153153
t.Fatalf("personal server repository = %#v", got)
154154
}
155-
// The name prefix decides, and being a fork no longer overrides it.
155+
// The name prefix decides the selector, and being a fork no longer
156+
// overrides it; the assigned portfolio stays the owner's tenancy.
156157
if got := byID[13]; got.MatchedSelector != "organization-servers" ||
157-
!containsString(got.Portfolios, "portfolio:servers") {
158+
!containsString(got.Portfolios, "portfolio:example-org") {
158159
t.Fatalf("server-named organization fork repository = %#v", got)
159160
}
160161
}

‎core/validation/estate.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package validation
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"os"
67
"path/filepath"
@@ -346,6 +347,15 @@ func (set *Set) ValidateEstateTree(root string) (EstateSummary, []domain.Finding
346347
assignment, _ := rawAssignment.(map[string]any)
347348
selector := stringField(assignment, "selector")
348349
workspaceRoot := stringField(assignment, "workspace_root")
350+
// Trait-matched includes key on their canonical match content so
351+
// several match rules may precede selector rules on one device.
352+
if selector == "" {
353+
if matchValue, found := assignment["match"]; found {
354+
if encoded, err := json.Marshal(matchValue); err == nil {
355+
selector = "match:" + string(encoded)
356+
}
357+
}
358+
}
349359
if _, duplicate := seenSelectors[selector]; duplicate {
350360
findings = append(findings, domain.Finding{
351361
Code: "GDS_ESTATE_DEVICE_SELECTOR_DUPLICATE", Severity: domain.SeverityHigh,

‎core/validation/estate_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func TestEstateTreeRejectsCanonicalSelectorPortfolioMismatch(t *testing.T) {
6464
t.Fatal(err)
6565
}
6666
raw = []byte(strings.Replace(
67-
string(raw), "portfolio:organization-projects", "portfolio:servers", 1,
67+
string(raw), "portfolio:example-guild", "portfolio:servers", 1,
6868
))
6969
if err := os.WriteFile(selectorPath, raw, 0o600); err != nil {
7070
t.Fatal(err)
@@ -318,8 +318,8 @@ func TestEstateTreeRejectsPolicyReferencesThatResolveToNothing(t *testing.T) {
318318
},
319319
{
320320
name: "portfolio",
321-
old: ` - "portfolio:servers"`,
322-
new: ` - "portfolio:no-selector-assigns-this"`,
321+
old: " name_prefixes:\n - \"server-\"",
322+
new: " portfolios:\n - \"portfolio:no-selector-assigns-this\"",
323323
code: "GDS_ESTATE_POLICY_PORTFOLIO_MISSING",
324324
},
325325
} {

‎core/validation/schema.go‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -427,15 +427,26 @@ func deviceFindings(source string, object map[string]any) []domain.Finding {
427427
assignment, _ := raw.(map[string]any)
428428
selector, _ := assignment["selector"].(string)
429429
workspaceRoot, _ := assignment["workspace_root"].(string)
430-
if selector != "" {
431-
if _, duplicate := selectors[selector]; duplicate {
430+
// A `match` entry is keyed by its canonical content so two identical
431+
// trait rules are still flagged while distinct trait rules may share a
432+
// root -- first-match-wins ordering makes that unambiguous.
433+
assignmentKey := selector
434+
if assignmentKey == "" {
435+
if matchValue, found := assignment["match"]; found {
436+
if encoded, err := json.Marshal(matchValue); err == nil {
437+
assignmentKey = "match:" + string(encoded)
438+
}
439+
}
440+
}
441+
if assignmentKey != "" {
442+
if _, duplicate := selectors[assignmentKey]; duplicate {
432443
findings = append(findings, domain.Finding{
433444
Code: "GDS_DEVICE_SELECTOR_DUPLICATE", Severity: domain.SeverityHigh,
434445
Message: "Device materialization selectors must be unique.",
435-
Evidence: map[string]any{"source": source, "index": index, "selector": selector},
446+
Evidence: map[string]any{"source": source, "index": index, "selector": assignmentKey},
436447
})
437448
}
438-
selectors[selector] = struct{}{}
449+
selectors[assignmentKey] = struct{}{}
439450
}
440451
if workspaceRoot == "" {
441452
continue
@@ -447,6 +458,11 @@ func deviceFindings(source string, object map[string]any) []domain.Finding {
447458
Evidence: map[string]any{"source": source, "index": index, "workspace_root": workspaceRoot},
448459
})
449460
}
461+
// The one-root-one-selector rule applies to label selectors only; trait
462+
// matches are disjoint by construction or ordered by first-match-wins.
463+
if selector == "" {
464+
continue
465+
}
450466
if prior, reused := usedRoots[workspaceRoot]; reused && prior != selector {
451467
findings = append(findings, domain.Finding{
452468
Code: "GDS_DEVICE_WORKSPACE_ROOT_REUSED", Severity: domain.SeverityHigh,

‎core/workspace/device.go‎

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,59 @@ type MaterializationPolicy struct {
8484
}
8585

8686
type MaterializationAssignment struct {
87-
Selector string `json:"selector"`
88-
WorkspaceRoot string `json:"workspace_root"`
89-
Mode string `json:"mode"`
87+
Selector string `json:"selector,omitempty"`
88+
Match *PlacementMatch `json:"match,omitempty"`
89+
WorkspaceRoot string `json:"workspace_root"`
90+
Mode string `json:"mode"`
91+
}
92+
93+
// PlacementMatch selects repositories by their own facts -- provider owner
94+
// login, repository name and visibility contract -- so placement does not
95+
// depend on a label the anchor must carry. Every declared field must match;
96+
// an empty field matches nothing by itself but narrows nothing either.
97+
type PlacementMatch struct {
98+
OwnerLogin string `json:"owner_login,omitempty"`
99+
Names []string `json:"names,omitempty"`
100+
NamePrefixes []string `json:"name_prefixes,omitempty"`
101+
Visibility []string `json:"visibility,omitempty"`
102+
}
103+
104+
func (match PlacementMatch) satisfiedBy(anchor domain.RepositoryAnchor) bool {
105+
if match.OwnerLogin != "" &&
106+
!strings.EqualFold(match.OwnerLogin, anchor.Provider.Owner) {
107+
return false
108+
}
109+
if len(match.Names) != 0 {
110+
found := false
111+
for _, name := range match.Names {
112+
if strings.EqualFold(name, anchor.Provider.Name) {
113+
found = true
114+
break
115+
}
116+
}
117+
if !found {
118+
return false
119+
}
120+
}
121+
if len(match.NamePrefixes) != 0 {
122+
found := false
123+
for _, prefix := range match.NamePrefixes {
124+
if strings.HasPrefix(
125+
strings.ToLower(anchor.Provider.Name), strings.ToLower(prefix),
126+
) {
127+
found = true
128+
break
129+
}
130+
}
131+
if !found {
132+
return false
133+
}
134+
}
135+
if len(match.Visibility) != 0 &&
136+
!contains(match.Visibility, anchor.Classification.VisibilityContract) {
137+
return false
138+
}
139+
return true
90140
}
91141

92142
type DeviceStatePolicy struct {
@@ -165,13 +215,27 @@ func ResolvePlacement(
165215
anchor domain.RepositoryAnchor,
166216
environment Environment,
167217
) (Placement, []domain.Finding) {
168-
matches := make([]MaterializationAssignment, 0, 1)
169-
for _, assignment := range descriptor.Materialization.Include {
170-
if contains(anchor.Classification.Portfolios, assignment.Selector) {
171-
matches = append(matches, assignment)
218+
// Includes are evaluated in declaration order and the first matching
219+
// assignment wins. A specialized rule therefore precedes the generic rule
220+
// it narrows, and a `selector` membership test and a `match` trait test may
221+
// coexist in one list without ambiguity findings.
222+
var assignment *MaterializationAssignment
223+
for index := range descriptor.Materialization.Include {
224+
candidate := descriptor.Materialization.Include[index]
225+
if candidate.Match != nil {
226+
if candidate.Match.satisfiedBy(anchor) {
227+
assignment = &descriptor.Materialization.Include[index]
228+
break
229+
}
230+
continue
231+
}
232+
if candidate.Selector != "" &&
233+
contains(anchor.Classification.Portfolios, candidate.Selector) {
234+
assignment = &descriptor.Materialization.Include[index]
235+
break
172236
}
173237
}
174-
if len(matches) == 0 {
238+
if assignment == nil {
175239
return Placement{
176240
DeviceID: descriptor.Device.ID, RepositoryID: anchor.Repository.ID,
177241
Mode: descriptor.Materialization.DefaultMode,
@@ -180,13 +244,6 @@ func ResolvePlacement(
180244
"Repository does not match a device materialization assignment.", anchor.Repository.ID,
181245
)}
182246
}
183-
if len(matches) != 1 {
184-
return Placement{}, []domain.Finding{workspaceFinding(
185-
"GDS_WORKSPACE_PLACEMENT_AMBIGUOUS",
186-
"Repository matches more than one device materialization assignment.", anchor.Repository.ID,
187-
)}
188-
}
189-
assignment := matches[0]
190247
portableRoot, found := descriptor.WorkspaceRoots[assignment.WorkspaceRoot]
191248
if !found {
192249
return Placement{}, []domain.Finding{workspaceFinding(

0 commit comments

Comments
 (0)