diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9fe2853 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,20 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26.2' + - name: Build & publish precompiled gh extension binaries + uses: cli/gh-extension-precompile@v2 diff --git a/.gitignore b/.gitignore index c279c1d..8c2406a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ terraform.tfvars .DS_Store .idea/ .vscode/ + +# Go build output (the gh extension binary) +/gh-repo-bootstrap +/gh-repo-bootstrap.exe +/dist/ diff --git a/README.md b/README.md index 0a548b1..8bbbb0c 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,22 @@ for applying a standard set of guard-rails to a GitHub repository: gh extension install JMR-dev/gh-repo-bootstrap ``` +`gh` will fetch a precompiled binary for your OS/arch from the latest +release (Linux, macOS, Windows; amd64 + arm64). + You also need: - [`tofu`](https://opentofu.org/docs/intro/install/) on PATH - `gh` already authenticated (`gh auth login`) +### Build from source + +```sh +git clone https://github.com/JMR-dev/gh-repo-bootstrap +cd gh-repo-bootstrap +go build -o gh-repo-bootstrap . +gh extension install . +``` + ## Use (gh extension) ```sh diff --git a/gh-repo-bootstrap b/gh-repo-bootstrap index d2c5371..030dbdd 100755 Binary files a/gh-repo-bootstrap and b/gh-repo-bootstrap differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..72383db --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/JMR-dev/gh-repo-bootstrap + +go 1.21 diff --git a/main.go b/main.go new file mode 100644 index 0000000..30dfe60 --- /dev/null +++ b/main.go @@ -0,0 +1,805 @@ +// gh-repo-bootstrap: apply standard branch protection + environments to a +// GitHub repository, using the bundled OpenTofu `repo` module. +// +// Installed as a gh extension, invoked as: gh repo-bootstrap [opts] +package main + +import ( + "bufio" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "os/signal" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" +) + +//go:embed modules/repo/main.tf modules/repo/variables.tf modules/repo/outputs.tf modules/repo/versions.tf +var moduleFS embed.FS + +const moduleSubpath = "modules/repo" + +const usageText = `Usage: + gh repo-bootstrap [options] + +Apply a standard branch-protection ruleset and a set of deployment +environments to an existing GitHub repository, via OpenTofu. + +Options: + --branch NAME Default branch to protect (default: main) + --reviews N Required PR approving reviews (default: 1) + --signed Require signed commits on the protected branch + --env NAME Add a deployment environment (repeatable) + Default if none given: production + --ruleset NAME Ruleset name (default: default-branch-protection) + --bypass SPEC Add a bypass actor (repeatable). SPEC is + :[:] + actor_type: RepositoryRole | Team | Integration | + OrganizationAdmin | DeployKey + actor_id: numeric ID (built-in repo roles: + 1=read 2=triage 3=write 4=maintain 5=admin) + mode: always | pull_request (default: always) + Shortcut: --solo is equivalent to + --bypass RepositoryRole:5:always + --solo Allow the Admin repo role to bypass the ruleset. + --upload-repo-secrets FILE + Upload repository-level GitHub Actions secrets + sourced from a tfvars-style file. Each non-blank, + non-comment line must be: SECRET_NAME = "value" + Names must match GitHub's rules (alphanumerics + + underscore, no leading digit, no GITHUB_ prefix). + Comments (#, //) and blank lines are allowed. + --upload-env-secrets DIR + Upload environment-level GitHub Actions secrets. + DIR must contain one .tfvars per env, where + matches one of the --env values. + --plan Run ` + "`tofu plan`" + ` instead of ` + "`tofu apply`" + ` + --destroy Run ` + "`tofu destroy`" + ` + --state-dir DIR Override working/state directory + (default: $XDG_STATE_HOME/gh-repo-bootstrap or + ~/.local/state/gh-repo-bootstrap on Unix, + %LOCALAPPDATA%\gh-repo-bootstrap on Windows) + -h, --help Show this help + +Authentication: + GITHUB_TOKEN is auto-populated from ` + "`gh auth token`" + ` if not already set. + +Secrets & state: + Uploaded secret values are sent to GitHub encrypted, but they are + ALSO stored in plaintext in the OpenTofu state file under the + per-repo state directory. Protect that directory accordingly. +` + +func usage() { fmt.Fprint(os.Stderr, usageText) } + +func errf(format string, a ...any) { + fmt.Fprintf(os.Stderr, "gh-repo-bootstrap: "+format+"\n", a...) +} + +type bypassActor struct { + ActorType string + ActorID int + BypassMode string +} + +type options struct { + repo string + owner string + name string + branch string + reviews int + signed bool + ruleset string + envs []string + bypass []bypassActor + action string // apply | plan | destroy + stateDir string + repoSecretsFile string + envSecretsDir string +} + +var ( + validActorTypes = map[string]bool{"RepositoryRole": true, "Team": true, "Integration": true, "OrganizationAdmin": true, "DeployKey": true} + validBypassModes = map[string]bool{"always": true, "pull_request": true} + secretNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + envNameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) + tfvarsLineRe = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)[\t ]*=[\t ]*(.*)$`) +) + +var errHelp = errors.New("help requested") + +func parseArgs(argv []string) (*options, error) { + opts := &options{ + branch: "main", + reviews: 1, + ruleset: "default-branch-protection", + action: "apply", + } + + needValue := func(i int, flag string) (string, error) { + if i+1 >= len(argv) { + return "", fmt.Errorf("flag %s requires a value", flag) + } + v := argv[i+1] + if v == "" { + return "", fmt.Errorf("flag %s requires a non-empty value", flag) + } + return v, nil + } + + for i := 0; i < len(argv); i++ { + a := argv[i] + switch a { + case "-h", "--help": + return nil, errHelp + case "--branch": + v, err := needValue(i, a) + if err != nil { + return nil, err + } + opts.branch = v + i++ + case "--reviews": + v, err := needValue(i, a) + if err != nil { + return nil, err + } + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + return nil, fmt.Errorf("--reviews must be a non-negative integer (got: %s)", v) + } + opts.reviews = n + i++ + case "--signed": + opts.signed = true + case "--ruleset": + v, err := needValue(i, a) + if err != nil { + return nil, err + } + opts.ruleset = v + i++ + case "--env": + v, err := needValue(i, a) + if err != nil { + return nil, err + } + opts.envs = append(opts.envs, v) + i++ + case "--bypass": + v, err := needValue(i, a) + if err != nil { + return nil, err + } + b, err := parseBypassSpec(v) + if err != nil { + return nil, err + } + opts.bypass = append(opts.bypass, b) + i++ + case "--solo": + opts.bypass = append(opts.bypass, bypassActor{ActorType: "RepositoryRole", ActorID: 5, BypassMode: "always"}) + case "--upload-repo-secrets": + v, err := needValue(i, a) + if err != nil { + return nil, err + } + opts.repoSecretsFile = v + i++ + case "--upload-env-secrets": + v, err := needValue(i, a) + if err != nil { + return nil, err + } + opts.envSecretsDir = v + i++ + case "--plan": + opts.action = "plan" + case "--destroy": + opts.action = "destroy" + case "--state-dir": + v, err := needValue(i, a) + if err != nil { + return nil, err + } + opts.stateDir = v + i++ + case "--": + // remaining args are positional; we only accept one (repo) + for _, rest := range argv[i+1:] { + if opts.repo != "" { + return nil, fmt.Errorf("unexpected positional argument: %s", rest) + } + opts.repo = rest + } + i = len(argv) + default: + if strings.HasPrefix(a, "-") { + return nil, fmt.Errorf("unknown option: %s", a) + } + if opts.repo != "" { + return nil, fmt.Errorf("unexpected positional argument: %s", a) + } + opts.repo = a + } + } + + if opts.repo == "" || !strings.Contains(opts.repo, "/") { + return nil, errors.New("first argument must be /") + } + parts := strings.SplitN(opts.repo, "/", 2) + opts.owner, opts.name = parts[0], parts[1] + if opts.owner == "" || opts.name == "" { + return nil, fmt.Errorf("invalid /: %s", opts.repo) + } + + if len(opts.envs) == 0 { + opts.envs = []string{"production"} + } + + return opts, nil +} + +func parseBypassSpec(spec string) (bypassActor, error) { + parts := strings.Split(spec, ":") + if len(parts) < 2 || len(parts) > 3 { + return bypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (expected :[:])", spec) + } + bType := parts[0] + bIDStr := parts[1] + bMode := "always" + if len(parts) == 3 && parts[2] != "" { + bMode = parts[2] + } + if bType == "" || bIDStr == "" { + return bypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (expected :[:])", spec) + } + bID, err := strconv.Atoi(bIDStr) + if err != nil || bID < 0 { + return bypassActor{}, fmt.Errorf("invalid --bypass SPEC %q (actor_id must be numeric)", spec) + } + if !validActorTypes[bType] { + return bypassActor{}, fmt.Errorf("invalid --bypass actor_type %q", bType) + } + if !validBypassModes[bMode] { + return bypassActor{}, fmt.Errorf("invalid --bypass mode %q (must be 'always' or 'pull_request')", bMode) + } + return bypassActor{ActorType: bType, ActorID: bID, BypassMode: bMode}, nil +} + +// hclString returns an HCL/JSON-compatible quoted string literal for s. +// HCL accepts JSON-style escaped strings for double-quoted literals, so this +// is safe for any user-supplied input. +func hclString(s string) string { + b, err := json.Marshal(s) + if err != nil { + // json.Marshal of a string never fails; fall back defensively. + return strconv.Quote(s) + } + return string(b) +} + +func hclStringList(items []string) string { + if len(items) == 0 { + return "[]" + } + parts := make([]string, len(items)) + for i, it := range items { + parts[i] = hclString(it) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +func hclBypassList(items []bypassActor) string { + if len(items) == 0 { + return "[]" + } + parts := make([]string, len(items)) + for i, b := range items { + parts[i] = fmt.Sprintf( + "{ actor_id = %d, actor_type = %s, bypass_mode = %s }", + b.ActorID, hclString(b.ActorType), hclString(b.BypassMode), + ) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +// defaultStateDir mirrors $XDG_STATE_HOME on Unix and %LOCALAPPDATA% on Windows. +func defaultStateDir() (string, error) { + if runtime.GOOS == "windows" { + base := os.Getenv("LOCALAPPDATA") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, "AppData", "Local") + } + return filepath.Join(base, "gh-repo-bootstrap"), nil + } + if v := os.Getenv("XDG_STATE_HOME"); v != "" { + return filepath.Join(v, "gh-repo-bootstrap"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "state", "gh-repo-bootstrap"), nil +} + +// extractModule writes the embedded module .tf files into destDir, replacing +// any prior contents (so stale files from a previous version don't linger). +func extractModule(destDir string) error { + if err := os.RemoveAll(destDir); err != nil { + return fmt.Errorf("clean module dir: %w", err) + } + if err := os.MkdirAll(destDir, 0o700); err != nil { + return fmt.Errorf("create module dir: %w", err) + } + entries, err := fs.ReadDir(moduleFS, moduleSubpath) + if err != nil { + return fmt.Errorf("read embedded module: %w", err) + } + if len(entries) == 0 { + return errors.New("no embedded module files (build error?)") + } + for _, e := range entries { + if e.IsDir() { + continue + } + data, err := fs.ReadFile(moduleFS, moduleSubpath+"/"+e.Name()) + if err != nil { + return fmt.Errorf("read embedded %s: %w", e.Name(), err) + } + if err := os.WriteFile(filepath.Join(destDir, e.Name()), data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", e.Name(), err) + } + } + return nil +} + +func writeMainTF(stateDir, modulePath string, opts *options) error { + var sb strings.Builder + sb.WriteString("# Generated by gh repo-bootstrap. Edits will be overwritten.\n") + sb.WriteString(`terraform { + required_version = ">= 1.8.0" + required_providers { + github = { + source = "integrations/github" + version = "~> 6.2" + } + } +} + +`) + fmt.Fprintf(&sb, "provider \"github\" {\n owner = %s\n}\n\n", hclString(opts.owner)) + fmt.Fprintf(&sb, "module \"repo\" {\n source = %s\n\n", hclString(filepath.ToSlash(modulePath))) + fmt.Fprintf(&sb, " repo_owner = %s\n", hclString(opts.owner)) + fmt.Fprintf(&sb, " repo_name = %s\n", hclString(opts.name)) + fmt.Fprintf(&sb, " default_branch = %s\n", hclString(opts.branch)) + fmt.Fprintf(&sb, " required_reviews = %d\n", opts.reviews) + fmt.Fprintf(&sb, " require_signed_commits = %t\n", opts.signed) + fmt.Fprintf(&sb, " ruleset_name = %s\n", hclString(opts.ruleset)) + fmt.Fprintf(&sb, " environments = %s\n", hclStringList(opts.envs)) + fmt.Fprintf(&sb, " bypass_actors = %s\n", hclBypassList(opts.bypass)) + sb.WriteString("}\n\n") + sb.WriteString(`output "repository_full_name" { value = module.repo.repository_full_name } +output "ruleset_id" { value = module.repo.ruleset_id } +output "environments" { value = module.repo.environments } +`) + return os.WriteFile(filepath.Join(stateDir, "main.tf"), []byte(sb.String()), 0o600) +} + +// ---------------------------------------------------------------------- +// Secrets handling +// ---------------------------------------------------------------------- + +func isValidGHSecretName(n string) bool { + if !secretNameRe.MatchString(n) { + return false + } + if strings.HasPrefix(n, "GITHUB_") { + return false + } + return true +} + +type secretEntry struct { + Name string + Value string +} + +// parseSecretsFile reads a tfvars-style file of `KEY = "value"` lines. +// Comments (# and //) and blank lines are ignored. Values may be double- or +// single-quoted; escape sequences \\ \" \n \r \t are recognized inside +// double-quoted values only. +func parseSecretsFile(path string) ([]secretEntry, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("secrets file not found: %s", path) + } + return nil, fmt.Errorf("secrets file not readable: %s: %w", path, err) + } + defer f.Close() + + var out []secretEntry + seen := map[string]bool{} + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + lineno := 0 + for sc.Scan() { + lineno++ + line := strings.TrimRight(sc.Text(), "\r") + line = strings.TrimLeft(line, " \t") + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + m := tfvarsLineRe.FindStringSubmatch(line) + if m == nil { + return nil, fmt.Errorf("%s:%d: cannot parse line (expected NAME = \"value\"): %s", path, lineno, line) + } + name := m[1] + rest := strings.TrimRight(m[2], " \t") + + var val string + switch { + case len(rest) >= 2 && rest[0] == '"' && rest[len(rest)-1] == '"': + inner := rest[1 : len(rest)-1] + decoded, err := decodeDoubleQuoted(inner) + if err != nil { + return nil, fmt.Errorf("%s:%d: %v", path, lineno, err) + } + val = decoded + case len(rest) >= 2 && rest[0] == '\'' && rest[len(rest)-1] == '\'': + val = rest[1 : len(rest)-1] + default: + return nil, fmt.Errorf("%s:%d: value must be a single quoted string", path, lineno) + } + + if !isValidGHSecretName(name) { + return nil, fmt.Errorf("%s:%d: invalid GitHub secret name %q (alphanumerics + underscore, no leading digit, no GITHUB_ prefix)", path, lineno, name) + } + if seen[name] { + return nil, fmt.Errorf("duplicate secret %q in %s", name, path) + } + seen[name] = true + out = append(out, secretEntry{Name: name, Value: val}) + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if len(out) == 0 { + return nil, fmt.Errorf("%s: no secrets found", path) + } + return out, nil +} + +// decodeDoubleQuoted decodes the limited set of escapes recognized by the +// original bash parser: \\ \" \n \r \t. Other backslash sequences are left +// as-is (matching bash behavior, which only substituted those five forms). +func decodeDoubleQuoted(s string) (string, error) { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c != '\\' { + b.WriteByte(c) + continue + } + if i+1 >= len(s) { + b.WriteByte('\\') + continue + } + nxt := s[i+1] + switch nxt { + case '\\': + b.WriteByte('\\') + case '"': + b.WriteByte('"') + case 'n': + b.WriteByte('\n') + case 'r': + b.WriteByte('\r') + case 't': + b.WriteByte('\t') + default: + b.WriteByte('\\') + b.WriteByte(nxt) + } + i++ + } + return b.String(), nil +} + +// makeSecretsTmpDir creates a chmod-700 temp dir, preferring /dev/shm on Linux +// so plaintext values never touch persistent disk. +func makeSecretsTmpDir() (string, error) { + if runtime.GOOS == "linux" { + if st, err := os.Stat("/dev/shm"); err == nil && st.IsDir() { + if dir, err := os.MkdirTemp("/dev/shm", "gh-repo-bootstrap.*"); err == nil { + _ = os.Chmod(dir, 0o700) + return dir, nil + } + } + } + dir, err := os.MkdirTemp("", "gh-repo-bootstrap.*") + if err != nil { + return "", err + } + _ = os.Chmod(dir, 0o700) + return dir, nil +} + +// generateSecrets writes secrets.tf into stateDir and secrets.auto.tfvars into +// secretsTmpDir. Returns the path to the var-file if any secrets were emitted, +// or "" otherwise. +func generateSecrets(stateDir, secretsTmpDir string, opts *options) (string, error) { + secretsTF := filepath.Join(stateDir, "secrets.tf") + secretsTFVars := filepath.Join(secretsTmpDir, "secrets.auto.tfvars") + + // Always remove any prior secrets.tf so stale resources don't persist. + _ = os.Remove(secretsTF) + + var tfBuf strings.Builder + var varsBuf strings.Builder + any := false + + if opts.repoSecretsFile != "" { + entries, err := parseSecretsFile(opts.repoSecretsFile) + if err != nil { + return "", err + } + tfBuf.WriteString("# Generated by gh repo-bootstrap. Repo-level Actions secrets.\n") + for i, s := range entries { + fmt.Fprintf(&tfBuf, "\nvariable \"rs_%d\" {\n type = string\n sensitive = true\n}\n", i) + fmt.Fprintf(&tfBuf, "resource \"github_actions_secret\" \"rs_%d\" {\n", i) + tfBuf.WriteString(" repository = module.repo.repository_name\n") + fmt.Fprintf(&tfBuf, " secret_name = %s\n", hclString(s.Name)) + fmt.Fprintf(&tfBuf, " plaintext_value = var.rs_%d\n}\n", i) + fmt.Fprintf(&varsBuf, "rs_%d = %s\n", i, hclString(s.Value)) + } + any = true + } + + if opts.envSecretsDir != "" { + st, err := os.Stat(opts.envSecretsDir) + if err != nil || !st.IsDir() { + return "", fmt.Errorf("env-secrets dir not found: %s", opts.envSecretsDir) + } + envSet := map[string]bool{} + for _, e := range opts.envs { + envSet[e] = true + } + matches, err := filepath.Glob(filepath.Join(opts.envSecretsDir, "*.tfvars")) + if err != nil { + return "", fmt.Errorf("scan env-secrets dir: %w", err) + } + if len(matches) == 0 { + return "", fmt.Errorf("no *.tfvars files in %s", opts.envSecretsDir) + } + // filepath.Glob returns lexically sorted results; preserve that order + // so resource indices stay stable across runs. + tfBuf.WriteString("\n# Generated by gh repo-bootstrap. Env-level Actions secrets.\n") + for envIdx, ef := range matches { + base := strings.TrimSuffix(filepath.Base(ef), ".tfvars") + if !envNameRe.MatchString(base) { + return "", fmt.Errorf("invalid env name derived from filename: %s (basename must match [A-Za-z][A-Za-z0-9_-]*)", ef) + } + if !envSet[base] { + return "", fmt.Errorf("env-secrets file %q targets env %q which is not in --env list (%s)", ef, base, strings.Join(opts.envs, " ")) + } + entries, err := parseSecretsFile(ef) + if err != nil { + return "", err + } + fmt.Fprintf(&tfBuf, "\n# env: %s (source: %s)\n", base, ef) + for i, s := range entries { + fmt.Fprintf(&tfBuf, "variable \"es_%d_%d\" {\n type = string\n sensitive = true\n}\n", envIdx, i) + fmt.Fprintf(&tfBuf, "resource \"github_actions_environment_secret\" \"es_%d_%d\" {\n", envIdx, i) + tfBuf.WriteString(" repository = module.repo.repository_name\n") + fmt.Fprintf(&tfBuf, " environment = module.repo.environments_by_name[%s].environment\n", hclString(base)) + fmt.Fprintf(&tfBuf, " secret_name = %s\n", hclString(s.Name)) + fmt.Fprintf(&tfBuf, " plaintext_value = var.es_%d_%d\n}\n", envIdx, i) + fmt.Fprintf(&varsBuf, "es_%d_%d = %s\n", envIdx, i, hclString(s.Value)) + } + } + any = true + } + + if !any { + return "", nil + } + + if err := os.WriteFile(secretsTF, []byte(tfBuf.String()), 0o600); err != nil { + return "", fmt.Errorf("write secrets.tf: %w", err) + } + if err := os.WriteFile(secretsTFVars, []byte(varsBuf.String()), 0o600); err != nil { + return "", fmt.Errorf("write secrets.auto.tfvars: %w", err) + } + return secretsTFVars, nil +} + +// ---------------------------------------------------------------------- +// Auth + tofu invocation +// ---------------------------------------------------------------------- + +func ensureGitHubToken() (string, error) { + if v := os.Getenv("GITHUB_TOKEN"); v != "" { + return v, nil + } + out, err := exec.Command("gh", "auth", "token").Output() + if err != nil { + return "", errors.New("no GITHUB_TOKEN set and `gh auth token` failed; run `gh auth login` first") + } + tok := strings.TrimSpace(string(out)) + if tok == "" { + return "", errors.New("`gh auth token` returned an empty token; run `gh auth login` first") + } + return tok, nil +} + +// runTofu executes tofu in workDir with stdio attached. It forwards SIGINT to +// the child and waits for the child to exit before returning, so callers can +// safely defer cleanup of any temporary files used as -var-file inputs. +func runTofu(ctx context.Context, workDir, token string, args ...string) error { + cmd := exec.Command("tofu", args...) + cmd.Dir = workDir + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + // Inherit env, ensure GITHUB_TOKEN is set. + env := os.Environ() + env = append(env, "GITHUB_TOKEN="+token) + cmd.Env = env + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start tofu: %w", err) + } + + // Forward signals to the child; do not exit the parent until child exits. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + defer signal.Stop(sigCh) + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + for { + select { + case sig := <-sigCh: + // Best-effort: forward to child. tofu handles SIGINT cleanly. + _ = cmd.Process.Signal(sig) + case <-ctx.Done(): + _ = cmd.Process.Signal(os.Interrupt) + case err := <-done: + return err + } + } +} + +func main() { + if err := run(); err != nil { + if errors.Is(err, errHelp) { + usage() + return + } + errf("%v", err) + os.Exit(1) + } +} + +func run() error { + opts, err := parseArgs(os.Args[1:]) + if err != nil { + if !errors.Is(err, errHelp) { + usage() + } + return err + } + + // Tooling check. + if _, err := exec.LookPath("tofu"); err != nil { + return errors.New("OpenTofu (`tofu`) is required but not on PATH. Install: https://opentofu.org/docs/intro/install/") + } + if _, err := exec.LookPath("gh"); err != nil { + return errors.New("the GitHub CLI (`gh`) is required but not on PATH") + } + + if opts.stateDir == "" { + base, err := defaultStateDir() + if err != nil { + return fmt.Errorf("compute default state dir: %w", err) + } + opts.stateDir = filepath.Join(base, opts.owner+"__"+opts.name) + } + if err := os.MkdirAll(opts.stateDir, 0o700); err != nil { + return fmt.Errorf("create state dir %s: %w", opts.stateDir, err) + } + // Best-effort tightening on already-existing dirs (no-op on Windows). + _ = os.Chmod(opts.stateDir, 0o700) + + // Extract bundled module fresh each run so updates propagate and stale + // files from prior versions are removed. + moduleDir := filepath.Join(opts.stateDir, ".module") + if err := extractModule(moduleDir); err != nil { + return err + } + + if err := writeMainTF(opts.stateDir, moduleDir, opts); err != nil { + return fmt.Errorf("write main.tf: %w", err) + } + + // Secrets: temp dir first (so we can defer cleanup before any failure + // path that might have written values). + secretsTmpDir, err := makeSecretsTmpDir() + if err != nil { + return fmt.Errorf("create secrets temp dir: %w", err) + } + defer func() { _ = os.RemoveAll(secretsTmpDir) }() + + varFile, err := generateSecrets(opts.stateDir, secretsTmpDir, opts) + if err != nil { + return err + } + + token, err := ensureGitHubToken() + if err != nil { + return err + } + + fmt.Printf(">>> Working directory: %s\n", opts.stateDir) + + ctx, cancel := signalContext() + defer cancel() + + if err := runTofu(ctx, opts.stateDir, token, "init", "-input=false", "-upgrade"); err != nil { + return fmt.Errorf("tofu init: %w", err) + } + + var tofuArgs []string + switch opts.action { + case "apply": + tofuArgs = []string{"apply", "-input=false", "-auto-approve"} + case "plan": + tofuArgs = []string{"plan", "-input=false"} + case "destroy": + tofuArgs = []string{"destroy", "-input=false", "-auto-approve"} + default: + return fmt.Errorf("internal error: unknown action %q", opts.action) + } + if varFile != "" { + tofuArgs = append(tofuArgs, "-var-file="+varFile) + } + if err := runTofu(ctx, opts.stateDir, token, tofuArgs...); err != nil { + return fmt.Errorf("tofu %s: %w", opts.action, err) + } + return nil +} + +// signalContext returns a context cancelled on the first os.Interrupt. The +// child-process forwarding in runTofu handles the actual signal propagation; +// this context is mainly here for future use and clean cancellation of any +// non-tofu work. +func signalContext() (context.Context, func()) { + ctx, cancel := context.WithCancel(context.Background()) + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt) + go func() { + select { + case <-ch: + cancel() + case <-ctx.Done(): + } + signal.Stop(ch) + }() + return ctx, cancel +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..ded7a3c --- /dev/null +++ b/main_test.go @@ -0,0 +1,304 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestHCLString(t *testing.T) { + cases := map[string]string{ + `hello`: `"hello"`, + `with "quote"`: `"with \"quote\""`, + "with\nnewline": `"with\nnewline"`, + `back\slash`: `"back\\slash"`, + `tab here`: `"tab\there"`, + `${interp}`: `"${interp}"`, + `C:\Users\me\dir`: `"C:\\Users\\me\\dir"`, + } + for in, want := range cases { + if got := hclString(in); got != want { + t.Errorf("hclString(%q) = %s, want %s", in, got, want) + } + } +} + +func TestHCLStringList(t *testing.T) { + if got := hclStringList(nil); got != "[]" { + t.Errorf("nil list: %s", got) + } + got := hclStringList([]string{"a", "weird\"name", "ok"}) + want := `["a", "weird\"name", "ok"]` + if got != want { + t.Errorf("got %s want %s", got, want) + } +} + +func TestHCLBypassList(t *testing.T) { + got := hclBypassList([]bypassActor{ + {ActorType: "RepositoryRole", ActorID: 5, BypassMode: "always"}, + {ActorType: "Team", ActorID: 42, BypassMode: "pull_request"}, + }) + want := `[{ actor_id = 5, actor_type = "RepositoryRole", bypass_mode = "always" }, { actor_id = 42, actor_type = "Team", bypass_mode = "pull_request" }]` + if got != want { + t.Errorf("got %s want %s", got, want) + } +} + +func TestParseBypassSpec(t *testing.T) { + good := []struct { + in string + want bypassActor + }{ + {"RepositoryRole:5", bypassActor{"RepositoryRole", 5, "always"}}, + {"Team:42:pull_request", bypassActor{"Team", 42, "pull_request"}}, + {"Integration:1:always", bypassActor{"Integration", 1, "always"}}, + } + for _, c := range good { + got, err := parseBypassSpec(c.in) + if err != nil || got != c.want { + t.Errorf("parseBypassSpec(%q) = %+v, %v; want %+v", c.in, got, err, c.want) + } + } + bad := []string{"", "Team", "Team:abc", "Bogus:1", "Team:1:weird", "Team:-1"} + for _, in := range bad { + if _, err := parseBypassSpec(in); err == nil { + t.Errorf("parseBypassSpec(%q) expected error", in) + } + } +} + +func TestIsValidGHSecretName(t *testing.T) { + good := []string{"FOO", "foo_bar", "_X", "A1"} + bad := []string{"", "1FOO", "GITHUB_TOKEN", "with space", "dash-name", "GITHUB_X"} + for _, n := range good { + if !isValidGHSecretName(n) { + t.Errorf("expected %q valid", n) + } + } + for _, n := range bad { + if isValidGHSecretName(n) { + t.Errorf("expected %q invalid", n) + } + } +} + +func TestDecodeDoubleQuoted(t *testing.T) { + cases := map[string]string{ + ``: ``, + `hello`: `hello`, + `a\nb`: "a\nb", + `a\rb\tc`: "a\rb\tc", + `back\\slash`: `back\slash`, + `q\"x`: `q"x`, + `unknown\zescape`: `unknown\zescape`, + `trailing\`: `trailing\`, + } + for in, want := range cases { + got, err := decodeDoubleQuoted(in) + if err != nil || got != want { + t.Errorf("decode(%q) = %q, %v; want %q", in, got, err, want) + } + } +} + +func TestParseSecretsFile(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "good.tfvars") + os.WriteFile(good, []byte(`# header comment +// other style + +API_TOKEN = "abc123" +WEBHOOK = "with \"quotes\" and \n newline" +SINGLE = 'no escapes here \n' +`), 0o600) + entries, err := parseSecretsFile(good) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(entries) != 3 { + t.Fatalf("got %d entries: %+v", len(entries), entries) + } + if entries[0] != (secretEntry{"API_TOKEN", "abc123"}) { + t.Errorf("entry0 = %+v", entries[0]) + } + if entries[1].Value != "with \"quotes\" and \n newline" { + t.Errorf("entry1 value = %q", entries[1].Value) + } + if entries[2].Value != `no escapes here \n` { + t.Errorf("entry2 value = %q (single quotes don't decode escapes)", entries[2].Value) + } + + // Duplicate + dup := filepath.Join(dir, "dup.tfvars") + os.WriteFile(dup, []byte("X=\"a\"\nX=\"b\"\n"), 0o600) + if _, err := parseSecretsFile(dup); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Errorf("dup err: %v", err) + } + + // Bad name + badname := filepath.Join(dir, "bad.tfvars") + os.WriteFile(badname, []byte("GITHUB_TOKEN = \"x\"\n"), 0o600) + if _, err := parseSecretsFile(badname); err == nil { + t.Errorf("expected reserved-name error") + } + + // Empty + empty := filepath.Join(dir, "empty.tfvars") + os.WriteFile(empty, []byte("# only comments\n\n"), 0o600) + if _, err := parseSecretsFile(empty); err == nil { + t.Errorf("expected empty-file error") + } + + // Unparseable line + junk := filepath.Join(dir, "junk.tfvars") + os.WriteFile(junk, []byte("not a valid line\n"), 0o600) + if _, err := parseSecretsFile(junk); err == nil { + t.Errorf("expected parse error") + } + + // Missing file + if _, err := parseSecretsFile(filepath.Join(dir, "nope.tfvars")); err == nil { + t.Errorf("expected not-found error") + } +} + +func TestParseArgsBasics(t *testing.T) { + opts, err := parseArgs([]string{"owner/repo"}) + if err != nil { + t.Fatal(err) + } + if opts.owner != "owner" || opts.name != "repo" || opts.action != "apply" || len(opts.envs) != 1 || opts.envs[0] != "production" { + t.Errorf("defaults wrong: %+v", opts) + } + + opts, err = parseArgs([]string{ + "o/r", "--branch", "trunk", "--reviews", "3", "--signed", + "--env", "production", "--env", "staging", "--solo", "--plan", + }) + if err != nil { + t.Fatal(err) + } + if opts.branch != "trunk" || opts.reviews != 3 || !opts.signed || opts.action != "plan" { + t.Errorf("opts wrong: %+v", opts) + } + if len(opts.envs) != 2 || opts.envs[1] != "staging" { + t.Errorf("envs wrong: %v", opts.envs) + } + if len(opts.bypass) != 1 || opts.bypass[0].ActorType != "RepositoryRole" || opts.bypass[0].ActorID != 5 { + t.Errorf("solo bypass wrong: %+v", opts.bypass) + } + + bad := [][]string{ + {}, + {"no-slash"}, + {"o/r", "--reviews", "abc"}, + {"o/r", "--branch"}, // missing value + {"o/r", "--unknown"}, + {"o/r", "extra"}, + {"o/r", "--bypass", "Bogus:1"}, + } + for _, args := range bad { + if _, err := parseArgs(args); err == nil { + t.Errorf("expected error for %v", args) + } + } +} + +func TestExtractModuleAndWriteMainTF(t *testing.T) { + dir := t.TempDir() + mod := filepath.Join(dir, ".module") + if err := extractModule(mod); err != nil { + t.Fatalf("extract: %v", err) + } + for _, want := range []string{"main.tf", "variables.tf", "outputs.tf", "versions.tf"} { + if _, err := os.Stat(filepath.Join(mod, want)); err != nil { + t.Errorf("missing embedded file %s: %v", want, err) + } + } + + // Stale-file removal: drop a junk file then re-extract. + junk := filepath.Join(mod, "stale.tf") + os.WriteFile(junk, []byte("bogus"), 0o600) + if err := extractModule(mod); err != nil { + t.Fatalf("re-extract: %v", err) + } + if _, err := os.Stat(junk); !os.IsNotExist(err) { + t.Errorf("stale file not removed: %v", err) + } + + opts := &options{ + owner: "o", name: "r", branch: `weird"branch`, reviews: 2, + signed: true, ruleset: "rs", envs: []string{"production", "staging"}, + bypass: []bypassActor{{"RepositoryRole", 5, "always"}}, + } + if err := writeMainTF(dir, mod, opts); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(dir, "main.tf")) + if err != nil { + t.Fatal(err) + } + s := string(got) + if !strings.Contains(s, `default_branch = "weird\"branch"`) { + t.Errorf("branch not escaped:\n%s", s) + } + if !strings.Contains(s, `environments = ["production", "staging"]`) { + t.Errorf("envs missing:\n%s", s) + } + if !strings.Contains(s, `require_signed_commits = true`) { + t.Errorf("signed missing") + } + if !strings.Contains(s, `actor_id = 5`) { + t.Errorf("bypass missing") + } +} + +func TestGenerateSecrets(t *testing.T) { + tmp := t.TempDir() + state := filepath.Join(tmp, "state") + os.MkdirAll(state, 0o700) + tmpd := filepath.Join(tmp, "tmp") + os.MkdirAll(tmpd, 0o700) + + repoSec := filepath.Join(tmp, "repo.tfvars") + os.WriteFile(repoSec, []byte("API = \"abc\"\nTOKEN=\"xyz\"\n"), 0o600) + + envDir := filepath.Join(tmp, "envs") + os.MkdirAll(envDir, 0o700) + os.WriteFile(filepath.Join(envDir, "production.tfvars"), []byte("DB = \"prod\"\n"), 0o600) + os.WriteFile(filepath.Join(envDir, "staging.tfvars"), []byte("DB = \"stage\"\n"), 0o600) + + opts := &options{ + repoSecretsFile: repoSec, + envSecretsDir: envDir, + envs: []string{"production", "staging"}, + } + vf, err := generateSecrets(state, tmpd, opts) + if err != nil { + t.Fatal(err) + } + if vf == "" { + t.Fatal("expected var-file path") + } + tfb, _ := os.ReadFile(filepath.Join(state, "secrets.tf")) + tf := string(tfb) + if !strings.Contains(tf, `secret_name = "API"`) || !strings.Contains(tf, `secret_name = "TOKEN"`) { + t.Errorf("repo secrets missing: %s", tf) + } + if !strings.Contains(tf, `module.repo.environments_by_name["production"]`) || !strings.Contains(tf, `module.repo.environments_by_name["staging"]`) { + t.Errorf("env secrets missing: %s", tf) + } + vfb, _ := os.ReadFile(vf) + v := string(vfb) + if !strings.Contains(v, `rs_0 = "abc"`) || !strings.Contains(v, `rs_1 = "xyz"`) { + t.Errorf("repo tfvars missing: %s", v) + } + + // Env not in --env list -> error + os.WriteFile(filepath.Join(envDir, "rogue.tfvars"), []byte("X = \"y\"\n"), 0o600) + if _, err := generateSecrets(state, tmpd, opts); err == nil { + t.Errorf("expected rogue-env error") + } +} diff --git a/runtime_test.go b/runtime_test.go new file mode 100644 index 0000000..1555b25 --- /dev/null +++ b/runtime_test.go @@ -0,0 +1,604 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +// captureStderr redirects os.Stderr for the duration of fn and returns what +// was written. Used to verify the side-effecting print helpers without +// asserting against the real terminal. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stderr + os.Stderr = w + defer func() { os.Stderr = orig }() + + done := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + + fn() + w.Close() + return <-done +} + +func TestUsageAndErrf(t *testing.T) { + out := captureStderr(t, func() { usage() }) + if !strings.Contains(out, "gh repo-bootstrap ") { + t.Errorf("usage output missing header:\n%s", out) + } + + out = captureStderr(t, func() { errf("hello %s %d", "world", 42) }) + want := "gh-repo-bootstrap: hello world 42\n" + if out != want { + t.Errorf("errf = %q want %q", out, want) + } +} + +func TestParseArgsHelp(t *testing.T) { + for _, flag := range []string{"-h", "--help"} { + _, err := parseArgs([]string{flag}) + if !errors.Is(err, errHelp) { + t.Errorf("expected errHelp for %s, got %v", flag, err) + } + } +} + +func TestParseArgsAllFlags(t *testing.T) { + args := []string{ + "o/r", + "--branch", "trunk", + "--reviews", "0", + "--signed", + "--ruleset", "rs", + "--env", "production", + "--env", "staging", + "--bypass", "Team:7:pull_request", + "--upload-repo-secrets", "/tmp/repo.tfvars", + "--upload-env-secrets", "/tmp/envs", + "--state-dir", "/tmp/state", + "--destroy", + } + opts, err := parseArgs(args) + if err != nil { + t.Fatal(err) + } + want := &options{ + repo: "o/r", owner: "o", name: "r", + branch: "trunk", reviews: 0, signed: true, ruleset: "rs", + envs: []string{"production", "staging"}, + bypass: []bypassActor{{"Team", 7, "pull_request"}}, + repoSecretsFile: "/tmp/repo.tfvars", + envSecretsDir: "/tmp/envs", + stateDir: "/tmp/state", + action: "destroy", + } + if !reflect.DeepEqual(opts, want) { + t.Errorf("got %+v\nwant %+v", opts, want) + } +} + +func TestParseArgsDoubleDash(t *testing.T) { + // Positional repo after `--`. + opts, err := parseArgs([]string{"--", "o/r"}) + if err != nil || opts.repo != "o/r" { + t.Errorf("got %+v err %v", opts, err) + } + // Two positionals after `--` is an error. + if _, err := parseArgs([]string{"--", "o/r", "extra"}); err == nil { + t.Errorf("expected error for two positionals after --") + } +} + +func TestParseArgsMissingValue(t *testing.T) { + for _, args := range [][]string{ + {"o/r", "--branch"}, + {"o/r", "--reviews"}, + {"o/r", "--ruleset"}, + {"o/r", "--env"}, + {"o/r", "--bypass"}, + {"o/r", "--upload-repo-secrets"}, + {"o/r", "--upload-env-secrets"}, + {"o/r", "--state-dir"}, + } { + if _, err := parseArgs(args); err == nil { + t.Errorf("expected missing-value error for %v", args) + } + } +} + +func TestParseArgsEmptyOwnerName(t *testing.T) { + if _, err := parseArgs([]string{"/repo"}); err == nil { + t.Errorf("expected error for empty owner") + } + if _, err := parseArgs([]string{"owner/"}); err == nil { + t.Errorf("expected error for empty repo") + } +} + +func TestDefaultStateDir(t *testing.T) { + t.Setenv("HOME", "/home/test") + + if runtime.GOOS == "windows" { + t.Setenv("LOCALAPPDATA", `C:\Users\test\AppData\Local`) + got, err := defaultStateDir() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(`C:\Users\test\AppData\Local`, "gh-repo-bootstrap") + if got != want { + t.Errorf("got %s want %s", got, want) + } + t.Setenv("LOCALAPPDATA", "") + got, err = defaultStateDir() + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(got, filepath.Join("AppData", "Local", "gh-repo-bootstrap")) { + t.Errorf("fallback path wrong: %s", got) + } + return + } + + t.Setenv("XDG_STATE_HOME", "/xdg/state") + got, err := defaultStateDir() + if err != nil { + t.Fatal(err) + } + if got != "/xdg/state/gh-repo-bootstrap" { + t.Errorf("XDG path wrong: %s", got) + } + + t.Setenv("XDG_STATE_HOME", "") + got, err = defaultStateDir() + if err != nil { + t.Fatal(err) + } + if got != "/home/test/.local/state/gh-repo-bootstrap" { + t.Errorf("home fallback wrong: %s", got) + } +} + +func TestExtractModuleErrors(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission semantics differ on Windows") + } + // Target a path under a regular file: MkdirAll will fail. + tmp := t.TempDir() + blocker := filepath.Join(tmp, "block") + if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + target := filepath.Join(blocker, "child") + if err := extractModule(target); err == nil { + t.Errorf("expected mkdir error under regular file") + } +} + +func TestMakeSecretsTmpDir(t *testing.T) { + dir, err := makeSecretsTmpDir() + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + st, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if !st.IsDir() { + t.Errorf("expected directory: %s", dir) + } + // The dir must be writable. + probe := filepath.Join(dir, "probe") + if err := os.WriteFile(probe, []byte("x"), 0o600); err != nil { + t.Errorf("tmpdir not writable: %v", err) + } +} + +func TestEnsureGitHubTokenFromEnv(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "from-env") + tok, err := ensureGitHubToken() + if err != nil || tok != "from-env" { + t.Errorf("env token: %q %v", tok, err) + } +} + +func TestEnsureGitHubTokenFromFakeGh(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("posix shell required for fake gh") + } + bin := t.TempDir() + // Print token *with trailing whitespace* to verify TrimSpace. + gh := "#!/bin/sh\nprintf 'tok-from-gh \\n'\n" + if err := os.WriteFile(filepath.Join(bin, "gh"), []byte(gh), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + t.Setenv("GITHUB_TOKEN", "") + tok, err := ensureGitHubToken() + if err != nil || tok != "tok-from-gh" { + t.Errorf("fake gh token: %q %v", tok, err) + } +} + +func TestEnsureGitHubTokenEmptyFromGh(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("posix shell required for fake gh") + } + bin := t.TempDir() + gh := "#!/bin/sh\necho ''\n" + os.WriteFile(filepath.Join(bin, "gh"), []byte(gh), 0o755) + t.Setenv("PATH", bin) + t.Setenv("GITHUB_TOKEN", "") + if _, err := ensureGitHubToken(); err == nil || !strings.Contains(err.Error(), "empty token") { + t.Errorf("expected empty-token error, got %v", err) + } +} + +func TestEnsureGitHubTokenGhMissing(t *testing.T) { + bin := t.TempDir() // no gh inside + t.Setenv("PATH", bin) + t.Setenv("GITHUB_TOKEN", "") + if _, err := ensureGitHubToken(); err == nil { + t.Errorf("expected error when gh is absent") + } +} + +// fakeBin builds a tempdir containing fake gh and tofu shell scripts and +// returns the directory. The fake tofu logs each invocation's args to +// $tmpdir/tofu.log and exits with status from $tmpdir/tofu.exit (default 0). +func fakeBin(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("posix shell required for fake binaries") + } + bin := t.TempDir() + gh := "#!/bin/sh\necho fake-token-from-gh\n" + tofu := `#!/bin/sh +echo "$@" >> "$BIN/tofu.log" +exit "$(cat "$BIN/tofu.exit" 2>/dev/null || echo 0)" +` + if err := os.WriteFile(filepath.Join(bin, "gh"), []byte(gh), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "tofu"), []byte(tofu), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("BIN", bin) + // Tests that use this helper need /bin and /usr/bin on PATH so the fake + // tofu shell script can resolve `cat`. Tests that specifically want + // missing tools should set their own PATH. + t.Setenv("PATH", bin+":/usr/bin:/bin") + return bin +} + +func TestRunTofuSuccessAndFailure(t *testing.T) { + bin := fakeBin(t) + + wd := t.TempDir() + if err := runTofu(t.Context(), wd, "tok", "init", "-input=false"); err != nil { + t.Errorf("expected success: %v", err) + } + got, _ := os.ReadFile(filepath.Join(bin, "tofu.log")) + if !strings.Contains(string(got), "init -input=false") { + t.Errorf("tofu not invoked correctly: %s", got) + } + + // Force a non-zero exit and verify error propagation. + os.WriteFile(filepath.Join(bin, "tofu.exit"), []byte("3\n"), 0o600) + if err := runTofu(t.Context(), wd, "tok", "plan"); err == nil { + t.Errorf("expected error from non-zero exit") + } +} + +func TestRunTofuStartFailure(t *testing.T) { + // Empty PATH so exec.LookPath/Start fails inside runTofu. + t.Setenv("PATH", t.TempDir()) + if err := runTofu(t.Context(), t.TempDir(), "tok"); err == nil { + t.Errorf("expected start error with no tofu on PATH") + } +} + +func TestSignalContext(t *testing.T) { + ctx, cancel := signalContext() + defer cancel() + select { + case <-ctx.Done(): + t.Errorf("ctx should not be done immediately") + default: + } + cancel() + <-ctx.Done() // should return promptly +} + +func TestRunEndToEndPlan(t *testing.T) { + bin := fakeBin(t) + state := t.TempDir() + + // Create a repo-secrets file so generateSecrets actually runs and the + // resulting -var-file gets passed to fake tofu. + repoSecrets := filepath.Join(t.TempDir(), "repo.tfvars") + os.WriteFile(repoSecrets, []byte("API = \"abc\"\n"), 0o600) + + t.Setenv("GITHUB_TOKEN", "") // force gh fallback + + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"gh-repo-bootstrap", + "owner/repo", + "--plan", + "--state-dir", state, + "--upload-repo-secrets", repoSecrets, + } + + if err := run(); err != nil { + t.Fatalf("run() failed: %v", err) + } + + // Expect main.tf and secrets.tf in state dir. + for _, want := range []string{"main.tf", "secrets.tf", filepath.Join(".module", "main.tf")} { + if _, err := os.Stat(filepath.Join(state, want)); err != nil { + t.Errorf("missing %s: %v", want, err) + } + } + // Verify tofu was called for init then plan, with -var-file on plan. + log, _ := os.ReadFile(filepath.Join(bin, "tofu.log")) + ls := string(log) + if !strings.Contains(ls, "init -input=false -upgrade") { + t.Errorf("init not logged: %s", ls) + } + if !strings.Contains(ls, "plan -input=false -var-file=") { + t.Errorf("plan with var-file not logged: %s", ls) + } +} + +func TestRunEndToEndApplyAndDestroy(t *testing.T) { + bin := fakeBin(t) + t.Setenv("GITHUB_TOKEN", "preset") + + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + + for _, action := range []string{"--destroy"} { // apply is the default + state := t.TempDir() + os.Args = []string{"gh-repo-bootstrap", "o/r", action, "--state-dir", state} + if err := run(); err != nil { + t.Fatalf("%s: run failed: %v", action, err) + } + } + + // Default apply path. + state := t.TempDir() + os.Args = []string{"gh-repo-bootstrap", "o/r", "--state-dir", state} + if err := run(); err != nil { + t.Fatalf("apply: run failed: %v", err) + } + + log, _ := os.ReadFile(filepath.Join(bin, "tofu.log")) + ls := string(log) + for _, want := range []string{ + "destroy -input=false -auto-approve", + "apply -input=false -auto-approve", + } { + if !strings.Contains(ls, want) { + t.Errorf("missing %q in log:\n%s", want, ls) + } + } +} + +func TestRunBadArgs(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"gh-repo-bootstrap", "no-slash"} + // Capture stderr so test output stays clean. + captureStderr(t, func() { + if err := run(); err == nil { + t.Errorf("expected error for bad args") + } + }) +} + +func TestRunHelp(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"gh-repo-bootstrap", "--help"} + err := run() + if !errors.Is(err, errHelp) { + t.Errorf("expected errHelp, got %v", err) + } +} + +func TestRunMissingTools(t *testing.T) { + // PATH with neither gh nor tofu. + t.Setenv("PATH", t.TempDir()) + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"gh-repo-bootstrap", "o/r"} + captureStderr(t, func() { + if err := run(); err == nil { + t.Errorf("expected error when tofu/gh missing") + } + }) +} + +func TestRunDefaultStateDir(t *testing.T) { + // Without --state-dir, run() resolves XDG_STATE_HOME and creates the dir. + bin := fakeBin(t) + xdg := t.TempDir() + t.Setenv("XDG_STATE_HOME", xdg) + t.Setenv("GITHUB_TOKEN", "preset") + + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"gh-repo-bootstrap", "o/r", "--plan"} + if err := run(); err != nil { + t.Fatalf("run: %v", err) + } + want := filepath.Join(xdg, "gh-repo-bootstrap", "o__r", "main.tf") + if _, err := os.Stat(want); err != nil { + t.Errorf("expected default state dir to be used: %v", err) + } + _ = bin +} + +func TestRunStateDirCreateFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission semantics differ on Windows") + } + _ = fakeBin(t) // PATH gets gh+tofu + t.Setenv("GITHUB_TOKEN", "preset") + + tmp := t.TempDir() + blocker := filepath.Join(tmp, "block") + os.WriteFile(blocker, []byte("x"), 0o600) + bad := filepath.Join(blocker, "child") + + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"gh-repo-bootstrap", "o/r", "--plan", "--state-dir", bad} + if err := run(); err == nil { + t.Errorf("expected mkdir error") + } +} + +func TestGenerateSecretsErrorPaths(t *testing.T) { + state := t.TempDir() + tmpd := t.TempDir() + + // Env-secrets dir does not exist. + opts := &options{ + envSecretsDir: filepath.Join(state, "nope"), + envs: []string{"production"}, + } + if _, err := generateSecrets(state, tmpd, opts); err == nil { + t.Errorf("expected missing-dir error") + } + + // Env-secrets dir is empty (no .tfvars files). + emptyDir := filepath.Join(state, "empty") + os.MkdirAll(emptyDir, 0o700) + opts.envSecretsDir = emptyDir + if _, err := generateSecrets(state, tmpd, opts); err == nil || + !strings.Contains(err.Error(), "no *.tfvars") { + t.Errorf("expected empty-dir error, got %v", err) + } + + // Env-secrets file has an invalid env-name in the basename. + weirdDir := filepath.Join(state, "weird") + os.MkdirAll(weirdDir, 0o700) + os.WriteFile(filepath.Join(weirdDir, "1bad.tfvars"), []byte("X = \"y\"\n"), 0o600) + opts.envSecretsDir = weirdDir + if _, err := generateSecrets(state, tmpd, opts); err == nil || + !strings.Contains(err.Error(), "invalid env name") { + t.Errorf("expected invalid-env-name error, got %v", err) + } + + // Env-secrets parse error propagates. + parseDir := filepath.Join(state, "parse") + os.MkdirAll(parseDir, 0o700) + os.WriteFile(filepath.Join(parseDir, "production.tfvars"), + []byte("not a valid line\n"), 0o600) + opts.envSecretsDir = parseDir + if _, err := generateSecrets(state, tmpd, opts); err == nil { + t.Errorf("expected parse-error propagation") + } + + // Repo-secrets file does not exist. + opts2 := &options{repoSecretsFile: filepath.Join(state, "nope.tfvars")} + if _, err := generateSecrets(state, tmpd, opts2); err == nil { + t.Errorf("expected missing repo-secrets error") + } +} + +func TestParseSecretsFileUnreadable(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("chmod-based unreadable check not portable / root bypasses perms") + } + dir := t.TempDir() + p := filepath.Join(dir, "locked.tfvars") + os.WriteFile(p, []byte("X = \"y\"\n"), 0o600) + if err := os.Chmod(p, 0o000); err != nil { + t.Skip("cannot chmod 000") + } + defer os.Chmod(p, 0o600) + if _, err := parseSecretsFile(p); err == nil { + t.Errorf("expected unreadable error") + } +} + +func TestParseBypassSpecTooManyParts(t *testing.T) { + if _, err := parseBypassSpec("Team:1:always:extra"); err == nil { + t.Errorf("expected error for 4-part spec") + } + if _, err := parseBypassSpec(":1:always"); err == nil { + t.Errorf("expected error for empty actor_type") + } +} + +// TestMainEntryPoint exercises main() in a subprocess so we can observe its +// exit code / stderr handling without disturbing the test runner. +func TestMainEntryPoint(t *testing.T) { + if os.Getenv("GO_TEST_RUN_MAIN") == "1" { + // Strip the testing flags; user-supplied args follow "--". + for i, a := range os.Args { + if a == "--" { + os.Args = append([]string{os.Args[0]}, os.Args[i+1:]...) + break + } + } + main() + return + } + if runtime.GOOS == "windows" { + t.Skip("subprocess args plumbing differs on Windows; main is a 7-line wrapper") + } + + cases := []struct { + name string + args []string + wantExit int + wantErr string + }{ + {"help", []string{"--help"}, 0, "gh repo-bootstrap"}, + {"badArgs", []string{"no-slash"}, 1, "first argument must be"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=TestMainEntryPoint", "--") + cmd.Args = append(cmd.Args, c.args...) + cmd.Env = append(os.Environ(), "GO_TEST_RUN_MAIN=1") + var stderr bytes.Buffer + cmd.Stderr = &stderr + cmd.Stdout = &stderr + err := cmd.Run() + rc := 0 + if ee, ok := err.(*exec.ExitError); ok { + rc = ee.ExitCode() + } else if err != nil { + t.Fatalf("subprocess: %v", err) + } + if rc != c.wantExit { + t.Errorf("exit=%d want %d (stderr=%s)", rc, c.wantExit, stderr.String()) + } + if !strings.Contains(stderr.String(), c.wantErr) { + t.Errorf("stderr missing %q", c.wantErr) + } + }) + } +}