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 @@ -5,14 +5,14 @@ bundle:
version: "0.8.0-dev"
release_sequence: 0
channel: "development"
source_tree_digest: "sha256:fc5b140798948c5f0075bf03c0d9798479a915dc9799b645d72a04f06588f93b"
digest: "sha256:9fc077c1299b2e22608501eddecfc1ed25a5c8fe448f5c1b3587630cdbfc0037"
source_tree_digest: "sha256:9bc634940f75b183e5a902de5fc5f3c4afdd9f2e8de02ef2a459ae4527841d55"
digest: "sha256:c37ff41502eec54ad1d72293599902fe35165c60bc5629727a0ac67150fb1579"

projection:
input_digest: "sha256:48c167ca2b9a0543540374c3212c44ca9745d56f5f38a5734447dd247832f604"
output_digest: "sha256:0a9798865f28716f1c524884e8d989186cf26dd63606f9737febe77774f9134c"
input_digest: "sha256:bf64ffc42fdab777b0519f2930a4d513e2adfe324bc9a27011c7140c0511261c"
output_digest: "sha256:64b0ebb161455923ef3689ac05b56797fa0f7e2b522ce0b4fcf9cf6b1963833b"
files:
- path: ".gds/compiled-policy.json"
digest: "sha256:807282f820294914e1c7e6ad1bf27c54a799d56305c58630254ab50ab286f379"
- path: ".github/workflows/gds-ci.yml"
digest: "sha256:3cebb82fa1407d47f7a4044e0ebf4c1e092bc8d6ef3c568fe2813fb1a508410f"
digest: "sha256:2e9b931e590fa503a8adc669d97b528f3136c95cefef4ad13e0ad1a92a4502a2"
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.8.0-dev
# source-tree-digest: sha256:fc5b140798948c5f0075bf03c0d9798479a915dc9799b645d72a04f06588f93b
# input-digest: sha256:48c167ca2b9a0543540374c3212c44ca9745d56f5f38a5734447dd247832f604
# source-tree-digest: sha256:9bc634940f75b183e5a902de5fc5f3c4afdd9f2e8de02ef2a459ae4527841d55
# input-digest: sha256:bf64ffc42fdab777b0519f2930a4d513e2adfe324bc9a27011c7140c0511261c
# output-digest: sha256:8c045e745cc69b731bc695a4a9d58a48c10f1ab7dd85b7354db7bfd0e072711c
# edit-source:
# - .gds/repository.yaml
Expand Down
73 changes: 73 additions & 0 deletions core/app/github_readonly.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/NDDev-OpenNetwork/github-device-sync/core/compiler"
"github.com/NDDev-OpenNetwork/github-device-sync/core/domain"
"github.com/NDDev-OpenNetwork/github-device-sync/core/estate"
"github.com/NDDev-OpenNetwork/github-device-sync/core/githubcoverage"
"github.com/NDDev-OpenNetwork/github-device-sync/core/githubruntime"
"github.com/NDDev-OpenNetwork/github-device-sync/core/governance"
githubprovider "github.com/NDDev-OpenNetwork/github-device-sync/core/providers/github"
Expand Down Expand Up @@ -47,6 +48,21 @@ type ReconciliationPlanData struct {
Result reconciler.Result `json:"result"`
}

type GitHubCoverageOptions struct {
GitHubReadOptions
IncludeLocal bool
LocalRoot string
LocalMaxDepth int
LocalMaxRepositories int
LocalConcurrency int
IncludeArchived bool
}

type GitHubCoverageData struct {
Coverage githubcoverage.Report `json:"coverage"`
Result reconciler.Result `json:"result"`
}

