diff --git a/cli/cmd_claim.go b/cli/cmd_claim.go new file mode 100644 index 00000000..e11a37db --- /dev/null +++ b/cli/cmd_claim.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "strings" +) + +func claimCommands() []Command { + return []Command{ + {Path: []string{"claim"}, Tier: TierWrite, + Summary: "claim a shared infra resource on the presence board", + Run: runClaim}, + {Path: []string{"release"}, Tier: TierWrite, + Summary: "release a presence claim", + Run: runRelease}, + } +} + +// runClaim parses `: --purpose "..."` in either order (the presence +// script takes the label first, so we can't rely on Go's flag package which +// stops at the first positional). +func runClaim(args []string) error { + var label, purpose string + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--purpose" || a == "-purpose": + if i+1 < len(args) { + purpose = args[i+1] + i++ + } + case strings.HasPrefix(a, "--purpose="): + purpose = strings.TrimPrefix(a, "--purpose=") + case !strings.HasPrefix(a, "-") && label == "": + label = a + } + } + if label == "" { + return fmt.Errorf(`usage: homelab claim : --purpose "what + why"`) + } + return presenceClaim(label, purpose) +} + +func runRelease(args []string) error { + var label string + for _, a := range args { + if !strings.HasPrefix(a, "-") { + label = a + break + } + } + if label == "" { + return fmt.Errorf("usage: homelab release :") + } + return presenceRelease(label) +} diff --git a/cli/cmd_tf.go b/cli/cmd_tf.go new file mode 100644 index 00000000..95e0260b --- /dev/null +++ b/cli/cmd_tf.go @@ -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 )", 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 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 [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 ") + } + 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") +} diff --git a/cli/cmd_tf_test.go b/cli/cmd_tf_test.go new file mode 100644 index 00000000..74f5b9bd --- /dev/null +++ b/cli/cmd_tf_test.go @@ -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) + } + } +} diff --git a/cli/command.go b/cli/command.go new file mode 100644 index 00000000..fd7f4812 --- /dev/null +++ b/cli/command.go @@ -0,0 +1,101 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// Tier classifies whether a command observes (read) or mutates (write) state. +// v0.1 allows everything; the tier is recorded so a classifier hook can gate +// writes later without restructuring (see docs/adr/0005). +type Tier string + +const ( + TierRead Tier = "read" + TierWrite Tier = "write" +) + +// Command is one homelab verb. Path is the token sequence that selects it, +// e.g. ["claim"] or ["tf", "plan"]. Run receives the args after the path. +type Command struct { + Path []string + Tier Tier + Summary string + Run func(args []string) error +} + +// dispatch routes args to the command whose Path is the longest matching prefix +// of args, passing the remaining args to its Run. +func dispatch(reg []Command, args []string) error { + best := -1 + bestLen := 0 + for i, c := range reg { + if len(c.Path) > len(args) { + continue + } + match := true + for j, p := range c.Path { + if args[j] != p { + match = false + break + } + } + if match && len(c.Path) >= bestLen { + best = i + bestLen = len(c.Path) + } + } + if best < 0 { + return fmt.Errorf("unknown command: %q", strings.Join(args, " ")) + } + return reg[best].Run(args[bestLen:]) +} + +// name is the space-joined verb path, e.g. "tf plan". +func (c Command) name() string { return strings.Join(c.Path, " ") } + +// sortedByName returns a copy of reg ordered by verb path for stable output. +func sortedByName(reg []Command) []Command { + out := make([]Command, len(reg)) + copy(out, reg) + sort.Slice(out, func(i, j int) bool { return out[i].name() < out[j].name() }) + return out +} + +// manifestText renders one aligned line per command: " ". +// This is the cheap progressive-discovery entrypoint (see docs/adr/0004). +func manifestText(reg []Command) string { + cmds := sortedByName(reg) + width := 0 + for _, c := range cmds { + if n := len(c.name()); n > width { + width = n + } + } + var b strings.Builder + for _, c := range cmds { + fmt.Fprintf(&b, "%-*s %-5s %s\n", width, c.name(), c.Tier, c.Summary) + } + return b.String() +} + +// manifestJSON renders the registry as a JSON array of {command, tier, summary} +// so agents can parse the full surface in one call. +func manifestJSON(reg []Command) (string, error) { + type entry struct { + Command string `json:"command"` + Tier string `json:"tier"` + Summary string `json:"summary"` + } + entries := make([]entry, 0, len(reg)) + for _, c := range sortedByName(reg) { + entries = append(entries, entry{Command: c.name(), Tier: string(c.Tier), Summary: c.Summary}) + } + b, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/cli/command_test.go b/cli/command_test.go new file mode 100644 index 00000000..e686622d --- /dev/null +++ b/cli/command_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// Tracer bullet: the dispatcher must route `homelab ` to the +// command whose Path is the longest matching prefix of the input tokens, and +// hand the command the remaining args. +func TestDispatchRoutesToLongestPrefixMatch(t *testing.T) { + var gotArgs []string + ran := "" + reg := []Command{ + {Path: []string{"claim"}, Tier: TierWrite, Summary: "claim a resource", + Run: func(a []string) error { ran = "claim"; gotArgs = a; return nil }}, + {Path: []string{"tf", "plan"}, Tier: TierRead, Summary: "plan a stack", + Run: func(a []string) error { ran = "tf plan"; gotArgs = a; return nil }}, + } + + if err := dispatch(reg, []string{"tf", "plan", "vault", "--json"}); err != nil { + t.Fatalf("dispatch returned error: %v", err) + } + if ran != "tf plan" { + t.Fatalf("routed to %q, want %q", ran, "tf plan") + } + if want := []string{"vault", "--json"}; !reflect.DeepEqual(gotArgs, want) { + t.Fatalf("command got args %v, want %v", gotArgs, want) + } +} + +func TestDispatchUnknownCommandErrors(t *testing.T) { + reg := []Command{{Path: []string{"claim"}, Run: func(a []string) error { return nil }}} + if err := dispatch(reg, []string{"bogus"}); err == nil { + t.Fatal("expected error for unknown command, got nil") + } +} + +// The manifest is the progressive-discovery entrypoint: one line per command +// showing the full verb path, its tier, and summary, sorted for stable output. +func TestManifestTextListsEveryCommandWithTier(t *testing.T) { + reg := []Command{ + {Path: []string{"tf", "plan"}, Tier: TierRead, Summary: "plan a stack"}, + {Path: []string{"claim"}, Tier: TierWrite, Summary: "claim a resource"}, + } + out := manifestText(reg) + for _, want := range []string{"claim", "tf plan", "read", "write", "plan a stack", "claim a resource"} { + if !strings.Contains(out, want) { + t.Errorf("manifest text missing %q\n---\n%s", want, out) + } + } + // sorted: claim (c) must appear before tf plan (t) + if strings.Index(out, "claim") > strings.Index(out, "tf plan") { + t.Errorf("manifest not sorted by path:\n%s", out) + } +} + +func TestManifestJSONIsParsableAndTagged(t *testing.T) { + reg := []Command{{Path: []string{"tf", "apply"}, Tier: TierWrite, Summary: "apply a stack"}} + out, err := manifestJSON(reg) + if err != nil { + t.Fatalf("manifestJSON error: %v", err) + } + var got []map[string]string + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("manifest JSON not parsable: %v\n%s", err, out) + } + if len(got) != 1 || got[0]["command"] != "tf apply" || got[0]["tier"] != "write" { + t.Fatalf("unexpected manifest JSON: %v", got) + } +} diff --git a/cli/homelab.go b/cli/homelab.go new file mode 100644 index 00000000..f3ad5f4b --- /dev/null +++ b/cli/homelab.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "strings" +) + +// version is stamped at build time via -ldflags "-X main.version=vX.Y.Z". +var version = "dev" + +// buildRegistry returns every homelab verb. New verb-groups append here. +func buildRegistry() []Command { + var reg []Command + reg = append(reg, claimCommands()...) + reg = append(reg, tfCommands()...) + return reg +} + +// dispatchTop handles the homelab verb surface. handled=false means the args are +// not a homelab verb, so main() falls back to the legacy -use-case path. +func dispatchTop(args []string) (handled bool, err error) { + if len(args) == 0 { + fmt.Print(usage()) + return true, nil + } + switch args[0] { + case "help", "-h", "--help": + fmt.Print(usage()) + return true, nil + case "version", "--version": + fmt.Println("homelab " + version) + return true, nil + case "manifest": + reg := buildRegistry() + if containsArg(args[1:], "--json") { + out, err := manifestJSON(reg) + if err != nil { + return true, err + } + fmt.Println(out) + return true, nil + } + fmt.Print(manifestText(reg)) + return true, nil + } + if strings.HasPrefix(args[0], "-") { + return false, nil + } + reg := buildRegistry() + if !isCommandGroup(reg, args[0]) { + return false, nil + } + return true, dispatch(reg, args) +} + +func isCommandGroup(reg []Command, group string) bool { + for _, c := range reg { + if len(c.Path) > 0 && c.Path[0] == group { + return true + } + } + return false +} + +func containsArg(args []string, want string) bool { + for _, a := range args { + if a == want { + return true + } + } + return false +} + +func usage() string { + var b strings.Builder + fmt.Fprintf(&b, "homelab %s — unified homelab operations CLI\n\n", version) + b.WriteString("Usage:\n homelab [args]\n\nCommands:\n") + for _, line := range strings.Split(strings.TrimRight(manifestText(buildRegistry()), "\n"), "\n") { + if line != "" { + b.WriteString(" " + line + "\n") + } + } + b.WriteString("\n manifest [--json] list all commands (machine-readable with --json)\n") + b.WriteString(" version print version\n") + b.WriteString("\nLegacy webhook use-cases remain available via -use-case=.\n") + return b.String() +} diff --git a/cli/main.go b/cli/main.go index 3b9fee1c..a53f7672 100644 --- a/cli/main.go +++ b/cli/main.go @@ -26,8 +26,16 @@ var ( ) func main() { - err := run() - if err != nil { + // homelab verb surface (work/tf/claim/...) is tried first; if the args are + // not a homelab verb, fall through to the legacy webhook -use-case path. + if handled, err := dispatchTop(os.Args[1:]); handled { + if err != nil { + fmt.Fprintln(os.Stderr, "homelab: "+err.Error()) + os.Exit(1) + } + return + } + if err := run(); err != nil { glog.Errorf("run failed: %s", err.Error()) os.Exit(255) } diff --git a/cli/presence.go b/cli/presence.go new file mode 100644 index 00000000..bcf054d7 --- /dev/null +++ b/cli/presence.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// validPresenceKinds is the fixed label taxonomy accepted by the presence board. +var validPresenceKinds = []string{"node", "host", "stack", "service", "db", "pvc", "infra"} + +// presenceScript locates the presence CLI — homelab WRAPS it, it does not +// reimplement it. Override with HOMELAB_PRESENCE; defaults to ~/code/scripts/presence. +func presenceScript() string { + if p := os.Getenv("HOMELAB_PRESENCE"); p != "" { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return "presence" + } + return filepath.Join(home, "code", "scripts", "presence") +} + +// validateLabel checks a presence label is : with a known kind. +func validateLabel(label string) error { + parts := strings.SplitN(label, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("label must be : (e.g. stack:vault), got %q", label) + } + for _, k := range validPresenceKinds { + if parts[0] == k { + return nil + } + } + return fmt.Errorf("invalid label kind %q; valid kinds: %s", parts[0], strings.Join(validPresenceKinds, ", ")) +} + +// presenceClaim claims label on the board with a purpose note. +func presenceClaim(label, purpose string) error { + if err := validateLabel(label); err != nil { + return err + } + args := []string{"claim", label} + if purpose != "" { + args = append(args, "--purpose", purpose) + } + return runStreaming(presenceScript(), args...) +} + +// presenceRelease releases a prior claim on label. +func presenceRelease(label string) error { + if err := validateLabel(label); err != nil { + return err + } + return runStreaming(presenceScript(), "release", label) +} diff --git a/cli/presence_test.go b/cli/presence_test.go new file mode 100644 index 00000000..3d1596e1 --- /dev/null +++ b/cli/presence_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +func TestValidateLabelAcceptsTaxonomy(t *testing.T) { + good := []string{ + "stack:vault", "service:health", "node:k8s-node1", "db:pg-cluster", + "infra:gpu-operator", "host:proxmox-1", "pvc:dbaas/data", + } + for _, l := range good { + if err := validateLabel(l); err != nil { + t.Errorf("validateLabel(%q) = %v, want nil", l, err) + } + } +} + +func TestValidateLabelRejectsBadLabels(t *testing.T) { + bad := []string{"vault", "stack:", "bogus:x", ":x", "stack", ""} + for _, l := range bad { + if err := validateLabel(l); err == nil { + t.Errorf("validateLabel(%q) = nil, want error", l) + } + } +} diff --git a/cli/repo.go b/cli/repo.go new file mode 100644 index 00000000..ff65e8a4 --- /dev/null +++ b/cli/repo.go @@ -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 ` 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 +} diff --git a/cli/repo_test.go b/cli/repo_test.go new file mode 100644 index 00000000..76cf21a7 --- /dev/null +++ b/cli/repo_test.go @@ -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) + } +} diff --git a/cli/run.go b/cli/run.go new file mode 100644 index 00000000..22e7f17a --- /dev/null +++ b/cli/run.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + "os/exec" +) + +// runStreaming executes name with args, wiring std streams to this process so +// 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). +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.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() +} diff --git a/cli/stack.go b/cli/stack.go new file mode 100644 index 00000000..1cfdd8d0 --- /dev/null +++ b/cli/stack.go @@ -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 /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 /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() } diff --git a/cli/stack_test.go b/cli/stack_test.go new file mode 100644 index 00000000..2967dc18 --- /dev/null +++ b/cli/stack_test.go @@ -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") + } +} diff --git a/cli/update_viktorbarzin_me.go b/cli/update_viktorbarzin_me.go index 1a693a25..c2c1d3f4 100644 --- a/cli/update_viktorbarzin_me.go +++ b/cli/update_viktorbarzin_me.go @@ -103,6 +103,6 @@ func notifyForIPChange(oldIP, newIP net.IP) error { if err != nil { return errors.Wrapf(err, "Error reading response") } - glog.Infof("Response:", string(responseBody)) + glog.Infof("Response: %s", string(responseBody)) return nil }