Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .gds/bundle.lock.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ schema_version: 1
bundle:
version: "0.9.7-dev"
release_sequence: 0
source_tree_digest: "sha256:f57aabc1e0980ecfd7b40d7728812a9c03af9f844c2b9ef7f8765b7720f29952"
digest: "sha256:d7e3c5193688aece7f043ee30612f68992af8af3c8fae18e035a6d8050903cc5"
source_tree_digest: "sha256:ba259a2260cfc55a79b76c69ea895ab446ea9ae6fe2892a016688713f903e162"
digest: "sha256:9b2dd4f5e413021932c2454c7e482fd790482ec9246e5adaf783dc92780672d9"

projection:
input_digest: "sha256:902dc4eb06c1608f4f3303bdead643efc1d540d578f65a3d6f65408dbd92355f"
output_digest: "sha256:3bc62bba5a5405bedb483ffa1e03dc74ff22991ba153035a41306f53f368c3ed"
input_digest: "sha256:4145cec9f3f37a5c3930907936c97ebab0dd5d3e96a3ee480220aa32255c2ffe"
output_digest: "sha256:2572b8c1c82ccf29b5f7950c788f29cc5504bebf64b141e6b60b743d65366717"
files:
- path: ".gds/compiled-policy.json"
digest: "sha256:9f498788bdc34e52a0ab793c536e0e6a7b360c2e1a20446cbf03ed51986cdc6f"
- path: ".github/workflows/gds-ci.yml"
digest: "sha256:d5fe07fdc246ebe55b9c243db242342fb6f09b4c265a0d26a89838be33c7a7a5"
digest: "sha256:439aac0176476d26063ff3799233a79a1032b905b737aa1b1db56d12f5c9b658"
2 changes: 1 addition & 1 deletion .gds/repository.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ provider:

classification:
portfolios:
- "portfolio:opennetwork-projects"
- "portfolio:nddev-opennetwork"
visibility_contract: "public"
data_classification: "public"

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/gds-ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# GENERATED FILE - DO NOT EDIT DIRECTLY
# generator: gds
# bundle: 0.9.7-dev
# source-tree-digest: sha256:f57aabc1e0980ecfd7b40d7728812a9c03af9f844c2b9ef7f8765b7720f29952
# input-digest: sha256:902dc4eb06c1608f4f3303bdead643efc1d540d578f65a3d6f65408dbd92355f
# source-tree-digest: sha256:ba259a2260cfc55a79b76c69ea895ab446ea9ae6fe2892a016688713f903e162
# input-digest: sha256:4145cec9f3f37a5c3930907936c97ebab0dd5d3e96a3ee480220aa32255c2ffe
# output-digest: sha256:4ef1ee2fcc42927eaedef9c85f7b421f87e5cc75ff7055ef520216a00f7d4b74
# edit-source:
# - .gds/repository.yaml
Expand Down
13 changes: 13 additions & 0 deletions core/compiler/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,9 @@ func matchFailure(
if len(match.Portfolios) != 0 && !intersects(match.Portfolios, anchor.Classification.Portfolios) {
return "repository portfolios do not match"
}
if len(match.NamePrefixes) != 0 && !nameHasPrefix(anchor.Provider.Name, match.NamePrefixes) {
return "repository name does not match"
}
if len(match.VisibilityContract) != 0 &&
!contains(match.VisibilityContract, anchor.Classification.VisibilityContract) {
return "visibility contract does not match"
Expand Down Expand Up @@ -807,6 +810,16 @@ func contains(values []string, expected string) bool {
return false
}

func nameHasPrefix(name string, prefixes []string) bool {
lowered := strings.ToLower(name)
for _, prefix := range prefixes {
if strings.HasPrefix(lowered, strings.ToLower(prefix)) {
return true
}
}
return false
}

func policyFinding(code string, source PolicySource, message, path string) domain.Finding {
return domain.Finding{
Code: code, Severity: domain.SeverityHigh, Message: message + ".",
Expand Down
1 change: 1 addition & 0 deletions core/compiler/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type PolicyMatch struct {
Owner string `json:"owner,omitempty"`
Roles []string `json:"roles,omitempty"`
Portfolios []string `json:"portfolios,omitempty"`
NamePrefixes []string `json:"name_prefixes,omitempty"`
VisibilityContract []string `json:"visibility_contract,omitempty"`
Lifecycle []string `json:"lifecycle,omitempty"`
}
Expand Down
11 changes: 6 additions & 5 deletions core/estate/compiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,20 +141,21 @@ func TestCompileRoutesServerRepositoriesByNamePrefix(t *testing.T) {
byID[assignment.ProviderID] = assignment
}
if got := byID[10]; got.MatchedSelector != "organization-servers" ||
len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:servers" {
len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:example-org" {
t.Fatalf("organization server repository = %#v", got)
}
if got := byID[11]; got.MatchedSelector != "organization-sources" ||
!containsString(got.Portfolios, "portfolio:organization-projects") {
!containsString(got.Portfolios, "portfolio:example-org") {
t.Fatalf("organization non-server repository = %#v", got)
}
if got := byID[12]; got.MatchedSelector != "personal-servers" ||
len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:servers" {
len(got.Portfolios) != 1 || got.Portfolios[0] != "portfolio:example-user" {
t.Fatalf("personal server repository = %#v", got)
}
// The name prefix decides, and being a fork no longer overrides it.
// The name prefix decides the selector, and being a fork no longer
// overrides it; the assigned portfolio stays the owner's tenancy.
if got := byID[13]; got.MatchedSelector != "organization-servers" ||
!containsString(got.Portfolios, "portfolio:servers") {
!containsString(got.Portfolios, "portfolio:example-org") {
t.Fatalf("server-named organization fork repository = %#v", got)
}
}
Expand Down
10 changes: 10 additions & 0 deletions core/validation/estate.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package validation

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -346,6 +347,15 @@ func (set *Set) ValidateEstateTree(root string) (EstateSummary, []domain.Finding
assignment, _ := rawAssignment.(map[string]any)
selector := stringField(assignment, "selector")
workspaceRoot := stringField(assignment, "workspace_root")
// Trait-matched includes key on their canonical match content so
// several match rules may precede selector rules on one device.
if selector == "" {
if matchValue, found := assignment["match"]; found {
if encoded, err := json.Marshal(matchValue); err == nil {
selector = "match:" + string(encoded)
}
}
}
if _, duplicate := seenSelectors[selector]; duplicate {
findings = append(findings, domain.Finding{
Code: "GDS_ESTATE_DEVICE_SELECTOR_DUPLICATE", Severity: domain.SeverityHigh,
Expand Down
6 changes: 3 additions & 3 deletions core/validation/estate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func TestEstateTreeRejectsCanonicalSelectorPortfolioMismatch(t *testing.T) {
t.Fatal(err)
}
raw = []byte(strings.Replace(
string(raw), "portfolio:organization-projects", "portfolio:servers", 1,
string(raw), "portfolio:example-guild", "portfolio:servers", 1,
))
if err := os.WriteFile(selectorPath, raw, 0o600); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -318,8 +318,8 @@ func TestEstateTreeRejectsPolicyReferencesThatResolveToNothing(t *testing.T) {
},
{
name: "portfolio",
old: ` - "portfolio:servers"`,
new: ` - "portfolio:no-selector-assigns-this"`,
old: " name_prefixes:\n - \"server-\"",
new: " portfolios:\n - \"portfolio:no-selector-assigns-this\"",
code: "GDS_ESTATE_POLICY_PORTFOLIO_MISSING",
},
} {
Expand Down
24 changes: 20 additions & 4 deletions core/validation/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,15 +427,26 @@ func deviceFindings(source string, object map[string]any) []domain.Finding {
assignment, _ := raw.(map[string]any)
selector, _ := assignment["selector"].(string)
workspaceRoot, _ := assignment["workspace_root"].(string)
if selector != "" {
if _, duplicate := selectors[selector]; duplicate {
// A `match` entry is keyed by its canonical content so two identical
// trait rules are still flagged while distinct trait rules may share a
// root -- first-match-wins ordering makes that unambiguous.
assignmentKey := selector
if assignmentKey == "" {
if matchValue, found := assignment["match"]; found {
if encoded, err := json.Marshal(matchValue); err == nil {
assignmentKey = "match:" + string(encoded)
}
}
}
if assignmentKey != "" {
if _, duplicate := selectors[assignmentKey]; duplicate {
findings = append(findings, domain.Finding{
Code: "GDS_DEVICE_SELECTOR_DUPLICATE", Severity: domain.SeverityHigh,
Message: "Device materialization selectors must be unique.",
Evidence: map[string]any{"source": source, "index": index, "selector": selector},
Evidence: map[string]any{"source": source, "index": index, "selector": assignmentKey},
})
}
selectors[selector] = struct{}{}
selectors[assignmentKey] = struct{}{}
}
if workspaceRoot == "" {
continue
Expand All @@ -447,6 +458,11 @@ func deviceFindings(source string, object map[string]any) []domain.Finding {
Evidence: map[string]any{"source": source, "index": index, "workspace_root": workspaceRoot},
})
}
// The one-root-one-selector rule applies to label selectors only; trait
// matches are disjoint by construction or ordered by first-match-wins.
if selector == "" {
continue
}
if prior, reused := usedRoots[workspaceRoot]; reused && prior != selector {
findings = append(findings, domain.Finding{
Code: "GDS_DEVICE_WORKSPACE_ROOT_REUSED", Severity: domain.SeverityHigh,
Expand Down
87 changes: 72 additions & 15 deletions core/workspace/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,59 @@ type MaterializationPolicy struct {
}

type MaterializationAssignment struct {
Selector string `json:"selector"`
WorkspaceRoot string `json:"workspace_root"`
Mode string `json:"mode"`
Selector string `json:"selector,omitempty"`
Match *PlacementMatch `json:"match,omitempty"`
WorkspaceRoot string `json:"workspace_root"`
Mode string `json:"mode"`
}

// PlacementMatch selects repositories by their own facts -- provider owner
// login, repository name and visibility contract -- so placement does not
// depend on a label the anchor must carry. Every declared field must match;
// an empty field matches nothing by itself but narrows nothing either.
type PlacementMatch struct {
OwnerLogin string `json:"owner_login,omitempty"`
Names []string `json:"names,omitempty"`
NamePrefixes []string `json:"name_prefixes,omitempty"`
Visibility []string `json:"visibility,omitempty"`
}

func (match PlacementMatch) satisfiedBy(anchor domain.RepositoryAnchor) bool {
if match.OwnerLogin != "" &&
!strings.EqualFold(match.OwnerLogin, anchor.Provider.Owner) {
return false
}
if len(match.Names) != 0 {
found := false
for _, name := range match.Names {
if strings.EqualFold(name, anchor.Provider.Name) {
found = true
break
}
}
if !found {
return false
}
}
if len(match.NamePrefixes) != 0 {
found := false
for _, prefix := range match.NamePrefixes {
if strings.HasPrefix(
strings.ToLower(anchor.Provider.Name), strings.ToLower(prefix),
) {
found = true
break
}
}
if !found {
return false
}
}
if len(match.Visibility) != 0 &&
!contains(match.Visibility, anchor.Classification.VisibilityContract) {
return false
}
return true
}

type DeviceStatePolicy struct {
Expand Down Expand Up @@ -165,13 +215,27 @@ func ResolvePlacement(
anchor domain.RepositoryAnchor,
environment Environment,
) (Placement, []domain.Finding) {
matches := make([]MaterializationAssignment, 0, 1)
for _, assignment := range descriptor.Materialization.Include {
if contains(anchor.Classification.Portfolios, assignment.Selector) {
matches = append(matches, assignment)
// Includes are evaluated in declaration order and the first matching
// assignment wins. A specialized rule therefore precedes the generic rule
// it narrows, and a `selector` membership test and a `match` trait test may
// coexist in one list without ambiguity findings.
var assignment *MaterializationAssignment
for index := range descriptor.Materialization.Include {
candidate := descriptor.Materialization.Include[index]
if candidate.Match != nil {
if candidate.Match.satisfiedBy(anchor) {
assignment = &descriptor.Materialization.Include[index]
break
}
continue
}
if candidate.Selector != "" &&
contains(anchor.Classification.Portfolios, candidate.Selector) {
assignment = &descriptor.Materialization.Include[index]
break
}
}
if len(matches) == 0 {
if assignment == nil {
return Placement{
DeviceID: descriptor.Device.ID, RepositoryID: anchor.Repository.ID,
Mode: descriptor.Materialization.DefaultMode,
Expand All @@ -180,13 +244,6 @@ func ResolvePlacement(
"Repository does not match a device materialization assignment.", anchor.Repository.ID,
)}
}
if len(matches) != 1 {
return Placement{}, []domain.Finding{workspaceFinding(
"GDS_WORKSPACE_PLACEMENT_AMBIGUOUS",
"Repository matches more than one device materialization assignment.", anchor.Repository.ID,
)}
}
assignment := matches[0]
portableRoot, found := descriptor.WorkspaceRoots[assignment.WorkspaceRoot]
if !found {
return Placement{}, []domain.Finding{workspaceFinding(
Expand Down
48 changes: 45 additions & 3 deletions core/workspace/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,62 @@ func TestResolvePlacementUsesOnePortfolioAssignment(t *testing.T) {
}
}

func TestResolvePlacementRejectsAmbiguousAssignments(t *testing.T) {
func TestResolvePlacementFirstMatchWins(t *testing.T) {
descriptor := testDevice()
descriptor.Materialization.Include = append(descriptor.Materialization.Include, MaterializationAssignment{
Selector: "portfolio:public-modules", WorkspaceRoot: "personal", Mode: "reference",
})
anchor := testWorkspaceAnchor()
anchor.Classification.Portfolios = append(anchor.Classification.Portfolios, "portfolio:public-modules")
home := filepath.Join(string(filepath.Separator), "home", "owner")
_, findings := ResolvePlacement(descriptor, anchor, Environment{
placement, findings := ResolvePlacement(descriptor, anchor, Environment{
Home: home, XDGStateHome: filepath.Join(home, ".local", "state"),
})
if len(findings) != 0 {
t.Fatalf("findings=%#v", findings)
}
if placement.Selector != "portfolio:personal-projects" {
t.Fatalf("placement=%#v", placement)
}
}

func TestResolvePlacementTraitMatch(t *testing.T) {
descriptor := testDevice()
descriptor.WorkspaceRoots["servers"] = "${HOME}/Developer/servers"
descriptor.Materialization.Include = []MaterializationAssignment{
{
Match: &PlacementMatch{
OwnerLogin: "Example-Org",
NamePrefixes: []string{"server-"},
},
WorkspaceRoot: "servers", Mode: "active",
},
{
Match: &PlacementMatch{OwnerLogin: "example-org"},
WorkspaceRoot: "personal", Mode: "active",
},
}
anchor := testWorkspaceAnchor()
anchor.Provider.Owner = "example-org"
anchor.Provider.Name = "server-testbed"
home := filepath.Join(string(filepath.Separator), "home", "owner")
placement, findings := ResolvePlacement(descriptor, anchor, Environment{
Home: home, XDGStateHome: filepath.Join(home, ".local", "state"),
})
if len(findings) != 1 || findings[0].Code != "GDS_WORKSPACE_PLACEMENT_AMBIGUOUS" {
if len(findings) != 0 {
t.Fatalf("findings=%#v", findings)
}
if placement.WorkspaceRoot != filepath.Join(home, "Developer", "servers") {
t.Fatalf("placement=%#v", placement)
}

anchor.Provider.Name = "plain-project"
placement, findings = ResolvePlacement(descriptor, anchor, Environment{
Home: home, XDGStateHome: filepath.Join(home, ".local", "state"),
})
if len(findings) != 0 || placement.WorkspaceRoot != filepath.Join(home, "Developer", "personal") {
t.Fatalf("placement=%#v findings=%#v", placement, findings)
}
}

func testDevice() DeviceDescriptor {
Expand Down
Loading
Loading