From a2d225ffcacef72b2066b96b57e1759000541716 Mon Sep 17 00:00:00 2001 From: Greg Allen Date: Sun, 5 Jul 2026 19:54:18 -0400 Subject: [PATCH] refactor(repos): extract per-repo install logic into internal/repos package Signed-off-by: Claude Signed-off-by: Greg Allen --- docs/guides/dev/cli-internals.md | 4 +- docs/plans/repos-management.md | 6 +- internal/cli/admin.go | 295 +++++++--- internal/cli/admin_test.go | 336 ++++++++++++ internal/cli/github.go | 5 +- internal/dispatch/gcf/provisioner.go | 14 +- internal/maputil/maputil.go | 13 + internal/maputil/maputil_test.go | 21 + internal/repos/install.go | 322 +++++++++++ internal/repos/install_test.go | 771 +++++++++++++++++++++++++++ 10 files changed, 1685 insertions(+), 102 deletions(-) create mode 100644 internal/maputil/maputil.go create mode 100644 internal/maputil/maputil_test.go create mode 100644 internal/repos/install.go create mode 100644 internal/repos/install_test.go diff --git a/docs/guides/dev/cli-internals.md b/docs/guides/dev/cli-internals.md index 686ffc586..79c04618e 100644 --- a/docs/guides/dev/cli-internals.md +++ b/docs/guides/dev/cli-internals.md @@ -243,7 +243,7 @@ Both modes call the same functions (`runAppSetup`, `gcf.NewProvisioner`, `Provis | **2. App setup** | `runAppSetup()` → PEMs + App IDs | All 7 roles by default | Excludes "fullsend" role | | **3. Mint** | `gcf.Provision()` or `EnsureOrgInMint()` | — | + `RegisterPerRepoWIF()` | | **4. WIF** | `ProvisionWIF()` | Org-wide provider ID | `mintcore.BuildRepoProviderID()` (repo-scoped) | -| **5. Scaffold** | `scaffold.PerRepoCustomizedDirs()` / `WalkFullsendRepo()` | Creates `.fullsend` repo, pushes workflows + optional binary | Writes `.fullsend/` dir + shim workflow + optional binary in target repo | +| **5. Scaffold** | `repos.BuildScaffoldFiles()` (via `scaffold.CollectPerRepoInstallFiles()`) | Creates `.fullsend` repo, pushes workflows + optional binary | Writes `.fullsend/` dir + shim workflow + optional binary in target repo | | **6. Secrets** | Same secret names, same API calls | Config repo + org variable | Target repo + `PER_REPO_GUARD` | | **7. Enrollment** | — | `EnrollmentLayer` enables repos | No-op (self-contained) | @@ -267,7 +267,7 @@ Install: process 1→8 (forward) Uninstall: process 8→1 (reverse) ``` -Per-repo mode does not use the layer stack — it runs the same phases inline in `runPerRepoInstall()` and `runGitHubSetupPerRepo()` since there's no need for composable uninstall ordering with a single repo. Vendoring (when `--vendor` is set) and stale asset cleanup are handled inline or via shared helpers; per-org mode uses `VendorBinaryLayer`. +Per-repo mode does not use the layer stack — `runPerRepoInstall()` delegates to `repos.Install()` (from `internal/repos`) for the core install logic (guard check, WIF provisioning, scaffold commit, variable/secret writes), while `runGitHubSetupPerRepo()` handles GitHub-specific setup. There's no need for composable uninstall ordering with a single repo. Vendoring (when `--vendor` is set) and stale asset cleanup are handled inline or via shared helpers; per-org mode uses `VendorBinaryLayer`. ### Binary acquisition (`internal/binary`) diff --git a/docs/plans/repos-management.md b/docs/plans/repos-management.md index bc753e84a..1f46eb7ca 100644 --- a/docs/plans/repos-management.md +++ b/docs/plans/repos-management.md @@ -336,9 +336,11 @@ in parallel with PRs 4–8. ## Phase 1: Foundation -### PR 1: Extract per-repo install logic into reusable package +### PR 1: Extract per-repo install logic into reusable package ✓ -**Scope:** Refactor only. Zero behavioral change. +**Status:** Implemented in [#3003](https://github.com/fullsend-ai/fullsend/pull/3003). + +**Scope:** Refactor only. Preserves install semantics. The existing `runPerRepoInstall()` in `internal/cli/admin.go` is ~450 lines mixing install logic with CLI concerns (interactive prompts, diff --git a/internal/cli/admin.go b/internal/cli/admin.go index ccaf9ea18..65d2fd1fe 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -28,8 +28,9 @@ import ( "github.com/fullsend-ai/fullsend/internal/inference" "github.com/fullsend-ai/fullsend/internal/inference/vertex" "github.com/fullsend-ai/fullsend/internal/layers" + "github.com/fullsend-ai/fullsend/internal/maputil" "github.com/fullsend-ai/fullsend/internal/mintcore" - "github.com/fullsend-ai/fullsend/internal/scaffold" + "github.com/fullsend-ai/fullsend/internal/repos" "github.com/fullsend-ai/fullsend/internal/ui" ) @@ -156,17 +157,16 @@ type perRepoInstallConfig struct { FullsendBinary string FullsendSource string Direct bool -} -// wifProviderPattern validates the full WIF provider resource name format -// required by google-github-actions/auth@v3. -// GCP pool/provider IDs: 4-32 chars, [a-z0-9-], start with letter, no trailing hyphen. -var wifProviderPattern = regexp.MustCompile( - `^projects/\d+/locations/global/workloadIdentityPools/[a-z][a-z0-9-]{2,30}[a-z0-9]/providers/[a-z][a-z0-9-]{2,30}[a-z0-9]$`, -) + // Testing overrides — when non-nil, used instead of resolving from + // the environment. Not set by CLI flag parsing. + testClient forge.Client + testPrinter *ui.Printer + testWIFProvisioner repos.WIFProvisioner +} func validateWIFProvider(raw string) error { - if !wifProviderPattern.MatchString(raw) { + if !repos.WIFProviderPattern.MatchString(raw) { return fmt.Errorf( "--inference-wif-provider must be a full WIF provider resource name "+ "(projects/{number}/locations/global/workloadIdentityPools/{pool}/providers/{id}), got %q", @@ -638,14 +638,23 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { return err } - token, err := resolveToken() - if err != nil { - return err + var client forge.Client + var printer *ui.Printer + if c.testClient != nil { + client = c.testClient + if c.testPrinter == nil { + return fmt.Errorf("testPrinter must be set when testClient is set") + } + printer = c.testPrinter + } else { + token, tokenErr := resolveToken() + if tokenErr != nil { + return tokenErr + } + client = gh.New(token) + printer = ui.New(os.Stdout) } - client := gh.New(token) - printer := ui.New(os.Stdout) - printer.Banner(Version()) printer.Blank() printer.Header("Installing per-repo fullsend for " + repoFullName) @@ -655,35 +664,7 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { printer.StepWarn("Using provided WIF provider value — skipping inference provider auto-provisioning") } - cfg := config.NewPerRepoConfig(roles, repoFullName) - if err := cfg.Validate(); err != nil { - return fmt.Errorf("invalid config: %w", err) - } - - cfgYAML, err := cfg.Marshal() - if err != nil { - return fmt.Errorf("marshaling per-repo config: %w", err) - } - upstreamRef, upstreamTag := resolveUpstreamRef() - installFiles, err := scaffold.CollectPerRepoInstallFiles(vendor, upstreamRef, upstreamTag) - if err != nil { - return fmt.Errorf("collecting per-repo scaffold files: %w", err) - } - - var files []forge.TreeFile - for _, f := range installFiles { - files = append(files, forge.TreeFile{ - Path: f.Path, - Content: f.Content, - Mode: f.Mode, - }) - } - files = append(files, forge.TreeFile{ - Path: ".fullsend/config.yaml", - Content: cfgYAML, - Mode: "100644", - }) needsWIFProvision := inferenceWIFProvider == "" @@ -823,7 +804,27 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { printer.StepInfo(fmt.Sprintf(" Repo restriction: %s/%s", owner, repo)) printer.Blank() } - for _, f := range files { + // BuildScaffoldFiles only reads Owner, Repo, Roles, VendorBinary, + // UpstreamRef, UpstreamTag. Extra fields are included to stay aligned + // with the non-dry-run installCfg; Skip* flags are omitted because + // they control Install() flow, not scaffold file generation. + dryRunFiles, dryRunErr := repos.BuildScaffoldFiles(repos.InstallConfig{ + Owner: owner, + Repo: repo, + Roles: roles, + MintURL: mintDisplay, + InferenceProject: inferenceProject, + InferenceRegion: inferenceRegion, + UpstreamRef: upstreamRef, + UpstreamTag: upstreamTag, + WIFProvider: inferenceWIFProvider, + VendorBinary: vendor, + Direct: c.Direct, + }) + if dryRunErr != nil { + return fmt.Errorf("generating scaffold files for dry run: %w", dryRunErr) + } + for _, f := range dryRunFiles { printer.StepDone(fmt.Sprintf("Would write: %s (%d bytes)", f.Path, len(f.Content))) } printer.Blank() @@ -833,7 +834,7 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { "FULLSEND_GCP_REGION": inferenceRegion, forge.PerRepoGuardVar: "true", } - for _, name := range sortedStringMapKeys(dryRunVars) { + for _, name := range maputil.SortedKeys(dryRunVars) { printer.StepInfo(fmt.Sprintf(" %s = %s", name, dryRunVars[name])) } secretNames := []string{"FULLSEND_GCP_PROJECT_ID", "FULLSEND_GCP_WIF_PROVIDER"} @@ -971,46 +972,127 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { printer.StepDone(mintValidationMessage(trafficEnv, envErr)) } - if needsWIFProvision { - printer.StepStart("Provisioning WIF infrastructure") - provisioner := gcf.NewProvisioner(gcf.Config{ - ProjectID: inferenceProject, - GitHubOrgs: []string{owner}, - Repo: owner + "/" + repo, - WIFPoolName: gcf.DefaultInferencePool, - }, gcf.NewLiveGCFClient(inferenceProject)) - var provErr error - inferenceWIFProvider, provErr = provisioner.ProvisionWIF(ctx) - if provErr != nil { - printer.StepFail("WIF provisioning failed") - return fmt.Errorf("provisioning WIF: %w", provErr) + // Delegate WIF provisioning, scaffold commit, and variable/secret + // writes to the reusable repos.Install function. This enables the + // future `fullsend repos install` command to share the same logic. + var wifProvisioner repos.WIFProvisioner + if c.testWIFProvisioner != nil { + wifProvisioner = c.testWIFProvisioner + } else if needsWIFProvision { + wifProvisioner = &gcfProvisionerAdapter{ + provisioner: gcf.NewProvisioner(gcf.Config{ + ProjectID: inferenceProject, + GitHubOrgs: []string{owner}, + Repo: owner + "/" + repo, + WIFPoolName: gcf.DefaultInferencePool, + }, gcf.NewLiveGCFClient(inferenceProject)), + } + } + + // Scaffold commit function wrapping layers.CommitScaffoldFiles, which + // provides retry on non-fast-forward errors, branch-protection fallback + // to PR delivery, and fork-based PR support for non-owner users. + scaffoldCommitFn := func(ctx context.Context, owner, repo string, files []forge.TreeFile, direct bool) error { + targetRepo, repoErr := client.GetRepo(ctx, owner, repo) + if repoErr != nil { + return fmt.Errorf("getting repo info: %w", repoErr) + } + commitMsg := fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version) + prTitle := "chore: initialize fullsend per-repo installation" + prBody := "This PR adds the fullsend scaffold files for per-repo installation.\n\n" + + "Merge this PR to activate fullsend workflows." + if direct { + printer.StepStart(fmt.Sprintf("Committing scaffold files to %s/%s (%s branch)", + owner, repo, targetRepo.DefaultBranch)) + } else { + printer.StepStart(fmt.Sprintf("Creating scaffold PR for %s/%s (target: %s)", + owner, repo, targetRepo.DefaultBranch)) + } + _, err := layers.CommitScaffoldFiles(ctx, client, printer, owner, repo, + targetRepo.DefaultBranch, commitMsg, prTitle, prBody, files, direct, os.Stdin) + return err + } + + installCfg := repos.InstallConfig{ + Owner: owner, + Repo: repo, + Roles: roles, + MintURL: mintURL, + InferenceProject: inferenceProject, + InferenceRegion: inferenceRegion, + UpstreamRef: upstreamRef, + UpstreamTag: upstreamTag, + SkipMintCheck: true, // already handled above + SkipAppSetup: true, // already handled above + SkipGuardCheck: true, // admin.go handles guard check itself + SkipWIF: !needsWIFProvision, + WIFProvider: inferenceWIFProvider, + VendorBinary: vendor, + Direct: c.Direct, + SkipScaffoldAndConfig: vendor, // vendor path commits scaffold+vendor atomically below + } + + progressFn := func(_ string, phase, msg string) { + switch phase { + case "wif": + if strings.Contains(msg, "Provisioning") { + printer.StepStart(msg) + } else if strings.Contains(msg, "ready") { + printer.StepDone(msg) + printer.StepInfo("IAM policy changes may take up to 7 minutes to propagate") + printer.StepInfo("Agent workflows that authenticate via WIF may fail until propagation completes") + } + case "scaffold": + if strings.Contains(msg, "Committing") || strings.Contains(msg, "Generating") { + printer.StepStart(msg) + } else { + printer.StepDone(msg) + } + case "vars": + if strings.Contains(msg, "Configuring") { + printer.StepStart(msg) + } else { + printer.StepDone(msg) + } + case "secrets": + if strings.Contains(msg, "Configuring") { + printer.StepStart(msg) + } else { + printer.StepDone(msg) + } } - printer.StepDone("WIF infrastructure ready") - printer.StepInfo("IAM policy changes may take up to 7 minutes to propagate") - printer.StepInfo("Agent workflows that authenticate via WIF may fail until propagation completes") } - repoVars := map[string]string{ - "FULLSEND_MINT_URL": mintURL, - "FULLSEND_GCP_REGION": inferenceRegion, - forge.PerRepoGuardVar: "true", + installResult, installErr := repos.Install(ctx, installCfg, client, wifProvisioner, scaffoldCommitFn, progressFn) + if installErr != nil { + return installErr } - repoSecrets := map[string]string{ - "FULLSEND_GCP_PROJECT_ID": inferenceProject, - "FULLSEND_GCP_WIF_PROVIDER": inferenceWIFProvider, + if installResult.WIFProvider != "" { + inferenceWIFProvider = installResult.WIFProvider } if vendor { - var vendorErr error - files, _, vendorErr = appendVendorTreeFiles(printer, owner, repo, files, vendor, fullsendBinary, fullsendSource) + scaffoldFiles, buildErr := repos.BuildScaffoldFiles(installCfg) + if buildErr != nil { + return fmt.Errorf("building scaffold files for vendor: %w", buildErr) + } + vendorFiles, _, vendorErr := appendVendorTreeFiles(printer, owner, repo, scaffoldFiles, vendor, fullsendBinary, fullsendSource) if vendorErr != nil { return fmt.Errorf("collecting vendored assets: %w", vendorErr) } - } - - if err := applyPerRepoScaffold(ctx, client, printer, owner, repo, files, repoVars, repoSecrets, c.Direct); err != nil { - return err + repoVars := map[string]string{ + "FULLSEND_MINT_URL": mintURL, + "FULLSEND_GCP_REGION": inferenceRegion, + forge.PerRepoGuardVar: "true", + } + repoSecrets := map[string]string{ + "FULLSEND_GCP_PROJECT_ID": inferenceProject, + "FULLSEND_GCP_WIF_PROVIDER": inferenceWIFProvider, + } + if err := applyPerRepoScaffold(ctx, client, printer, owner, repo, vendorFiles, repoVars, repoSecrets, c.Direct); err != nil { + return err + } } if !vendor { @@ -1024,6 +1106,58 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { return nil } +// gcfProvisionerAdapter wraps a gcf.Provisioner to implement repos.WIFProvisioner, +// bridging the GCF-specific provisioner to the package-agnostic interface. +type gcfProvisionerAdapter struct { + provisioner *gcf.Provisioner +} + +func (a *gcfProvisionerAdapter) DiscoverMint(ctx context.Context) (*repos.MintDiscovery, error) { + if a.provisioner == nil { + return nil, repos.ErrMintNotFound + } + d, err := a.provisioner.DiscoverMint(ctx) + if err != nil { + if errors.Is(err, gcf.ErrFunctionNotFound) { + return nil, fmt.Errorf("%w: %w", repos.ErrMintNotFound, err) + } + return nil, err + } + return &repos.MintDiscovery{ + URL: d.URL, + RoleAppIDs: d.RoleAppIDs, + PerRepoWIFRepos: d.PerRepoWIFRepos, + }, nil +} + +func (a *gcfProvisionerAdapter) ProvisionWIF(ctx context.Context) (string, error) { + if a.provisioner == nil { + return "", fmt.Errorf("WIF provisioner not configured") + } + return a.provisioner.ProvisionWIF(ctx) +} + +func (a *gcfProvisionerAdapter) RegisterPerRepoWIF(ctx context.Context, repo string) error { + if a.provisioner == nil { + return fmt.Errorf("WIF provisioner not configured") + } + return a.provisioner.RegisterPerRepoWIF(ctx, repo) +} + +func (a *gcfProvisionerAdapter) EnsureOrgInMint(ctx context.Context, expectedURL string, org string) error { + if a.provisioner == nil { + return fmt.Errorf("WIF provisioner not configured") + } + return a.provisioner.EnsureOrgInMint(ctx, expectedURL, org) +} + +func (a *gcfProvisionerAdapter) DeletePerRepoWIF(ctx context.Context, repo string) error { + if a.provisioner == nil { + return fmt.Errorf("WIF provisioner not configured") + } + return a.provisioner.RemoveRepoFromMint(ctx, repo) +} + // applyPerRepoScaffold commits scaffold files to the repo's default branch // and configures the repository variables and secrets needed for fullsend. func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui.Printer, @@ -1051,7 +1185,7 @@ func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui. } printer.StepStart("Configuring repository variables") - for _, name := range sortedStringMapKeys(repoVars) { + for _, name := range maputil.SortedKeys(repoVars) { if err := client.CreateOrUpdateRepoVariable(ctx, owner, repo, name, repoVars[name]); err != nil { printer.StepFail(fmt.Sprintf("Failed to set variable %s", name)) return fmt.Errorf("setting repo variable %s: %w", name, err) @@ -1060,7 +1194,7 @@ func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui. printer.StepDone(fmt.Sprintf("Set %d repository variables", len(repoVars))) printer.StepStart("Configuring repository secrets") - for _, name := range sortedStringMapKeys(repoSecrets) { + for _, name := range maputil.SortedKeys(repoSecrets) { if err := client.CreateRepoSecret(ctx, owner, repo, name, repoSecrets[name]); err != nil { printer.StepFail(fmt.Sprintf("Failed to set secret %s", name)) return fmt.Errorf("setting repo secret %s: %w", name, err) @@ -2778,15 +2912,6 @@ func checkTokenScopes(ctx context.Context, client forge.Client, printer *ui.Prin // Helper functions. -func sortedStringMapKeys(m map[string]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - func repoNameList(repos []forge.Repository) []string { names := make([]string, len(repos)) for i, r := range repos { diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 5bf5f478f..e6c4296b2 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -18,6 +18,7 @@ import ( "github.com/fullsend-ai/fullsend/internal/dispatch/gcf" "github.com/fullsend-ai/fullsend/internal/forge" "github.com/fullsend-ai/fullsend/internal/layers" + "github.com/fullsend-ai/fullsend/internal/repos" "github.com/fullsend-ai/fullsend/internal/ui" ) @@ -2063,6 +2064,175 @@ func TestRunPerRepoInstall_ValidationErrors(t *testing.T) { } } +type testWIFProvisioner struct { + wifProvider string + wifErr error + discoverResult *repos.MintDiscovery + discoverErr error +} + +func (p *testWIFProvisioner) DiscoverMint(_ context.Context) (*repos.MintDiscovery, error) { + return p.discoverResult, p.discoverErr +} + +func (p *testWIFProvisioner) ProvisionWIF(_ context.Context) (string, error) { + return p.wifProvider, p.wifErr +} + +func (p *testWIFProvisioner) RegisterPerRepoWIF(_ context.Context, _ string) error { return nil } +func (p *testWIFProvisioner) EnsureOrgInMint(_ context.Context, _, _ string) error { return nil } +func (p *testWIFProvisioner) DeletePerRepoWIF(_ context.Context, _ string) error { return nil } + +func perRepoTestBase() perRepoInstallConfig { + return perRepoInstallConfig{ + RepoFullName: "acme/widget", + Agents: strings.Join(config.PerRepoDefaultRoles(), ","), + InferenceProject: "test-project", + InferenceRegion: "us-central1", + MintURL: "https://mint.example.com/v1/token", + SkipMintCheck: true, + SkipAppSetup: true, + InferenceWIFProvider: "projects/123456789/locations/global/workloadIdentityPools/fullsend-pool/providers/fullsend-provider", + testPrinter: ui.New(&bytes.Buffer{}), + } +} + +func TestRunPerRepoInstall_SuccessfulNonVendor(t *testing.T) { + client := forge.NewFakeClient() + client.Repos = []forge.Repository{ + {Name: "widget", FullName: "acme/widget", DefaultBranch: "main"}, + } + + cfg := perRepoTestBase() + cfg.testClient = client + cfg.Direct = true + + err := runPerRepoInstall(context.Background(), cfg) + require.NoError(t, err) + + var foundGuard bool + for _, v := range client.Variables { + if v.Name == forge.PerRepoGuardVar && v.Value == "true" { + foundGuard = true + } + } + assert.True(t, foundGuard, "expected guard variable to be set") +} + +func TestRunPerRepoInstall_WithWIFProvisioning(t *testing.T) { + client := forge.NewFakeClient() + client.Repos = []forge.Repository{ + {Name: "widget", FullName: "acme/widget", DefaultBranch: "main"}, + } + + cfg := perRepoTestBase() + cfg.testClient = client + cfg.InferenceWIFProvider = "" + cfg.Direct = true + cfg.testWIFProvisioner = &testWIFProvisioner{ + wifProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/prov", + } + + err := runPerRepoInstall(context.Background(), cfg) + require.NoError(t, err) +} + +func TestRunPerRepoInstall_WIFProvisioningError(t *testing.T) { + client := forge.NewFakeClient() + client.Repos = []forge.Repository{ + {Name: "widget", FullName: "acme/widget", DefaultBranch: "main"}, + } + + cfg := perRepoTestBase() + cfg.testClient = client + cfg.InferenceWIFProvider = "" + cfg.Direct = true + cfg.testWIFProvisioner = &testWIFProvisioner{ + wifErr: fmt.Errorf("IAM permission denied"), + } + + err := runPerRepoInstall(context.Background(), cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "provisioning WIF") +} + +func TestRunPerRepoInstall_GuardAlreadyInstalled(t *testing.T) { + client := forge.NewFakeClient() + client.VariableValues = map[string]string{ + "acme/widget/" + forge.PerRepoGuardVar: "true", + } + client.Repos = []forge.Repository{ + {Name: "widget", FullName: "acme/widget", DefaultBranch: "main"}, + } + + cfg := perRepoTestBase() + cfg.testClient = client + cfg.Direct = true + + err := runPerRepoInstall(context.Background(), cfg) + require.NoError(t, err) +} + +func TestRunPerRepoInstall_DryRun(t *testing.T) { + client := forge.NewFakeClient() + + cfg := perRepoTestBase() + cfg.testClient = client + cfg.DryRun = true + + err := runPerRepoInstall(context.Background(), cfg) + require.NoError(t, err) +} + +func TestRunPerRepoInstall_ScaffoldCommitError(t *testing.T) { + client := forge.NewFakeClient() + client.Errors = map[string]error{ + "GetRepo": fmt.Errorf("repo not accessible"), + } + + cfg := perRepoTestBase() + cfg.testClient = client + cfg.Direct = true + + err := runPerRepoInstall(context.Background(), cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "getting repo info") +} + +func TestRunPerRepoInstall_GuardValueFalse(t *testing.T) { + client := forge.NewFakeClient() + client.VariableValues = map[string]string{ + "acme/widget/" + forge.PerRepoGuardVar: "false", + } + client.Repos = []forge.Repository{ + {Name: "widget", FullName: "acme/widget", DefaultBranch: "main"}, + } + + cfg := perRepoTestBase() + cfg.testClient = client + cfg.Direct = true + + err := runPerRepoInstall(context.Background(), cfg) + require.NoError(t, err) +} + +func TestRunPerRepoInstall_GuardValueUnexpected(t *testing.T) { + client := forge.NewFakeClient() + client.VariableValues = map[string]string{ + "acme/widget/" + forge.PerRepoGuardVar: "maybe", + } + client.Repos = []forge.Repository{ + {Name: "widget", FullName: "acme/widget", DefaultBranch: "main"}, + } + + cfg := perRepoTestBase() + cfg.testClient = client + cfg.Direct = true + + err := runPerRepoInstall(context.Background(), cfg) + require.NoError(t, err) +} + func TestFilterSlugsByAppSet(t *testing.T) { tests := []struct { name string @@ -2896,6 +3066,172 @@ func TestApplyPerRepoScaffold_ProtectedBranch_BranchUpToDate(t *testing.T) { assert.Contains(t, buf.String(), "up to date") } +func TestGCFWIFAdapter_DiscoverMint_Success(t *testing.T) { + fakeClient := gcf.NewFakeGCFClient(gcf.WithFakeFunctionInfo(&gcf.FunctionInfo{ + URI: "https://mint.example.run.app", + EnvVars: map[string]string{ + "ROLE_APP_IDS": `{"coder":"100","triage":"200"}`, + "PER_REPO_WIF_REPOS": "acme/widget,acme/api", + }, + })) + prov := gcf.NewProvisioner(gcf.Config{ + ProjectID: "test-project", + GitHubOrgs: []string{"acme"}, + Repo: "acme/widget", + }, fakeClient) + adapter := &gcfProvisionerAdapter{provisioner: prov} + + d, err := adapter.DiscoverMint(context.Background()) + require.NoError(t, err) + assert.Equal(t, "https://mint.example.run.app", d.URL) + assert.Equal(t, "100", d.RoleAppIDs["coder"]) + assert.Equal(t, "200", d.RoleAppIDs["triage"]) + assert.Contains(t, d.PerRepoWIFRepos, "acme/widget") + assert.Contains(t, d.PerRepoWIFRepos, "acme/api") +} + +func TestGCFWIFAdapter_DiscoverMint_NilProvisioner(t *testing.T) { + adapter := &gcfProvisionerAdapter{provisioner: nil} + _, err := adapter.DiscoverMint(context.Background()) + require.Error(t, err) + assert.True(t, errors.Is(err, repos.ErrMintNotFound)) +} + +func TestGCFWIFAdapter_DiscoverMint_FunctionNotFound(t *testing.T) { + fakeClient := gcf.NewFakeGCFClient(gcf.WithFakeErrors(map[string]error{ + "GetFunction": fmt.Errorf("checking mint function: %w", gcf.ErrFunctionNotFound), + })) + prov := gcf.NewProvisioner(gcf.Config{ + ProjectID: "test-project", + GitHubOrgs: []string{"acme"}, + Repo: "acme/widget", + }, fakeClient) + adapter := &gcfProvisionerAdapter{provisioner: prov} + + _, err := adapter.DiscoverMint(context.Background()) + require.Error(t, err) + assert.True(t, errors.Is(err, repos.ErrMintNotFound), + "expected ErrMintNotFound, got: %v", err) + assert.True(t, errors.Is(err, gcf.ErrFunctionNotFound), + "original gcf error should be preserved in chain, got: %v", err) +} + +func TestGCFWIFAdapter_DiscoverMint_OtherError(t *testing.T) { + fakeClient := gcf.NewFakeGCFClient(gcf.WithFakeErrors(map[string]error{ + "GetFunction": fmt.Errorf("permission denied"), + })) + prov := gcf.NewProvisioner(gcf.Config{ + ProjectID: "test-project", + GitHubOrgs: []string{"acme"}, + Repo: "acme/widget", + }, fakeClient) + adapter := &gcfProvisionerAdapter{provisioner: prov} + + _, err := adapter.DiscoverMint(context.Background()) + require.Error(t, err) + assert.False(t, errors.Is(err, repos.ErrMintNotFound), + "non-function-not-found errors should not be translated") +} + +func TestGCFWIFAdapter_ProvisionWIF_NilProvisioner(t *testing.T) { + adapter := &gcfProvisionerAdapter{provisioner: nil} + _, err := adapter.ProvisionWIF(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not configured") +} + +func TestGCFWIFAdapter_RegisterPerRepoWIF_NilProvisioner(t *testing.T) { + adapter := &gcfProvisionerAdapter{provisioner: nil} + err := adapter.RegisterPerRepoWIF(context.Background(), "acme/widget") + require.Error(t, err) + assert.Contains(t, err.Error(), "not configured") +} + +func TestGCFWIFAdapter_EnsureOrgInMint_NilProvisioner(t *testing.T) { + adapter := &gcfProvisionerAdapter{provisioner: nil} + err := adapter.EnsureOrgInMint(context.Background(), "https://mint.example.com", "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "not configured") +} + +func TestGCFWIFAdapter_DeletePerRepoWIF_NilProvisioner(t *testing.T) { + adapter := &gcfProvisionerAdapter{provisioner: nil} + err := adapter.DeletePerRepoWIF(context.Background(), "acme/widget") + require.Error(t, err) + assert.Contains(t, err.Error(), "not configured") +} + +func TestGCFWIFAdapter_ProvisionWIF_Success(t *testing.T) { + fakeClient := gcf.NewFakeGCFClient() + prov := gcf.NewProvisioner(gcf.Config{ + ProjectID: "test-project", + GitHubOrgs: []string{"acme"}, + Repo: "acme/widget", + WIFPoolName: "fullsend-pool", + }, fakeClient) + adapter := &gcfProvisionerAdapter{provisioner: prov} + + provider, err := adapter.ProvisionWIF(context.Background()) + require.NoError(t, err) + assert.Contains(t, provider, "fullsend-pool") +} + +func TestGCFWIFAdapter_EnsureOrgInMint_Success(t *testing.T) { + fakeClient := gcf.NewFakeGCFClient( + gcf.WithFakeFunctionInfo(&gcf.FunctionInfo{ + URI: "https://mint.example.run.app", + EnvVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "ROLE_APP_IDS": `{"triage":"100"}`, + }, + }), + ) + prov := gcf.NewProvisioner(gcf.Config{ + ProjectID: "test-project", + GitHubOrgs: []string{"acme"}, + }, fakeClient) + adapter := &gcfProvisionerAdapter{provisioner: prov} + + err := adapter.EnsureOrgInMint(context.Background(), "https://mint.example.run.app", "acme") + require.NoError(t, err) +} + +func TestGCFWIFAdapter_RegisterPerRepoWIF_Success(t *testing.T) { + fakeClient := gcf.NewFakeGCFClient( + gcf.WithFakeFunctionInfo(&gcf.FunctionInfo{ + URI: "https://mint.example.run.app", + EnvVars: map[string]string{}, + }), + ) + prov := gcf.NewProvisioner(gcf.Config{ + ProjectID: "test-project", + GitHubOrgs: []string{"acme"}, + }, fakeClient) + adapter := &gcfProvisionerAdapter{provisioner: prov} + + err := adapter.RegisterPerRepoWIF(context.Background(), "acme/widget") + require.NoError(t, err) +} + +func TestGCFWIFAdapter_DeletePerRepoWIF_Success(t *testing.T) { + fakeClient := gcf.NewFakeGCFClient( + gcf.WithFakeFunctionInfo(&gcf.FunctionInfo{ + URI: "https://mint.example.run.app", + EnvVars: map[string]string{ + "PER_REPO_WIF_REPOS": "acme/widget,acme/api", + }, + }), + ) + prov := gcf.NewProvisioner(gcf.Config{ + ProjectID: "test-project", + GitHubOrgs: []string{"acme"}, + }, fakeClient) + adapter := &gcfProvisionerAdapter{provisioner: prov} + + err := adapter.DeletePerRepoWIF(context.Background(), "acme/widget") + require.NoError(t, err) +} + func TestToAgentCredentials(t *testing.T) { ac := &appsetup.AppCredentials{ AppID: 42, diff --git a/internal/cli/github.go b/internal/cli/github.go index 268fc52fd..06538151b 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -16,6 +16,7 @@ import ( "github.com/fullsend-ai/fullsend/internal/inference" "github.com/fullsend-ai/fullsend/internal/inference/vertex" "github.com/fullsend-ai/fullsend/internal/layers" + "github.com/fullsend-ai/fullsend/internal/maputil" "github.com/fullsend-ai/fullsend/internal/mintcore" "github.com/fullsend-ai/fullsend/internal/scaffold" "github.com/fullsend-ai/fullsend/internal/ui" @@ -263,10 +264,10 @@ func runGitHubSetupPerRepo(ctx context.Context, client forge.Client, printer *ui } printer.Blank() printer.StepInfo("Would set repository variables:") - for _, name := range sortedStringMapKeys(repoVars) { + for _, name := range maputil.SortedKeys(repoVars) { printer.StepInfo(fmt.Sprintf(" %s = %s", name, repoVars[name])) } - secretNames := sortedStringMapKeys(repoSecrets) + secretNames := maputil.SortedKeys(repoSecrets) printer.StepInfo(fmt.Sprintf("Would set %d repository secrets:", len(secretNames))) for _, name := range secretNames { printer.StepInfo(fmt.Sprintf(" %s", name)) diff --git a/internal/dispatch/gcf/provisioner.go b/internal/dispatch/gcf/provisioner.go index 605a05271..7c325847f 100644 --- a/internal/dispatch/gcf/provisioner.go +++ b/internal/dispatch/gcf/provisioner.go @@ -26,6 +26,7 @@ import ( "time" "github.com/fullsend-ai/fullsend/internal/dispatch" + "github.com/fullsend-ai/fullsend/internal/maputil" "github.com/fullsend-ai/fullsend/internal/mintcore" ) @@ -627,7 +628,7 @@ func (p *Provisioner) provisionWithExistingMint(ctx context.Context) (map[string } // Verify secrets exist for roles without fresh PEMs (re-install). - for _, role := range sortedStringMapKeys(p.cfg.AgentAppIDs) { + for _, role := range maputil.SortedKeys(p.cfg.AgentAppIDs) { if _, hasPEM := p.cfg.AgentPEMs[role]; hasPEM { continue } @@ -797,7 +798,7 @@ func (p *Provisioner) provisionSelfManaged(ctx context.Context) (map[string]stri } // Step 5b: Verify secrets exist for roles without PEMs (re-install). - for _, role := range sortedStringMapKeys(p.cfg.AgentAppIDs) { + for _, role := range maputil.SortedKeys(p.cfg.AgentAppIDs) { if _, hasPEM := p.cfg.AgentPEMs[role]; hasPEM { continue } @@ -1661,15 +1662,6 @@ func (p *Provisioner) zeroPEMs() { } } -func sortedStringMapKeys(m map[string]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - func sortedByteMapKeys(m map[string][]byte) []string { keys := make([]string, 0, len(m)) for k := range m { diff --git a/internal/maputil/maputil.go b/internal/maputil/maputil.go new file mode 100644 index 000000000..b5be87136 --- /dev/null +++ b/internal/maputil/maputil.go @@ -0,0 +1,13 @@ +package maputil + +import "sort" + +// SortedKeys returns the keys of m in lexicographic order. +func SortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/maputil/maputil_test.go b/internal/maputil/maputil_test.go new file mode 100644 index 000000000..4c863c15f --- /dev/null +++ b/internal/maputil/maputil_test.go @@ -0,0 +1,21 @@ +package maputil + +import "testing" + +func TestSortedKeys(t *testing.T) { + m := map[string]string{"c": "3", "a": "1", "b": "2"} + keys := SortedKeys(m) + if len(keys) != 3 { + t.Fatalf("expected 3 keys, got %d", len(keys)) + } + if keys[0] != "a" || keys[1] != "b" || keys[2] != "c" { + t.Errorf("expected [a b c], got %v", keys) + } +} + +func TestSortedKeys_Empty(t *testing.T) { + keys := SortedKeys(map[string]string{}) + if len(keys) != 0 { + t.Errorf("expected empty slice, got %v", keys) + } +} diff --git a/internal/repos/install.go b/internal/repos/install.go new file mode 100644 index 000000000..6bfb409be --- /dev/null +++ b/internal/repos/install.go @@ -0,0 +1,322 @@ +// Package repos provides reusable per-repo installation logic for fullsend. +// It decouples the core install flow (guard check, WIF provisioning, scaffold +// commit, variable/secret writes) from CLI concerns (prompts, spinners, flag +// parsing) so that both the interactive CLI and future bulk-install commands +// can share the same logic. +package repos + +import ( + "context" + "errors" + "fmt" + "regexp" + + "github.com/fullsend-ai/fullsend/internal/config" + "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/maputil" + "github.com/fullsend-ai/fullsend/internal/scaffold" +) + +// WIFProviderPattern validates the full WIF provider resource name format +// required by google-github-actions/auth@v3. +// GCP pool/provider IDs: 4-32 chars, [a-z0-9-], start with letter, no trailing hyphen. +var WIFProviderPattern = regexp.MustCompile( + `^projects/\d+/locations/global/workloadIdentityPools/[a-z][a-z0-9-]{2,30}[a-z0-9]/providers/[a-z][a-z0-9-]{2,30}[a-z0-9]$`, +) + +// InstallConfig is a pure data struct holding all inputs needed for a +// per-repo installation. CLI flags, environment variables, and interactive +// prompts are resolved by the caller before constructing this struct. +type InstallConfig struct { + Owner string + Repo string + + // Roles is the list of agent roles to install (e.g., "triage", "code"). + Roles []string + + MintURL string + + InferenceProject string + InferenceRegion string + + // UpstreamRef is the git ref (SHA) used to pin scaffold workflow refs. + // Empty for dev builds (falls back to config.DefaultUpstreamRef). + UpstreamRef string + // UpstreamTag is the version tag corresponding to UpstreamRef (e.g., "v0.42.0"). + UpstreamTag string + + // Skip flags control which install steps are executed. Set by callers + // that handle specific steps externally (e.g., admin.go handles guard + // checks and mint setup before calling Install). + SkipAppSetup bool + SkipMintCheck bool + SkipWIF bool + SkipGuardCheck bool + + // SkipScaffoldAndConfig skips scaffold file delivery and variable/secret + // writes. The caller handles these steps externally (e.g., vendor mode + // commits scaffold and vendor files together atomically). + SkipScaffoldAndConfig bool + + // WIFProvider is a pre-provisioned WIF provider resource name. When set + // and SkipWIF is true, the install skips WIF provisioning and uses this + // value directly. + WIFProvider string + + VendorBinary bool + + // Direct controls scaffold delivery: true pushes directly to the default + // branch; false creates a PR. + Direct bool +} + +// InstallResult holds the outcome of a per-repo installation. +type InstallResult struct { + Owner string + Repo string + + Success bool + Error error + AlreadyInstalled bool + + // WIFProvider is the WIF provider resource name, either pre-existing + // (from InstallConfig) or newly provisioned. + WIFProvider string +} + +// MintDiscovery holds the results of a mint infrastructure discovery call. +type MintDiscovery struct { + URL string + RoleAppIDs map[string]string + PerRepoWIFRepos []string +} + +// WIFProvisioner abstracts Workload Identity Federation and mint discovery +// operations, decoupling the install logic from the concrete GCF provisioner. +// +// All methods return wrapped errors suitable for errors.Is checks. +// DiscoverMint wraps ErrMintNotFound when the mint function does not exist. +// Other methods return provider-specific errors (e.g., IAM permission denied). +type WIFProvisioner interface { + // DiscoverMint fetches mint infrastructure info (URL, role-to-app-ID + // mappings, per-repo WIF repos). Returns an error wrapping + // ErrMintNotFound if the mint function does not exist. + DiscoverMint(ctx context.Context) (*MintDiscovery, error) + + // ProvisionWIF creates WIF infrastructure (service account, pool, + // provider, principal binding) and returns the full WIF provider + // resource name. + ProvisionWIF(ctx context.Context) (string, error) + + // RegisterPerRepoWIF adds a repo to the mint's PER_REPO_WIF_REPOS + // env var so the mint routes OIDC tokens for that repo to a dedicated + // WIF provider. + RegisterPerRepoWIF(ctx context.Context, repo string) error + + // EnsureOrgInMint validates that a mint function exists at expectedURL + // and that the given org is registered in ALLOWED_ORGS. + EnsureOrgInMint(ctx context.Context, expectedURL string, org string) error + + // DeletePerRepoWIF removes a repo from per-repo WIF registration. + DeletePerRepoWIF(ctx context.Context, repo string) error +} + +// ErrMintNotFound indicates the mint function does not exist. +var ErrMintNotFound = errors.New("mint function not found") + +// ScaffoldCommitFunc delivers scaffold files to a repository and returns +// any error encountered. +// +// The CLI layer provides an implementation wrapping layers.CommitScaffoldFiles, +// which adds retry on non-fast-forward errors, branch-protection fallback to +// PR delivery, and fork-based PR support for non-owner users. +type ScaffoldCommitFunc func(ctx context.Context, owner, repo string, + files []forge.TreeFile, direct bool) error + +// ProgressFunc is a callback for reporting installation progress. The caller +// maps this to spinner output, structured logs, or whatever UI is appropriate. +// +// Parameters: +// - repo: the "owner/repo" being installed +// - phase: a machine-readable phase name (e.g., "guard", "wif", "scaffold", "vars") +// - message: a human-readable status message +type ProgressFunc func(repo, phase, message string) + +// Install performs a per-repo fullsend installation. It checks for an existing +// installation, optionally discovers mint infrastructure and provisions WIF, +// generates and commits scaffold files, and writes repository variables and +// secrets. +// +// The commitScaffold callback handles scaffold file delivery. The CLI layer +// provides an implementation with retry and fallback semantics; tests provide +// a simple fake. +// +// CLI concerns (prompts, spinners, token resolution, scope checks, dry-run) +// are handled by the caller. This function contains only the pure install logic. +func Install(ctx context.Context, cfg InstallConfig, + client forge.Client, provisioner WIFProvisioner, + commitScaffold ScaffoldCommitFunc, + progress ProgressFunc) (*InstallResult, error) { + + if progress == nil { + progress = func(_, _, _ string) {} + } + + repoFullName := cfg.Owner + "/" + cfg.Repo + result := &InstallResult{ + Owner: cfg.Owner, + Repo: cfg.Repo, + } + + // Step 1: Check guard variable to detect existing installations. + // Bulk-install callers use this to skip already-installed repos. + // Interactive CLI callers set SkipGuardCheck=true because they + // handle the guard check themselves (and always proceed with updates). + // Fails closed: a guard-check error returns an error rather than + // proceeding with a potentially duplicate install. + if !cfg.SkipGuardCheck { + progress(repoFullName, "guard", "Checking installation status") + guardVal, guardExists, guardErr := client.GetRepoVariable(ctx, cfg.Owner, cfg.Repo, forge.PerRepoGuardVar) + if guardErr != nil { + return result, fmt.Errorf("checking guard variable: %w", guardErr) + } + if guardExists && guardVal == "true" { + result.AlreadyInstalled = true + result.Success = true + progress(repoFullName, "guard", "Already installed (per-repo mode)") + return result, nil + } + } + + // Step 2: Discover mint infrastructure (unless SkipMintCheck). + mintURL := cfg.MintURL + if !cfg.SkipMintCheck && mintURL == "" { + if provisioner == nil { + return result, fmt.Errorf("mint discovery required but no provisioner provided") + } + progress(repoFullName, "discover", "Discovering mint infrastructure") + discovery, err := provisioner.DiscoverMint(ctx) + if err != nil { + return result, fmt.Errorf("discovering mint infrastructure: %w", err) + } + mintURL = discovery.URL + if mintURL == "" { + return result, fmt.Errorf("mint discovery returned empty URL") + } + } + + // Step 3: WIF provisioning (unless SkipWIF or provider already set). + wifProvider := cfg.WIFProvider + if !cfg.SkipWIF && wifProvider == "" { + if provisioner == nil { + return result, fmt.Errorf("WIF provisioning required but no provisioner provided") + } + progress(repoFullName, "wif", "Provisioning WIF infrastructure") + var err error + wifProvider, err = provisioner.ProvisionWIF(ctx) + if err != nil { + result.WIFProvider = wifProvider + return result, fmt.Errorf("provisioning WIF: %w", err) + } + result.WIFProvider = wifProvider + progress(repoFullName, "wif", "WIF infrastructure ready") + } else if wifProvider != "" { + result.WIFProvider = wifProvider + } + + // When SkipScaffoldAndConfig is set, the caller handles scaffold delivery + // and variable/secret writes externally (e.g., vendor mode commits + // scaffold and vendor files together atomically via applyPerRepoScaffold). + if cfg.SkipScaffoldAndConfig { + result.Success = true + progress(repoFullName, "done", "Pre-install steps complete") + return result, nil + } + + if wifProvider == "" { + return result, fmt.Errorf("WIF provider required for repository secret configuration; set WIFProvider or enable WIF provisioning") + } + if !WIFProviderPattern.MatchString(wifProvider) { + return result, fmt.Errorf("invalid WIF provider format %q: expected projects/{number}/locations/global/workloadIdentityPools/{pool}/providers/{id}", wifProvider) + } + + // Step 4: Generate scaffold files. + progress(repoFullName, "scaffold", "Generating scaffold files") + files, err := BuildScaffoldFiles(cfg) + if err != nil { + return result, fmt.Errorf("generating scaffold files: %w", err) + } + + // Step 5: Commit scaffold files via the caller-provided commit function. + progress(repoFullName, "scaffold", "Committing scaffold files") + if commitErr := commitScaffold(ctx, cfg.Owner, cfg.Repo, files, cfg.Direct); commitErr != nil { + return result, fmt.Errorf("committing scaffold: %w", commitErr) + } + progress(repoFullName, "scaffold", "Scaffold files committed") + + // Step 6: Write repository variables. + progress(repoFullName, "vars", "Configuring repository variables") + repoVars := map[string]string{ + "FULLSEND_MINT_URL": mintURL, + "FULLSEND_GCP_REGION": cfg.InferenceRegion, + forge.PerRepoGuardVar: "true", + } + for _, name := range maputil.SortedKeys(repoVars) { + if err := client.CreateOrUpdateRepoVariable(ctx, cfg.Owner, cfg.Repo, name, repoVars[name]); err != nil { + return result, fmt.Errorf("setting repo variable %s: %w", name, err) + } + } + progress(repoFullName, "vars", fmt.Sprintf("Set %d repository variables", len(repoVars))) + + // Step 7: Write repository secrets. + progress(repoFullName, "secrets", "Configuring repository secrets") + repoSecrets := map[string]string{ + "FULLSEND_GCP_PROJECT_ID": cfg.InferenceProject, + "FULLSEND_GCP_WIF_PROVIDER": wifProvider, + } + for _, name := range maputil.SortedKeys(repoSecrets) { + if err := client.CreateRepoSecret(ctx, cfg.Owner, cfg.Repo, name, repoSecrets[name]); err != nil { + return result, fmt.Errorf("setting repo secret %s: %w", name, err) + } + } + progress(repoFullName, "secrets", fmt.Sprintf("Set %d repository secrets", len(repoSecrets))) + + result.Success = true + progress(repoFullName, "done", "Installation complete") + return result, nil +} + +// BuildScaffoldFiles generates the scaffold tree files for a per-repo install. +// Exported so the CLI dry-run path can display the file list without running +// the full install. +func BuildScaffoldFiles(cfg InstallConfig) ([]forge.TreeFile, error) { + perRepoCfg := config.NewPerRepoConfig(cfg.Roles, cfg.Owner+"/"+cfg.Repo) + if err := perRepoCfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + cfgYAML, err := perRepoCfg.Marshal() + if err != nil { + return nil, fmt.Errorf("marshaling config: %w", err) + } + + installFiles, err := scaffold.CollectPerRepoInstallFiles(cfg.VendorBinary, cfg.UpstreamRef, cfg.UpstreamTag) + if err != nil { + return nil, fmt.Errorf("collecting install files: %w", err) + } + + var files []forge.TreeFile + for _, f := range installFiles { + files = append(files, forge.TreeFile{ + Path: f.Path, + Content: f.Content, + Mode: f.Mode, + }) + } + files = append(files, forge.TreeFile{ + Path: ".fullsend/config.yaml", + Content: cfgYAML, + Mode: "100644", + }) + + return files, nil +} diff --git a/internal/repos/install_test.go b/internal/repos/install_test.go new file mode 100644 index 000000000..8a08fea56 --- /dev/null +++ b/internal/repos/install_test.go @@ -0,0 +1,771 @@ +package repos + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/fullsend-ai/fullsend/internal/forge" +) + +// fakeWIFProvisioner is a test double for WIFProvisioner. +type fakeWIFProvisioner struct { + discoverResult *MintDiscovery + discoverErr error + provisionResult string + provisionErr error + + // Call recorders. + discoverCalled bool + provisionCalled bool +} + +func (f *fakeWIFProvisioner) DiscoverMint(_ context.Context) (*MintDiscovery, error) { + f.discoverCalled = true + return f.discoverResult, f.discoverErr +} + +func (f *fakeWIFProvisioner) ProvisionWIF(_ context.Context) (string, error) { + f.provisionCalled = true + return f.provisionResult, f.provisionErr +} + +func (f *fakeWIFProvisioner) RegisterPerRepoWIF(_ context.Context, _ string) error { + return nil +} + +func (f *fakeWIFProvisioner) EnsureOrgInMint(_ context.Context, _, _ string) error { + return nil +} + +func (f *fakeWIFProvisioner) DeletePerRepoWIF(_ context.Context, _ string) error { + return nil +} + +// noopProgress is a no-op progress callback for tests. +func noopProgress(_, _, _ string) {} + +// fakeScaffoldCommit is a test double for ScaffoldCommitFunc that records +// calls and returns configurable results. +type fakeScaffoldCommit struct { + called bool + err error +} + +func (f *fakeScaffoldCommit) fn() ScaffoldCommitFunc { + return func(_ context.Context, _, _ string, _ []forge.TreeFile, _ bool) error { + f.called = true + return f.err + } +} + +const ( + fakeWIFProvider = "projects/100000/locations/global/workloadIdentityPools/fake-pool/providers/fake-provider" + fakeWIFProvider2 = "projects/999999/locations/global/workloadIdentityPools/fake-pool/providers/fake-provider" +) + +// baseCfg returns an InstallConfig suitable for most tests. +// It skips guard check, mint check, and WIF provisioning +// (the common path when called from admin.go). +func baseCfg() InstallConfig { + return InstallConfig{ + Owner: "acme", + Repo: "widgets", + Roles: []string{"triage", "coder"}, + MintURL: "https://mint.example.com", + InferenceProject: "fake-inference-project", + InferenceRegion: "us-central1", + WIFProvider: fakeWIFProvider, + Direct: true, + SkipGuardCheck: true, + SkipMintCheck: true, + SkipWIF: true, + } +} + +// newFakeClientWithRepo returns a FakeClient pre-populated with a repo. +func newFakeClientWithRepo() *forge.FakeClient { + fc := forge.NewFakeClient() + fc.Repos = []forge.Repository{{ + FullName: "acme/widgets", + Name: "widgets", + DefaultBranch: "main", + }} + return fc +} + +func TestInstall_FreshInstall_Direct(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + if !result.Success { + t.Error("expected Success=true") + } + if result.AlreadyInstalled { + t.Error("expected AlreadyInstalled=false for fresh install") + } + if !sc.called { + t.Error("expected scaffold commit function to be called") + } + + // Verify repository variables were set. + if len(fc.Variables) != 3 { + t.Errorf("expected 3 variables, got %d", len(fc.Variables)) + } + varMap := make(map[string]string) + for _, v := range fc.Variables { + varMap[v.Name] = v.Value + } + if varMap["FULLSEND_MINT_URL"] != "https://mint.example.com" { + t.Errorf("FULLSEND_MINT_URL = %q, want %q", varMap["FULLSEND_MINT_URL"], "https://mint.example.com") + } + if varMap["FULLSEND_GCP_REGION"] != "us-central1" { + t.Errorf("FULLSEND_GCP_REGION = %q, want %q", varMap["FULLSEND_GCP_REGION"], "us-central1") + } + if varMap[forge.PerRepoGuardVar] != "true" { + t.Errorf("%s = %q, want %q", forge.PerRepoGuardVar, varMap[forge.PerRepoGuardVar], "true") + } + + // Verify repository secrets were set. + if len(fc.CreatedSecrets) != 2 { + t.Errorf("expected 2 secrets, got %d", len(fc.CreatedSecrets)) + } + secretMap := make(map[string]string) + for _, s := range fc.CreatedSecrets { + secretMap[s.Name] = s.Value + } + if secretMap["FULLSEND_GCP_PROJECT_ID"] != "fake-inference-project" { + t.Errorf("FULLSEND_GCP_PROJECT_ID = %q, want %q", secretMap["FULLSEND_GCP_PROJECT_ID"], "fake-inference-project") + } + if secretMap["FULLSEND_GCP_WIF_PROVIDER"] != fakeWIFProvider { + t.Errorf("FULLSEND_GCP_WIF_PROVIDER = %q, want %q", secretMap["FULLSEND_GCP_WIF_PROVIDER"], fakeWIFProvider) + } + + // Verify WIF provider is propagated to result. + if result.WIFProvider != fakeWIFProvider { + t.Errorf("result.WIFProvider = %q, want %q", result.WIFProvider, fakeWIFProvider) + } +} + +func TestInstall_FreshInstall_PR(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.Direct = false + + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + if !result.Success { + t.Error("expected Success=true") + } + if !sc.called { + t.Error("expected scaffold commit function to be called") + } +} + +func TestInstall_AlreadyInstalled_GuardTrue(t *testing.T) { + fc := newFakeClientWithRepo() + fc.VariableValues["acme/widgets/"+forge.PerRepoGuardVar] = "true" + + cfg := baseCfg() + cfg.SkipGuardCheck = false // enable guard check + + sc := &fakeScaffoldCommit{} + result, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + if !result.AlreadyInstalled { + t.Error("expected AlreadyInstalled=true") + } + if !result.Success { + t.Error("expected Success=true") + } + + // Verify NO writes occurred. + if sc.called { + t.Error("expected scaffold commit NOT to be called for already-installed repo") + } + if len(fc.Variables) != 0 { + t.Error("expected no variable writes for already-installed repo") + } + if len(fc.CreatedSecrets) != 0 { + t.Error("expected no secret writes for already-installed repo") + } +} + +func TestInstall_SkipGuardCheck_ProceedsEvenWithGuardTrue(t *testing.T) { + fc := newFakeClientWithRepo() + fc.VariableValues["acme/widgets/"+forge.PerRepoGuardVar] = "true" + + cfg := baseCfg() + cfg.SkipGuardCheck = true // CLI path: always proceed + + sc := &fakeScaffoldCommit{} + result, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + // Should NOT be marked as already installed. + if result.AlreadyInstalled { + t.Error("expected AlreadyInstalled=false when SkipGuardCheck=true") + } + if !result.Success { + t.Error("expected Success=true") + } + + // Verify writes DID occur. + if !sc.called { + t.Error("expected scaffold commit to be called when guard check is skipped") + } + if len(fc.Variables) == 0 { + t.Error("expected variables to be written when guard check is skipped") + } +} + +func TestInstall_MintDiscovery(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipMintCheck = false + cfg.MintURL = "" // force discovery + + prov := &fakeWIFProvisioner{ + discoverResult: &MintDiscovery{ + URL: "https://discovered-mint.example.com", + }, + } + + sc := &fakeScaffoldCommit{} + result, err := Install(context.Background(), cfg, fc, prov, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + if !prov.discoverCalled { + t.Error("expected DiscoverMint to be called") + } + if !result.Success { + t.Error("expected Success=true") + } + + // Verify the discovered mint URL was used in variables. + varMap := make(map[string]string) + for _, v := range fc.Variables { + varMap[v.Name] = v.Value + } + if varMap["FULLSEND_MINT_URL"] != "https://discovered-mint.example.com" { + t.Errorf("FULLSEND_MINT_URL = %q, want %q", varMap["FULLSEND_MINT_URL"], "https://discovered-mint.example.com") + } +} + +func TestInstall_SkipMintCheck_NoDiscovery(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipMintCheck = true + cfg.MintURL = "https://preset-mint.example.com" + + prov := &fakeWIFProvisioner{} + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, prov, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + if prov.discoverCalled { + t.Error("expected DiscoverMint NOT to be called when SkipMintCheck=true") + } + if !result.Success { + t.Error("expected Success=true") + } +} + +func TestInstall_WIFProvisioning(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipWIF = false + cfg.WIFProvider = "" // force provisioning + + prov := &fakeWIFProvisioner{ + provisionResult: fakeWIFProvider2, + } + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, prov, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + if !prov.provisionCalled { + t.Error("expected ProvisionWIF to be called") + } + if result.WIFProvider != fakeWIFProvider2 { + t.Errorf("result.WIFProvider = %q, want %q", result.WIFProvider, fakeWIFProvider2) + } + + // Verify the provisioned WIF provider was used in secrets. + secretMap := make(map[string]string) + for _, s := range fc.CreatedSecrets { + secretMap[s.Name] = s.Value + } + if secretMap["FULLSEND_GCP_WIF_PROVIDER"] != fakeWIFProvider2 { + t.Errorf("FULLSEND_GCP_WIF_PROVIDER secret = %q, want %q", + secretMap["FULLSEND_GCP_WIF_PROVIDER"], fakeWIFProvider2) + } +} + +func TestInstall_WIFProvisioningFailure(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipWIF = false + cfg.WIFProvider = "" + + provErr := fmt.Errorf("IAM permission denied") + prov := &fakeWIFProvisioner{ + provisionErr: provErr, + } + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, prov, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error from WIF provisioning failure") + } + if !errors.Is(err, provErr) { + t.Errorf("expected error to wrap %v, got %v", provErr, err) + } + + // Result should be non-nil so callers can inspect partial state. + if result == nil { + t.Fatal("expected non-nil result on WIF failure (partial state)") + } + + // Verify NO scaffold commit occurred. + if sc.called { + t.Error("expected scaffold commit NOT to be called after WIF failure") + } + + // Verify NO variables or secrets were written. + if len(fc.Variables) != 0 { + t.Error("expected no variable writes after WIF failure") + } + if len(fc.CreatedSecrets) != 0 { + t.Error("expected no secret writes after WIF failure") + } +} + +func TestInstall_ScaffoldCommitFailure(t *testing.T) { + fc := newFakeClientWithRepo() + sc := &fakeScaffoldCommit{err: fmt.Errorf("network error")} + + cfg := baseCfg() + cfg.Direct = true + + result, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error from scaffold commit failure") + } + + // Result should be non-nil with partial state. + if result == nil { + t.Fatal("expected non-nil result on scaffold commit failure") + } + + // WIF provider should still be captured in result (partial state). + if result.WIFProvider != fakeWIFProvider { + t.Errorf("result.WIFProvider = %q, want %q (should capture partial state)", + result.WIFProvider, fakeWIFProvider) + } + + // Verify NO variables or secrets were written. + if len(fc.Variables) != 0 { + t.Error("expected no variable writes after scaffold commit failure") + } + if len(fc.CreatedSecrets) != 0 { + t.Error("expected no secret writes after scaffold commit failure") + } +} + +func TestInstall_SkipWIF_UsesPresetProvider(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipWIF = true + cfg.WIFProvider = fakeWIFProvider2 + + prov := &fakeWIFProvisioner{} + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, prov, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + if prov.provisionCalled { + t.Error("expected ProvisionWIF NOT to be called when SkipWIF=true") + } + if result.WIFProvider != fakeWIFProvider2 { + t.Errorf("result.WIFProvider = %q, want %q", result.WIFProvider, fakeWIFProvider2) + } +} + +func TestInstall_WIFNotSkipped_ProviderPreset(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipWIF = false + cfg.WIFProvider = fakeWIFProvider2 + + prov := &fakeWIFProvisioner{} + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, prov, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + if prov.provisionCalled { + t.Error("expected ProvisionWIF NOT to be called when WIFProvider is already set") + } + if result.WIFProvider != fakeWIFProvider2 { + t.Errorf("result.WIFProvider = %q, want %q", result.WIFProvider, fakeWIFProvider2) + } +} + +func TestInstall_MintDiscoveryEmptyURL(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipMintCheck = false + cfg.MintURL = "" + + prov := &fakeWIFProvisioner{ + discoverResult: &MintDiscovery{URL: ""}, + } + sc := &fakeScaffoldCommit{} + + _, err := Install(context.Background(), cfg, fc, prov, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error when mint discovery returns empty URL") + } + if sc.called { + t.Error("expected scaffold commit NOT to be called after empty mint URL discovery") + } +} + +func TestInstall_EmptyWIFProvider_Rejected(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipWIF = true + cfg.WIFProvider = "" + + sc := &fakeScaffoldCommit{} + + _, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error when WIF provider is empty and secrets would be written") + } + if sc.called { + t.Error("expected scaffold commit NOT to be called after empty WIF provider validation") + } +} + +func TestInstall_InvalidWIFProviderFormat_Rejected(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipWIF = true + cfg.WIFProvider = "not-a-valid-provider" + + sc := &fakeScaffoldCommit{} + + _, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error when WIF provider has invalid format") + } + if sc.called { + t.Error("expected scaffold commit NOT to be called after WIF provider format validation") + } +} + +func TestInstall_MintDiscoveryFailure(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipMintCheck = false + cfg.MintURL = "" + + prov := &fakeWIFProvisioner{ + discoverErr: fmt.Errorf("wrapped: %w", ErrMintNotFound), + } + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, prov, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error from mint discovery failure") + } + if !errors.Is(err, ErrMintNotFound) { + t.Errorf("expected error to wrap ErrMintNotFound, got %v", err) + } + // Result should be non-nil for partial state inspection. + if result == nil { + t.Fatal("expected non-nil result on mint discovery failure") + } +} + +func TestInstall_ProgressCallbackPhases(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + + sc := &fakeScaffoldCommit{} + var phases []string + progress := func(_, phase, _ string) { + phases = append(phases, phase) + } + + _, err := Install(context.Background(), cfg, fc, nil, sc.fn(), progress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + // Verify expected phases are reported in order. + wantPhases := []string{"scaffold", "scaffold", "scaffold", "vars", "vars", "secrets", "secrets", "done"} + if len(phases) != len(wantPhases) { + t.Fatalf("got %d phases %v, want %d phases %v", len(phases), phases, len(wantPhases), wantPhases) + } + for i, want := range wantPhases { + if phases[i] != want { + t.Errorf("phase[%d] = %q, want %q (all phases: %v)", i, phases[i], want, phases) + break + } + } +} + +func TestInstall_GuardCheckError_FailsClosed(t *testing.T) { + fc := newFakeClientWithRepo() + fc.Errors["GetRepoVariable"] = fmt.Errorf("API rate limit") + + cfg := baseCfg() + cfg.SkipGuardCheck = false + + sc := &fakeScaffoldCommit{} + result, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error when guard check fails (fail closed)") + } + + // Result should be non-nil for partial state. + if result == nil { + t.Fatal("expected non-nil result on guard check failure") + } + + // Verify NO writes occurred. + if sc.called { + t.Error("expected scaffold commit NOT to be called after guard check failure") + } + if len(fc.Variables) != 0 { + t.Error("expected no variable writes after guard check failure") + } +} + +func TestInstall_SkipScaffoldAndConfig(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipScaffoldAndConfig = true + + sc := &fakeScaffoldCommit{} + result, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + if !result.Success { + t.Error("expected Success=true") + } + + // Scaffold commit should NOT be called. + if sc.called { + t.Error("expected scaffold commit NOT to be called when SkipScaffoldAndConfig=true") + } + + // Variables and secrets should NOT be written. + if len(fc.Variables) != 0 { + t.Error("expected no variable writes when SkipScaffoldAndConfig=true") + } + if len(fc.CreatedSecrets) != 0 { + t.Error("expected no secret writes when SkipScaffoldAndConfig=true") + } + + // WIF provider should still be propagated. + if result.WIFProvider != fakeWIFProvider { + t.Errorf("result.WIFProvider = %q, want %q", result.WIFProvider, fakeWIFProvider) + } +} + +func TestInstall_SkipScaffoldAndConfig_WithWIF(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipScaffoldAndConfig = true + cfg.SkipWIF = false + cfg.WIFProvider = "" + + prov := &fakeWIFProvisioner{ + provisionResult: fakeWIFProvider2, + } + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, prov, sc.fn(), noopProgress) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + + // WIF provisioning should still run. + if !prov.provisionCalled { + t.Error("expected ProvisionWIF to be called even with SkipScaffoldAndConfig") + } + if result.WIFProvider != fakeWIFProvider2 { + t.Errorf("result.WIFProvider = %q, want %q", result.WIFProvider, fakeWIFProvider2) + } + + // But scaffold and config should be skipped. + if sc.called { + t.Error("expected scaffold commit NOT to be called when SkipScaffoldAndConfig=true") + } + if len(fc.Variables) != 0 { + t.Error("expected no variable writes when SkipScaffoldAndConfig=true") + } +} + +func TestInstall_VariableWriteFailure(t *testing.T) { + fc := newFakeClientWithRepo() + fc.Errors["CreateOrUpdateRepoVariable"] = fmt.Errorf("forbidden") + + cfg := baseCfg() + sc := &fakeScaffoldCommit{} + + _, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error from variable write failure") + } + if !sc.called { + t.Error("scaffold commit should have been called before variable write") + } +} + +func TestInstall_SecretWriteFailure(t *testing.T) { + fc := newFakeClientWithRepo() + fc.Errors["CreateRepoSecret"] = fmt.Errorf("forbidden") + + cfg := baseCfg() + sc := &fakeScaffoldCommit{} + + _, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error from secret write failure") + } +} + +func TestBuildScaffoldFiles(t *testing.T) { + cfg := baseCfg() + + files, err := BuildScaffoldFiles(cfg) + if err != nil { + t.Fatalf("BuildScaffoldFiles() returned error: %v", err) + } + + if len(files) == 0 { + t.Fatal("expected at least one scaffold file") + } + + // Verify config.yaml is present. + var hasConfig bool + for _, f := range files { + if f.Path == ".fullsend/config.yaml" { + hasConfig = true + if len(f.Content) == 0 { + t.Error("config.yaml should have content") + } + if f.Mode != "100644" { + t.Errorf("config.yaml mode = %q, want %q", f.Mode, "100644") + } + } + } + if !hasConfig { + t.Error("expected .fullsend/config.yaml in scaffold files") + } +} + +func TestBuildScaffoldFiles_InvalidConfig(t *testing.T) { + cfg := baseCfg() + cfg.Roles = []string{"nonexistent-role"} + + _, err := BuildScaffoldFiles(cfg) + if err == nil { + t.Fatal("expected error for invalid role") + } +} + +func TestInstall_BuildScaffoldFilesError(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.Roles = []string{"nonexistent-role"} + + sc := &fakeScaffoldCommit{} + result, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error from BuildScaffoldFiles failure") + } + if result == nil { + t.Fatal("expected non-nil result on BuildScaffoldFiles failure") + } + if sc.called { + t.Error("expected scaffold commit NOT to be called after BuildScaffoldFiles failure") + } +} + +func TestInstall_NilProgress(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + sc := &fakeScaffoldCommit{} + + result, err := Install(context.Background(), cfg, fc, nil, sc.fn(), nil) + if err != nil { + t.Fatalf("Install() returned error: %v", err) + } + if !result.Success { + t.Error("expected Success=true") + } +} + +func TestInstall_NilProvisioner_MintRequired(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipMintCheck = false + cfg.MintURL = "" + + sc := &fakeScaffoldCommit{} + _, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error when provisioner is nil and mint discovery required") + } +} + +func TestInstall_NilProvisioner_WIFRequired(t *testing.T) { + fc := newFakeClientWithRepo() + cfg := baseCfg() + cfg.SkipWIF = false + cfg.WIFProvider = "" + + sc := &fakeScaffoldCommit{} + _, err := Install(context.Background(), cfg, fc, nil, sc.fn(), noopProgress) + if err == nil { + t.Fatal("expected error when provisioner is nil and WIF provisioning required") + } +}