homelab: add tf verbs + stack/git-crypt substrate
Adds the tf verb-group and the resolver substrate beneath it, continuing the v0.1 infra-loop build. - substrate: findInfraRoot (walk up to terragrunt.hcl + stacks/), stack→dir resolver, and repo/remote/git-crypt detection (preferRemote forgejo>origin, hasGitCryptAttr, gitCryptFlags) — the last is for `work` next. - tf plan/validate/fmt/force-unlock/apply, resolving the stack from cwd and delegating to scripts/tg (which owns state decrypt/encrypt, the Vault lock, and the ingress auth-comment check) rather than calling terragrunt directly. - tf apply is presence-coupled: claims stack:<name>, ALWAYS releases on exit (normal, error, or SIGINT/SIGTERM via sync.Once + signal handler) — fixing the documented ~200-claim leak — and prints an out-of-band reminder since CI applies canonically on push. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ed6f22fd53
commit
36d562c15c
8 changed files with 362 additions and 0 deletions
122
cli/cmd_tf.go
Normal file
122
cli/cmd_tf.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tfCommands() []Command {
|
||||||
|
return []Command{
|
||||||
|
{Path: []string{"tf", "plan"}, Tier: TierRead,
|
||||||
|
Summary: "terragrunt plan a stack (via scripts/tg)", Run: tfPassthrough("plan")},
|
||||||
|
{Path: []string{"tf", "validate"}, Tier: TierRead,
|
||||||
|
Summary: "terragrunt validate a stack", Run: tfPassthrough("validate")},
|
||||||
|
{Path: []string{"tf", "fmt"}, Tier: TierRead,
|
||||||
|
Summary: "terraform fmt a stack's files", Run: tfFmt},
|
||||||
|
{Path: []string{"tf", "force-unlock"}, Tier: TierWrite,
|
||||||
|
Summary: "release a stuck terraform state lock (needs <stack> <lock-id>)", Run: tfForceUnlock},
|
||||||
|
{Path: []string{"tf", "apply"}, Tier: TierWrite,
|
||||||
|
Summary: "terragrunt apply a stack — presence-coupled, out-of-band", Run: tfApply},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstPositional returns the first non-flag arg and the remaining args with it removed.
|
||||||
|
func firstPositional(args []string) (string, []string) {
|
||||||
|
for i, a := range args {
|
||||||
|
if !strings.HasPrefix(a, "-") {
|
||||||
|
rest := append(append([]string{}, args[:i]...), args[i+1:]...)
|
||||||
|
return a, rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", args
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTfStack finds the infra root (from cwd) and the stack directory named
|
||||||
|
// by the first positional arg, returning the remaining args.
|
||||||
|
func resolveTfStack(args []string) (infraRoot, stackName, stackDir string, rest []string, err error) {
|
||||||
|
stackName, rest = firstPositional(args)
|
||||||
|
if stackName == "" {
|
||||||
|
err = fmt.Errorf("missing <stack> argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cwd, e := os.Getwd()
|
||||||
|
if e != nil {
|
||||||
|
err = e
|
||||||
|
return
|
||||||
|
}
|
||||||
|
infraRoot, err = findInfraRoot(cwd)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stackDir, err = resolveStack(infraRoot, stackName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func tgPath(infraRoot string) string { return filepath.Join(infraRoot, "scripts", "tg") }
|
||||||
|
|
||||||
|
// tfPassthrough runs `scripts/tg <verb> [extra]` in the stack directory.
|
||||||
|
func tfPassthrough(verb string) func([]string) error {
|
||||||
|
return func(args []string) error {
|
||||||
|
infraRoot, _, stackDir, rest, err := resolveTfStack(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runStreamingIn(stackDir, tgPath(infraRoot), append([]string{verb}, rest...)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tfFmt(args []string) error {
|
||||||
|
_, _, stackDir, _, err := resolveTfStack(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runStreamingIn(stackDir, "terraform", "fmt", "-recursive", ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tfForceUnlock(args []string) error {
|
||||||
|
infraRoot, _, stackDir, rest, err := resolveTfStack(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(rest) < 1 {
|
||||||
|
return fmt.Errorf("usage: homelab tf force-unlock <stack> <lock-id>")
|
||||||
|
}
|
||||||
|
return runStreamingIn(stackDir, tgPath(infraRoot), "force-unlock", "-force", rest[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// tfApply applies a stack out-of-band: claim the stack on the presence board,
|
||||||
|
// ALWAYS release on exit (normal, error, or signal — fixing the claim leak),
|
||||||
|
// and warn that CI applies canonically on push.
|
||||||
|
func tfApply(args []string) error {
|
||||||
|
infraRoot, stackName, stackDir, _, err := resolveTfStack(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
label := "stack:" + stackName
|
||||||
|
fmt.Fprintf(os.Stderr,
|
||||||
|
"homelab: out-of-band apply of %q — CI applies canonically on push to master.\n", stackName)
|
||||||
|
|
||||||
|
if err := presenceClaim(label, "homelab tf apply "+stackName); err != nil {
|
||||||
|
return fmt.Errorf("presence claim failed (run `vault login -method=oidc`?): %w", err)
|
||||||
|
}
|
||||||
|
// Release exactly once, whether we exit normally, on error, or on signal —
|
||||||
|
// sync.Once makes the defer and the signal goroutine safe to both call it.
|
||||||
|
var once sync.Once
|
||||||
|
release := func() { once.Do(func() { _ = presenceRelease(label) }) }
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sig
|
||||||
|
release()
|
||||||
|
os.Exit(130)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return runStreamingIn(stackDir, tgPath(infraRoot), "apply", "--non-interactive")
|
||||||
|
}
|
||||||
27
cli/cmd_tf_test.go
Normal file
27
cli/cmd_tf_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFirstPositional(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
args []string
|
||||||
|
wantName string
|
||||||
|
wantRest []string
|
||||||
|
}{
|
||||||
|
{[]string{"vault"}, "vault", []string{}},
|
||||||
|
{[]string{"--json", "vault"}, "vault", []string{"--json"}},
|
||||||
|
{[]string{"vault", "abc-123"}, "vault", []string{"abc-123"}},
|
||||||
|
{[]string{"--foo", "monitoring", "extra"}, "monitoring", []string{"--foo", "extra"}},
|
||||||
|
{[]string{"--only-flags"}, "", []string{"--only-flags"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
gotName, gotRest := firstPositional(c.args)
|
||||||
|
if gotName != c.wantName || !reflect.DeepEqual(gotRest, c.wantRest) {
|
||||||
|
t.Errorf("firstPositional(%v) = (%q, %v), want (%q, %v)",
|
||||||
|
c.args, gotName, gotRest, c.wantName, c.wantRest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ var version = "dev"
|
||||||
func buildRegistry() []Command {
|
func buildRegistry() []Command {
|
||||||
var reg []Command
|
var reg []Command
|
||||||
reg = append(reg, claimCommands()...)
|
reg = append(reg, claimCommands()...)
|
||||||
|
reg = append(reg, tfCommands()...)
|
||||||
return reg
|
return reg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
63
cli/repo.go
Normal file
63
cli/repo.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// preferRemote picks the canonical remote: forgejo if present, else origin,
|
||||||
|
// else the first listed. (For infra, origin and forgejo both point at Forgejo.)
|
||||||
|
func preferRemote(remotes []string) string {
|
||||||
|
has := map[string]bool{}
|
||||||
|
for _, r := range remotes {
|
||||||
|
has[r] = true
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case has["forgejo"]:
|
||||||
|
return "forgejo"
|
||||||
|
case has["origin"]:
|
||||||
|
return "origin"
|
||||||
|
case len(remotes) > 0:
|
||||||
|
return remotes[0]
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasGitCryptAttr reports whether .gitattributes content enables git-crypt.
|
||||||
|
func hasGitCryptAttr(gitattributes string) bool {
|
||||||
|
return strings.Contains(gitattributes, "filter=git-crypt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitCryptFlags are the per-command flags that disable smudge/clean so git
|
||||||
|
// operations in a git-crypt repo don't try to decrypt (NEVER persisted to config).
|
||||||
|
func gitCryptFlags() []string {
|
||||||
|
return []string{
|
||||||
|
"-c", "filter.git-crypt.smudge=cat",
|
||||||
|
"-c", "filter.git-crypt.clean=cat",
|
||||||
|
"-c", "filter.git-crypt.required=false",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitOutput runs `git -C dir <args>` and returns trimmed stdout.
|
||||||
|
func gitOutput(dir string, args ...string) (string, error) {
|
||||||
|
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
return strings.TrimSpace(string(out)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitRepoRoot(dir string) (string, error) {
|
||||||
|
return gitOutput(dir, "rev-parse", "--show-toplevel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitRemotes lists configured remote names for the repo at dir.
|
||||||
|
func gitRemotes(dir string) ([]string, error) {
|
||||||
|
out, err := gitOutput(dir, "remote")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if out == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return strings.Split(out, "\n"), nil
|
||||||
|
}
|
||||||
37
cli/repo_test.go
Normal file
37
cli/repo_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPreferRemote(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"origin", "forgejo"}, "forgejo"},
|
||||||
|
{[]string{"forgejo"}, "forgejo"},
|
||||||
|
{[]string{"origin"}, "origin"},
|
||||||
|
{[]string{"upstream"}, "upstream"},
|
||||||
|
{nil, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := preferRemote(c.in); got != c.want {
|
||||||
|
t.Errorf("preferRemote(%v) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasGitCryptAttr(t *testing.T) {
|
||||||
|
if !hasGitCryptAttr("*.tfvars filter=git-crypt diff=git-crypt") {
|
||||||
|
t.Error("expected git-crypt detected")
|
||||||
|
}
|
||||||
|
if hasGitCryptAttr("*.md text\n*.png binary") {
|
||||||
|
t.Error("expected no git-crypt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitCryptFlagsShape(t *testing.T) {
|
||||||
|
f := gitCryptFlags()
|
||||||
|
if len(f) != 6 || f[0] != "-c" || f[1] != "filter.git-crypt.smudge=cat" {
|
||||||
|
t.Fatalf("unexpected git-crypt flags: %v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,13 @@ import (
|
||||||
// the caller sees live output, and returns the command's error (non-nil on
|
// the caller sees live output, and returns the command's error (non-nil on
|
||||||
// non-zero exit — preserved so homelab's own exit code reflects the child's).
|
// non-zero exit — preserved so homelab's own exit code reflects the child's).
|
||||||
func runStreaming(name string, args ...string) error {
|
func runStreaming(name string, args ...string) error {
|
||||||
|
return runStreamingIn("", name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStreamingIn is runStreaming with a working directory (empty = inherit).
|
||||||
|
func runStreamingIn(dir, name string, args ...string) error {
|
||||||
cmd := exec.Command(name, args...)
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Dir = dir
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
|
|
|
||||||
54
cli/stack.go
Normal file
54
cli/stack.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findInfraRoot walks up from start to the infra repo root — the directory
|
||||||
|
// holding both terragrunt.hcl and a stacks/ directory.
|
||||||
|
func findInfraRoot(start string) (string, error) {
|
||||||
|
dir := start
|
||||||
|
for {
|
||||||
|
if isFile(filepath.Join(dir, "terragrunt.hcl")) && isDir(filepath.Join(dir, "stacks")) {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return "", fmt.Errorf("not inside an infra checkout (no terragrunt.hcl + stacks/ found above %s)", start)
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveStack maps a bare stack name to its directory under <infraRoot>/stacks.
|
||||||
|
func resolveStack(infraRoot, name string) (string, error) {
|
||||||
|
dir := filepath.Join(infraRoot, "stacks", name)
|
||||||
|
if isDir(dir) {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
avail := listStacks(infraRoot)
|
||||||
|
return "", fmt.Errorf("stack %q not found under stacks/; available: %s", name, strings.Join(avail, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// listStacks returns the sorted names of every directory under <infraRoot>/stacks.
|
||||||
|
func listStacks(infraRoot string) []string {
|
||||||
|
entries, err := os.ReadDir(filepath.Join(infraRoot, "stacks"))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFile(p string) bool { fi, err := os.Stat(p); return err == nil && !fi.IsDir() }
|
||||||
|
func isDir(p string) bool { fi, err := os.Stat(p); return err == nil && fi.IsDir() }
|
||||||
52
cli/stack_test.go
Normal file
52
cli/stack_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newInfraTree(t *testing.T, stacks ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "terragrunt.hcl"), []byte("# root"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range stacks {
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "stacks", s), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindInfraRootWalksUp(t *testing.T) {
|
||||||
|
root := newInfraTree(t, "vault")
|
||||||
|
got, err := findInfraRoot(filepath.Join(root, "stacks", "vault"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findInfraRoot error: %v", err)
|
||||||
|
}
|
||||||
|
if got != root {
|
||||||
|
t.Fatalf("findInfraRoot = %q, want %q", got, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindInfraRootErrorsOutsideInfra(t *testing.T) {
|
||||||
|
if _, err := findInfraRoot(t.TempDir()); err == nil {
|
||||||
|
t.Fatal("expected error outside an infra checkout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStack(t *testing.T) {
|
||||||
|
root := newInfraTree(t, "vault", "monitoring")
|
||||||
|
dir, err := resolveStack(root, "vault")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveStack error: %v", err)
|
||||||
|
}
|
||||||
|
if want := filepath.Join(root, "stacks", "vault"); dir != want {
|
||||||
|
t.Fatalf("resolveStack = %q, want %q", dir, want)
|
||||||
|
}
|
||||||
|
if _, err := resolveStack(root, "nonesuch"); err == nil {
|
||||||
|
t.Fatal("expected error for unknown stack")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue