Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0659cda6 |
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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 <owner/repo> [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 <owner/repo> [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>:<actor_id>[:<mode>]
|
||||
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 <env>.tfvars per env, where
|
||||
<env> 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 <owner>/<repo>")
|
||||
}
|
||||
parts := strings.SplitN(opts.repo, "/", 2)
|
||||
opts.owner, opts.name = parts[0], parts[1]
|
||||
if opts.owner == "" || opts.name == "" {
|
||||
return nil, fmt.Errorf("invalid <owner>/<repo>: %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 <actor_type>:<actor_id>[:<mode>])", 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 <actor_type>:<actor_id>[:<mode>])", 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
|
||||
}
|
||||
+304
@@ -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")
|
||||
}
|
||||
}
|
||||
+604
@@ -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 <owner/repo>") {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user