Files
gh-repo-bootstrap/internal/runner/runner.go
T
2026-05-26 09:09:47 -05:00

266 lines
8.5 KiB
Go

// Package runner wires CLI options to the Pulumi Automation API: it
// configures a local-filesystem-backed workspace, manages the per-state-dir
// secrets passphrase, resolves GITHUB_TOKEN, runs interactive prompts and
// reviewer resolution, and dispatches up/preview/destroy.
package runner
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/JMR-dev/gh-repo-bootstrap/internal/cli"
"github.com/JMR-dev/gh-repo-bootstrap/internal/githubapi"
"github.com/JMR-dev/gh-repo-bootstrap/internal/prompt"
"github.com/JMR-dev/gh-repo-bootstrap/internal/pulumiprog"
"github.com/JMR-dev/gh-repo-bootstrap/internal/secrets"
"github.com/pulumi/pulumi/sdk/v3/go/auto"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optdestroy"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optpreview"
"github.com/pulumi/pulumi/sdk/v3/go/auto/optup"
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
const (
projectName = "gh-repo-bootstrap"
stackName = "bootstrap"
)
var execCommand = exec.Command
type stackInterface interface {
SetConfig(ctx context.Context, key string, val auto.ConfigValue) error
Up(ctx context.Context, opts ...optup.Option) (auto.UpResult, error)
Preview(ctx context.Context, opts ...optpreview.Option) (auto.PreviewResult, error)
Destroy(ctx context.Context, opts ...optdestroy.Option) (auto.DestroyResult, error)
}
var upsertStack = func(ctx context.Context, stackName, projectName string, program pulumi.RunFunc, opts ...auto.LocalWorkspaceOption) (stackInterface, error) {
s, err := auto.UpsertStackInlineSource(ctx, stackName, projectName, program, opts...)
if err != nil {
return nil, err
}
return &s, nil
}
// Run executes the requested action against the GitHub repo described by opts.
func Run(ctx context.Context, opts *cli.Options) error {
// --- TOML config takes over the Options struct if --config was set ---
if opts.ConfigFile != "" {
loaded, err := cli.LoadConfig(opts.ConfigFile)
if err != nil {
return err
}
opts = loaded
}
// --- --create interactive prompts (CLI path only) -------------------
if opts.RepoMode == cli.RepoModeCreate && opts.ConfigFile == "" {
if err := runCreatePrompts(opts); err != nil {
return err
}
}
// Final create-mode sanity check (works for both CLI and TOML paths).
if opts.RepoMode == cli.RepoModeCreate {
if opts.RepoSettings.Visibility == "" {
return errors.New("--create requires --visibility (or set [repo].visibility in config)")
}
}
stateDir := opts.StateDir
if stateDir == "" {
stateDir = cli.DefaultStateDir(opts.Owner, opts.Repo)
}
if err := os.MkdirAll(stateDir, 0o700); err != nil {
return fmt.Errorf("creating state dir: %w", err)
}
// --- Auth: prefer caller-supplied GITHUB_TOKEN, else borrow from gh ---
if os.Getenv("GITHUB_TOKEN") == "" {
out, err := execCommand("gh", "auth", "token").Output()
if err != nil {
return fmt.Errorf("no GITHUB_TOKEN set and `gh auth token` failed; run `gh auth login` first")
}
_ = os.Setenv("GITHUB_TOKEN", strings.TrimSpace(string(out)))
}
// --- Passphrase for the local backend's secret encryption ------------
if err := ensurePassphrase(stateDir); err != nil {
return err
}
// --- Parse secret files ----------------------------------------------
var repoSecrets []secrets.Pair
if opts.RepoSecretsFile != "" {
ps, err := secrets.ParseFile(opts.RepoSecretsFile)
if err != nil {
return err
}
repoSecrets = ps
}
var envSecrets []secrets.EnvFile
if opts.EnvSecretsDir != "" {
envSet := make(map[string]struct{}, len(opts.Environments))
for _, e := range opts.Environments {
envSet[e.Name] = struct{}{}
}
es, err := secrets.LoadEnvDir(opts.EnvSecretsDir, envSet)
if err != nil {
return err
}
envSecrets = es
}
// --- Resolve reviewer identifiers ------------------------------------
resolver := githubapi.New()
resolvedEnvs := make([]pulumiprog.ResolvedEnv, 0, len(opts.Environments))
for _, e := range opts.Environments {
re := pulumiprog.ResolvedEnv{
Name: e.Name,
WaitTimer: e.WaitTimer,
PreventSelfReview: e.PreventSelfReview,
CanAdminsBypass: e.CanAdminsBypass,
BranchPolicy: e.BranchPolicy,
BranchPatterns: e.BranchPatterns,
}
for _, u := range e.ReviewerUsers {
id, err := resolver.ResolveUser(u)
if err != nil {
return err
}
re.ReviewerUserIDs = append(re.ReviewerUserIDs, id)
}
for _, t := range e.ReviewerTeams {
id, err := resolver.ResolveTeam(t)
if err != nil {
return err
}
re.ReviewerTeamIDs = append(re.ReviewerTeamIDs, id)
}
resolvedEnvs = append(resolvedEnvs, re)
}
// --- Pulumi workspace pointed at file://<stateDir> -------------------
backendURL := "file://" + stateDir
projectSettings := workspace.Project{
Name: tokens.PackageName(projectName),
Runtime: workspace.NewProjectRuntimeInfo("go", nil),
Backend: &workspace.ProjectBackend{URL: backendURL},
}
program := pulumiprog.Build(pulumiprog.Inputs{
Owner: opts.Owner,
Repo: opts.Repo,
Branch: opts.Branch,
Reviews: opts.Reviews,
Signed: opts.Signed,
RulesetName: opts.Ruleset,
Environments: resolvedEnvs,
Bypass: opts.Bypass,
RepoSecrets: repoSecrets,
EnvSecrets: envSecrets,
RepoMode: opts.RepoMode,
RepoSettings: opts.RepoSettings,
})
fmt.Printf(">>> Working directory: %s\n", stateDir)
if opts.RepoMode == cli.RepoModeManage {
fmt.Println(">>> --manage-repo: the first apply imports the existing repo into state.")
fmt.Println(">>> Run with --plan first to review the import + any drift reconciliation.")
}
stack, err := upsertStack(ctx, stackName, projectName, program,
auto.WorkDir(stateDir),
auto.EnvVars(map[string]string{
"PULUMI_BACKEND_URL": backendURL,
"PULUMI_CONFIG_PASSPHRASE": os.Getenv("PULUMI_CONFIG_PASSPHRASE"),
"PULUMI_SKIP_UPDATE_CHECK": "true",
}),
auto.Project(projectSettings),
)
if err != nil {
return fmt.Errorf("creating Pulumi stack: %w", err)
}
if err := stack.SetConfig(ctx, "github:owner", auto.ConfigValue{Value: opts.Owner}); err != nil {
return fmt.Errorf("setting github:owner config: %w", err)
}
switch opts.Action {
case cli.ActionApply:
_, err = stack.Up(ctx, optup.ProgressStreams(os.Stdout), optup.ErrorProgressStreams(os.Stderr))
case cli.ActionPlan:
_, err = stack.Preview(ctx, optpreview.ProgressStreams(os.Stdout), optpreview.ErrorProgressStreams(os.Stderr))
case cli.ActionDestroy:
_, err = stack.Destroy(ctx, optdestroy.ProgressStreams(os.Stdout), optdestroy.ErrorProgressStreams(os.Stderr))
default:
return fmt.Errorf("unknown action: %s", opts.Action)
}
return err
}
// runCreatePrompts asks the user for any missing required --create values
// (visibility, description) when those weren't supplied on the command line.
func runCreatePrompts(opts *cli.Options) error {
needsVisibility := opts.RepoSettings.Visibility == ""
needsDescription := opts.RepoSettings.Description == nil
if !needsVisibility && !needsDescription {
return nil
}
if !prompt.IsInteractive() {
return prompt.ErrNotInteractive
}
p := prompt.New()
fmt.Fprintf(os.Stderr, ">>> Creating %s/%s\n", opts.Owner, opts.Repo)
if needsVisibility {
v, err := p.Choice("Visibility? [public/private] (default: private): ",
"private", []string{"public", "private"})
if err != nil {
return err
}
opts.RepoSettings.Visibility = v
}
if needsDescription {
d, err := p.Line("Description (optional): ")
if err != nil {
return err
}
opts.RepoSettings.Description = &d
}
return nil
}
// ensurePassphrase makes sure PULUMI_CONFIG_PASSPHRASE is set for this process,
// auto-generating and persisting one at <stateDir>/.passphrase if needed.
func ensurePassphrase(stateDir string) error {
if os.Getenv("PULUMI_CONFIG_PASSPHRASE") != "" || os.Getenv("PULUMI_CONFIG_PASSPHRASE_FILE") != "" {
return nil
}
p := filepath.Join(stateDir, ".passphrase")
b, err := os.ReadFile(p)
if err == nil {
_ = os.Setenv("PULUMI_CONFIG_PASSPHRASE", strings.TrimRight(string(b), "\r\n"))
return nil
}
if !os.IsNotExist(err) {
return fmt.Errorf("reading passphrase file: %w", err)
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return fmt.Errorf("generating passphrase: %w", err)
}
pass := hex.EncodeToString(raw)
if err := os.WriteFile(p, []byte(pass+"\n"), 0o600); err != nil {
return fmt.Errorf("writing passphrase file: %w", err)
}
_ = os.Setenv("PULUMI_CONFIG_PASSPHRASE", pass)
return nil
}