type githubRuntime struct {
desired estate.Config
config githubruntime.Config
Expand Down Expand Up @@ -218,6 +234,63 @@ func (services *Services) ReconcileGitHub(
return envelopeValue
}

func (services *Services) GitHubCoverage(
ctx context.Context,
path string,
options GitHubCoverageOptions,
) domain.Envelope {
const command = "gds github coverage"
runtime, envelope := services.loadGitHubRuntime(ctx, path, options.GitHubReadOptions, command)
if envelope != nil {
return *envelope
}
readers := make(map[string]reconciler.InstallationReader, len(runtime.readers))
for id, reader := range runtime.readers {
readers[id] = reader
}
result := (reconciler.Reconciler{
Config: runtime.desired, Readers: readers,
Concurrency: runtime.desired.Root.Rollout.MaxParallelObservation,
MaxRepositories: runtime.maxRepositories,
}).ReconcileAll(ctx)
findings := append([]domain.Finding(nil), result.Findings...)
var identities []estate.IdentityRepository
localCollected := false
if options.IncludeLocal {
local := DiscoveryOptions{
Root: options.LocalRoot, MaxDepth: options.LocalMaxDepth,
MaxRepositories: options.LocalMaxRepositories, Concurrency: options.LocalConcurrency,
IncludeArchived: options.IncludeArchived,
}
if local.Root == "" {
local.Root = path
}
if local.MaxDepth == 0 {
local.MaxDepth = 8
}
if local.MaxRepositories == 0 {
local.MaxRepositories = 2000
}
if local.Concurrency == 0 {
local.Concurrency = 4
}
var localFindings []domain.Finding
identities, localFindings = services.coverageIdentities(ctx, local)
localCollected = true
findings = append(findings, localFindings...)
}
coverage := githubcoverage.Evaluate(runtime.desired, result, identities, localCollected)
class := classifyFindings(findings)
if class == domain.ExitSuccess && len(result.Findings) != 0 {
class = domain.ExitNotProven
}
envelopeValue := domain.NewEnvelope(command, class, GitHubCoverageData{
Coverage: coverage, Result: result,
}, findings...)
envelopeValue.Scope["estate_id"] = runtime.desired.Root.Estate.ID
return envelopeValue
}

func (services *Services) loadGitHubRuntime(
ctx context.Context,
path string,
Expand Down
9 changes: 9 additions & 0 deletions core/app/github_readonly_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ func TestGitHubInventoryAndReconciliationUseLiveReadOnlyRuntime(t *testing.T) {
summaryData.DriftByClass["identity"] != 5 {
t.Fatalf("summary=%#v", summary)
}
coverage := services.GitHubCoverage(context.Background(), root, GitHubCoverageOptions{
GitHubReadOptions: GitHubReadOptions{RuntimeConfig: runtimePath},
})
coverageData, ok := coverage.Data.(GitHubCoverageData)
if coverage.ExitClass != domain.ExitSuccess || !ok || coverage.Mutation.Attempted ||
coverageData.Coverage.Counts["partial"] != 5 ||
coverageData.Coverage.LocalIdentitiesCollected {
t.Fatalf("coverage=%#v", coverage)
}
}

func TestGitHubInventoryRejectsWrongInstallationOwner(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion core/app/module_process_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ const moduleTerminationWait = 2 * time.Second

// Own a new group, never the caller's group. Cancel and normal-exit cleanup
// share one synchronous operation, so no delayed signal goroutine outlives the
// command or its verification workspace.
// command or its verification workspace. Descendants that call setsid, or a
// daemon such as Docker started by the command, are a different process group
// and are not reaped here.
func configureModuleProcess(command *exec.Cmd) (func() (bool, error), error) {
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
var once sync.Once
Expand Down
34 changes: 34 additions & 0 deletions core/app/module_process_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,40 @@ func TestDeclaredSuccessCannotLeaveBackgroundWriter(t *testing.T) {
assertTestChildStopped(t, pid)
}

func TestDeclaredTimeoutDoesNotClaimSetsidChildren(t *testing.T) {
// Process-group cleanup is not ownership of setsid/Docker-daemon children.
// Darwin images have no util-linux `setsid(1)`; python3.os.setsid is the
// same syscall on linux and darwin.
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 is required to create a new session without util-linux")
}
dir := t.TempDir()
child := "import os, signal, time\n" +
"os.setsid()\n" +
"open('child.pid', 'w', encoding='ascii').write(str(os.getpid()))\n" +
"signal.signal(signal.SIGTERM, signal.SIG_IGN)\n" +
"while True:\n" +
" time.sleep(0.05)\n"
if err := os.WriteFile(filepath.Join(dir, "setsid_child.py"), []byte(child), 0o600); err != nil {
t.Fatal(err)
}
report := runDeclaredCommand(
context.Background(),
dir,
"python3 setsid_child.py & wait",
350*time.Millisecond,
)
pid := readOwnedTestChild(t, dir)
defer stopOwnedTestChild(pid)
if report.Status == "passed" {
t.Fatalf("setsid child made the parent look finished: %#v", report)
}
b, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil || strings.TrimSpace(string(b)) == "" || strings.HasPrefix(strings.TrimSpace(string(b)), "Z") {
t.Fatalf("setsid child %d did not remain outside the module process group: err=%v stat=%q", pid, err, b)
}
}

func readOwnedTestChild(t *testing.T, dir string) int {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
Expand Down
35 changes: 35 additions & 0 deletions core/app/relationship_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,38 @@ func (services *Services) completeRelationshipIndex(
findings = append(findings, indexFindings...)
return index, findings
}

func (services *Services) coverageIdentities(
ctx context.Context,
options DiscoveryOptions,
) ([]estate.IdentityRepository, []domain.Finding) {
if finding := validateDiscoveryOptions(options); finding != nil {
return nil, []domain.Finding{*finding}
}
discovered, err := services.Discovery.Discover(ctx, options.Root, discovery.Options{
MaxDepth: options.MaxDepth, MaxRepositories: options.MaxRepositories,
Concurrency: options.Concurrency, IncludeArchived: options.IncludeArchived,
})
if err != nil {
return nil, []domain.Finding{{
Code: "GDS_IDENTITY_INDEX_DISCOVERY_FAILED", Severity: domain.SeverityHigh,
Message: err.Error(), Evidence: map[string]any{"root": options.Root},
}}
}
indexed := make([]estate.IndexedRepository, 0, len(discovered.Boundaries))
findings := append([]domain.Finding(nil), discovered.Findings...)
loader := manifest.NewLoader(services.Schemas)
for _, boundary := range discovered.Boundaries {
if boundary.AnchorState != "valid" {
continue
}
anchorValue, anchorFindings := loader.LoadRepository(boundary.Path)
findings = append(findings, anchorFindings...)
if len(anchorFindings) == 0 {
indexed = append(indexed, estate.IndexedRepository{Path: boundary.Path, Anchor: anchorValue})
}
}
index, indexFindings := estate.BuildIdentityIndex(indexed, false)
findings = append(findings, indexFindings...)
return index.Repositories, findings
}
19 changes: 19 additions & 0 deletions core/cli/github_readonly_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ func TestGitHubInventoryRequiresRuntimeEvidenceWithoutAttemptingMutation(t *test
assertEnvelopeSchema(t, envelope)
}

func TestGitHubCoverageRequiresRuntimeEvidenceWithoutAttemptingMutation(t *testing.T) {
root := repositoryRoot(t)
missing := filepath.Join(t.TempDir(), "github-runtime.yaml")
exitCode, envelope, stderr := executeJSON(
t,
"--json", "--cwd", root,
"github", "coverage",
"--runtime-config", missing,
)
if exitCode != 3 || envelope.ExitClass != domain.ExitNotProven || stderr != "" {
t.Fatalf("exit=%d stderr=%q envelope=%#v", exitCode, stderr, envelope)
}
if !containsFinding(envelope.Findings, "GDS_GITHUB_RUNTIME_NOT_PROVEN") ||
envelope.Mutation.Attempted || envelope.Mutation.Completed {
t.Fatalf("envelope=%#v", envelope)
}
assertEnvelopeSchema(t, envelope)
}

func TestGitHubGovernanceRequiresExactRepositoryScopeBeforeRuntimeAccess(t *testing.T) {
root := repositoryRoot(t)
exitCode, envelope, stderr := executeJSON(
Expand Down
28 changes: 28 additions & 0 deletions core/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2488,6 +2488,34 @@ func (executor *executor) githubCommand() *cobra.Command {
&options.InstallationID, "installation", "", "exact logical estate installation id",
)
command.AddCommand(inventory)
coverageOptions := app.GitHubCoverageOptions{}
coverage := &cobra.Command{
Use: "coverage",
Short: "Classify App inventories against local GDS identities by GitHub repository ID",
Args: cobra.NoArgs,
RunE: func(child *cobra.Command, _ []string) error {
return executor.run(child, func(ctx context.Context) domain.Envelope {
return executor.services.GitHubCoverage(ctx, executor.options.cwd, coverageOptions)
})
},
}
addGitHubReadFlags(coverage, &coverageOptions.GitHubReadOptions)
coverage.Flags().BoolVar(
&coverageOptions.IncludeLocal, "include-local", false,
"union device-local GDS identities with App inventories by immutable GitHub repository ID",
)
coverage.Flags().StringVar(&coverageOptions.LocalRoot, "root", "", "filesystem root for local identity discovery")
coverage.Flags().IntVar(&coverageOptions.LocalMaxDepth, "max-depth", 8, "maximum directory depth for local identity discovery")
coverage.Flags().IntVar(
&coverageOptions.LocalMaxRepositories, "local-max-repositories", 2000,
"hard local identity count limit",
)
coverage.Flags().IntVar(&coverageOptions.LocalConcurrency, "concurrency", 4, "bounded Git inspection workers")
coverage.Flags().BoolVar(
&coverageOptions.IncludeArchived, "include-archived", false,
"also index local repositories whose anchor declares lifecycle: archived",
)
command.AddCommand(coverage)
governanceOptions := app.GitHubGovernanceOperationOptions{}
governancePlan := false
governanceApply := ""
Expand Down
5 changes: 3 additions & 2 deletions core/estate/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ func Compile(
seenProviderIDs[repository.ProviderID] = struct{}{}
assignment := Assignment{
ProviderID: repository.ProviderID, Owner: repository.Owner, Name: repository.Name,
IdentityState: "unassigned", ManagementMode: config.Root.Discovery.DefaultManagementMode,
RolloutRing: config.Root.Rollout.DefaultRing,
Archived: repository.Archived, IdentityState: "unassigned",
ManagementMode: config.Root.Discovery.DefaultManagementMode,
RolloutRing: config.Root.Rollout.DefaultRing,
}
owner, found := ownerByLogin[strings.ToLower(repository.Owner)]
if !found {
Expand Down
12 changes: 12 additions & 0 deletions core/estate/compiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,18 @@ func estateHasFinding(findings []domain.Finding, code string) bool {
return false
}

func TestCompilePreservesArchivedObservation(t *testing.T) {
t.Parallel()
config := loadCanonical(t)
compiled, findings := Compile(config, []ObservedRepository{{
ProviderID: 42, Owner: "example-user", Name: "retired",
Archived: true, Visibility: "private", DefaultBranch: "main",
}})
if len(findings) != 0 || len(compiled.Repositories) != 1 || !compiled.Repositories[0].Archived {
t.Fatalf("compiled=%#v findings=%#v", compiled, findings)
}
}

func organizationForksSelector(t *testing.T, config Config) Selector {
t.Helper()
for _, selector := range config.Selectors {
Expand Down
1 change: 1 addition & 0 deletions core/estate/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ type Assignment struct {
ProviderID int64 `json:"provider_id"`
Owner string `json:"owner"`
Name string `json:"name"`
Archived bool `json:"archived"`
OwnerID string `json:"owner_id,omitempty"`
InstallationID string `json:"installation_id,omitempty"`
IdentityState string `json:"identity_state"`
Expand Down
Loading