Compare commits
2 commits
36d562c15c
...
66caa0bf7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66caa0bf7f | ||
|
|
087b415f73 |
11 changed files with 440 additions and 2 deletions
|
|
@ -289,6 +289,7 @@ curl -X POST -H "Authorization: token $TOK" -H 'Content-Type: application/json'
|
|||
```
|
||||
|
||||
## Common Operations
|
||||
- **`homelab` CLI** (`/usr/local/bin/homelab`, source `cli/`): unified infra-ops verbs — run `homelab manifest` to discover the surface (each verb tagged read/write). v0.1 covers the inner loop: `homelab tf plan|fmt|apply <stack>` (wraps `scripts/tg`; `apply` auto-claims presence + releases on exit, warns out-of-band), `homelab claim|release <kind>:<name>`, `homelab work start|land|clean <topic>` (worktree lifecycle; `land` gates on verification, `--verify-cmd`/`--no-verify`). Full docs: `cli/README.md`.
|
||||
- **Deploy new service**: Use `stacks/<existing-service>/` as template. Create stack, add DNS in tfvars, apply platform then service.
|
||||
- **Fix crashed pods**: Run healthcheck first. Safe to delete evicted/failed pods and CrashLoopBackOff pods with >10 restarts.
|
||||
- **OOMKilled**: Check `kubectl describe limitrange tier-defaults -n <ns>`. Increase `resources.limits.memory` in the stack's main.tf.
|
||||
|
|
|
|||
|
|
@ -1,2 +1,68 @@
|
|||
# What is this?
|
||||
This is a CLI to manipulate files in the terraform repo and commit and push them
|
||||
# homelab
|
||||
|
||||
`homelab` is the unified, agent-facing CLI for operating this homelab — one
|
||||
composable, JSON-capable surface for the operations agents run over and over,
|
||||
discovered progressively at runtime. It is grown **in place** from this
|
||||
directory (the former `infra-cli`), and the legacy webhook use-cases still work
|
||||
(see below).
|
||||
|
||||
It encodes *actions*, never *judgment*: methodology (debugging, TDD, review) and
|
||||
third-party/owned MCP servers (e.g. phpIPAM) are deliberately out of scope.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
homelab <command> [args]
|
||||
homelab manifest [--json] # list every verb + its read/write tier (discovery entrypoint)
|
||||
homelab version
|
||||
```
|
||||
|
||||
### v0.1 verbs — the infra inner-loop
|
||||
|
||||
| Command | Tier | What it does |
|
||||
|---|---|---|
|
||||
| `claim <kind>:<name> --purpose "…"` | write | claim a shared resource on the presence board (wraps `scripts/presence`) |
|
||||
| `release <kind>:<name>` | write | release a presence claim |
|
||||
| `tf plan <stack>` | read | `scripts/tg plan` for a stack (resolved from cwd) |
|
||||
| `tf validate <stack>` | read | `scripts/tg validate` |
|
||||
| `tf fmt <stack>` | read | `terraform fmt -recursive` on the stack |
|
||||
| `tf force-unlock <stack> <lock-id>` | write | release a stuck state lock |
|
||||
| `tf apply <stack>` | write | `scripts/tg apply` — auto-claims `stack:<name>`, always releases, warns it's out-of-band |
|
||||
| `work start <topic>` | write | create `.worktrees/<topic>` on `<user>/<topic>` off `<remote>/master`; enter with native `EnterWorktree` |
|
||||
| `work land [--verify-cmd "…"] [--no-verify]` | write | merge master in → verify → push `HEAD:master` (non-ff retry; PR fallback) |
|
||||
| `work clean <topic>` | write | remove a task's worktree + branch (run from the main checkout) |
|
||||
|
||||
`tf` resolves the stack dir by walking up from cwd to the infra root and
|
||||
delegates to `scripts/tg` (which owns state decrypt/encrypt, the Vault lock, and
|
||||
the ingress auth-comment check). git-crypt filter flags are auto-injected on git
|
||||
operations in the encrypted infra repo.
|
||||
|
||||
**`work land` refuses to push when it cannot verify** (no `--verify-cmd` and no
|
||||
auto-detected suite) unless you pass `--no-verify` — landing to master unverified
|
||||
must be deliberate. It does not yet block on CI to green (that arrives with the
|
||||
ci/deploy watch verbs); it reminds you to follow the pipeline.
|
||||
|
||||
Tiers are recorded per verb so a future PreToolUse classifier can auto-allow
|
||||
reads / prompt writes; v0.1 allows everything and relies on existing gates
|
||||
(permission mode, presence claims, plan approval).
|
||||
|
||||
## Build / install
|
||||
|
||||
Built from source to `/usr/local/bin/homelab` during devvm provisioning
|
||||
(`scripts/workstation/setup-devvm.sh`, the `t3-dispatch` pattern); version is
|
||||
stamped from `cli/VERSION` via ldflags. Manual build:
|
||||
|
||||
```
|
||||
cd cli && go build -ldflags "-X main.version=$(cat VERSION)" -o /usr/local/bin/homelab .
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Legacy webhook use-cases (preserved)
|
||||
|
||||
This binary is also the in-cluster `infra-cli` image. Invocations starting with
|
||||
`-use-case=<vpn|setup-openwrt-dns|add-email-alias|...>` fall through to the
|
||||
original flag-based path unchanged, so the webhook handler is unaffected.
|
||||
|
||||
## Design
|
||||
|
||||
See `infra/docs/adr/0004`–`0006` for the architecture decisions.
|
||||
|
|
|
|||
1
cli/VERSION
Normal file
1
cli/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
v0.1.0
|
||||
205
cli/cmd_work.go
Normal file
205
cli/cmd_work.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func workCommands() []Command {
|
||||
return []Command{
|
||||
{Path: []string{"work", "start"}, Tier: TierWrite,
|
||||
Summary: "create a worktree + branch for a task (enter it with EnterWorktree)", Run: workStart},
|
||||
{Path: []string{"work", "land"}, Tier: TierWrite,
|
||||
Summary: "merge master in, verify, push HEAD:master (run from the worktree)", Run: workLand},
|
||||
{Path: []string{"work", "clean"}, Tier: TierWrite,
|
||||
Summary: "remove a task's worktree + branch (run from the main checkout)", Run: workClean},
|
||||
}
|
||||
}
|
||||
|
||||
// flagValue extracts `--name value` or `--name=value` from args.
|
||||
func flagValue(args []string, name string) string {
|
||||
for i, a := range args {
|
||||
if a == name && i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
if strings.HasPrefix(a, name+"=") {
|
||||
return strings.TrimPrefix(a, name+"=")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func remotesOrEmpty(repoRoot string) []string {
|
||||
r, _ := gitRemotes(repoRoot)
|
||||
return r
|
||||
}
|
||||
|
||||
// workStart creates .worktrees/<topic> on branch <user>/<topic> off <remote>/master.
|
||||
func workStart(args []string) error {
|
||||
topic, _ := firstPositional(args)
|
||||
if topic == "" {
|
||||
return fmt.Errorf("usage: homelab work start <topic>")
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
repoRoot, err := gitRepoRoot(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a git repository: %w", err)
|
||||
}
|
||||
remote := preferRemote(remotesOrEmpty(repoRoot))
|
||||
if remote == "" {
|
||||
return fmt.Errorf("no git remote configured in %s", repoRoot)
|
||||
}
|
||||
flags := cryptFlagsFor(repoRoot)
|
||||
branch := currentUser() + "/" + topic
|
||||
wtRel := filepath.Join(".worktrees", topic)
|
||||
|
||||
ensureWorktreesIgnored(repoRoot)
|
||||
if err := gitStream(repoRoot, flags, "fetch", remote); err != nil {
|
||||
return fmt.Errorf("fetch %s failed: %w", remote, err)
|
||||
}
|
||||
if err := gitStream(repoRoot, flags, "worktree", "add", wtRel, "-b", branch, remote+"/master"); err != nil {
|
||||
return fmt.Errorf("worktree add failed: %w", err)
|
||||
}
|
||||
wtPath := filepath.Join(repoRoot, wtRel)
|
||||
fmt.Printf("homelab: created worktree %s (branch %s off %s/master)\n", wtPath, branch, remote)
|
||||
fmt.Printf("homelab: enter it with the native tool: EnterWorktree(path=%q)\n", wtPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// workLand integrates the current branch into master: fetch, merge master in,
|
||||
// verify, push HEAD:master (retrying on non-fast-forward), with a feature-branch
|
||||
// fallback when the direct push is rejected (e.g. branch protection).
|
||||
func workLand(args []string) error {
|
||||
verifyCmd := flagValue(args, "--verify-cmd")
|
||||
cwd, _ := os.Getwd()
|
||||
repoRoot, err := gitRepoRoot(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a git repository: %w", err)
|
||||
}
|
||||
branch, err := gitOutput(repoRoot, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if branch == "master" || branch == "main" {
|
||||
return fmt.Errorf("refusing to land: already on %s", branch)
|
||||
}
|
||||
remote := preferRemote(remotesOrEmpty(repoRoot))
|
||||
if remote == "" {
|
||||
return fmt.Errorf("no git remote configured in %s", repoRoot)
|
||||
}
|
||||
flags := cryptFlagsFor(repoRoot)
|
||||
|
||||
if err := gitStream(repoRoot, flags, "fetch", remote); err != nil {
|
||||
return fmt.Errorf("fetch failed: %w", err)
|
||||
}
|
||||
if err := gitStream(repoRoot, flags, "merge", "--no-edit", remote+"/master"); err != nil {
|
||||
return fmt.Errorf("merging %s/master failed — resolve conflicts then re-run `homelab work land`: %w", remote, err)
|
||||
}
|
||||
if err := runVerify(repoRoot, verifyCmd, containsArg(args, "--no-verify")); err != nil {
|
||||
return fmt.Errorf("not landing: %w", err)
|
||||
}
|
||||
if err := pushWithRetry(repoRoot, flags, remote, 3); err != nil {
|
||||
return landFallback(repoRoot, flags, remote, branch, err)
|
||||
}
|
||||
fmt.Printf("homelab: landed %s -> %s/master.\n", branch, remote)
|
||||
fmt.Println("homelab: CI was triggered by the push — watch it to completion before calling the work done")
|
||||
fmt.Println(" (the ci/deploy watch verbs arrive in a later version; for now follow the pipeline manually).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// runVerify runs the explicit --verify-cmd, else auto-detects (go test). If
|
||||
// neither is available it REFUSES (returns an error) unless allowSkip is set —
|
||||
// landing to master unverified must be a deliberate choice (--no-verify).
|
||||
func runVerify(repoRoot, verifyCmd string, allowSkip bool) error {
|
||||
if verifyCmd != "" {
|
||||
fmt.Fprintf(os.Stderr, "homelab: verify: %s\n", verifyCmd)
|
||||
return runStreamingIn(repoRoot, "sh", "-c", verifyCmd)
|
||||
}
|
||||
if isFile(filepath.Join(repoRoot, "go.mod")) {
|
||||
fmt.Fprintln(os.Stderr, "homelab: verify: go test ./...")
|
||||
return runStreamingIn(repoRoot, "go", "test", "./...")
|
||||
}
|
||||
if allowSkip {
|
||||
fmt.Fprintln(os.Stderr, "homelab: WARNING: --no-verify set — landing without verification")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("no verification configured for this repo — pass --verify-cmd \"...\" or --no-verify to land without verifying")
|
||||
}
|
||||
|
||||
// pushWithRetry pushes HEAD:master, recovering from non-fast-forward rejections
|
||||
// by fetching + merging master and retrying.
|
||||
func pushWithRetry(repoRoot string, flags []string, remote string, attempts int) error {
|
||||
var lastErr error
|
||||
for i := 0; i < attempts; i++ {
|
||||
if err := gitStream(repoRoot, flags, "push", remote, "HEAD:master"); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
if i < attempts-1 {
|
||||
fmt.Fprintln(os.Stderr, "homelab: push rejected — fetching + merging master, then retrying")
|
||||
if err := gitStream(repoRoot, flags, "fetch", remote); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gitStream(repoRoot, flags, "merge", "--no-edit", remote+"/master"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("push to %s/master failed after %d attempts: %w", remote, attempts, lastErr)
|
||||
}
|
||||
|
||||
// landFallback pushes the feature branch when the direct master push is rejected
|
||||
// (e.g. branch protection), so the work isn't lost and a PR can be opened.
|
||||
func landFallback(repoRoot string, flags []string, remote, branch string, pushErr error) error {
|
||||
fmt.Fprintf(os.Stderr, "homelab: direct push to master failed (%v)\n", pushErr)
|
||||
fmt.Fprintf(os.Stderr, "homelab: falling back to pushing the feature branch %q for a PR\n", branch)
|
||||
if err := gitStream(repoRoot, flags, "push", "-u", remote, branch); err != nil {
|
||||
return fmt.Errorf("fallback branch push also failed: %w", err)
|
||||
}
|
||||
fmt.Printf("homelab: pushed %s to %s. Open a PR to land it (branch protection blocked the direct push).\n", branch, remote)
|
||||
return nil
|
||||
}
|
||||
|
||||
// workClean removes a task's worktree and branch. Run from the main checkout.
|
||||
func workClean(args []string) error {
|
||||
topic, _ := firstPositional(args)
|
||||
if topic == "" {
|
||||
return fmt.Errorf("usage: homelab work clean <topic> (run from the main checkout)")
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
repoRoot, err := gitRepoRoot(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a git repository: %w", err)
|
||||
}
|
||||
flags := cryptFlagsFor(repoRoot)
|
||||
wtRel := filepath.Join(".worktrees", topic)
|
||||
branch := currentUser() + "/" + topic
|
||||
|
||||
if err := gitStream(repoRoot, flags, "worktree", "remove", wtRel); err != nil {
|
||||
return fmt.Errorf("worktree remove failed (uncommitted changes? run from the main checkout, not the worktree): %w", err)
|
||||
}
|
||||
if err := gitStream(repoRoot, flags, "branch", "-d", branch); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "homelab: note: could not delete branch %s (unmerged — use `git branch -D` if intended): %v\n", branch, err)
|
||||
}
|
||||
fmt.Printf("homelab: removed worktree %s and branch %s\n", wtRel, branch)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureWorktreesIgnored appends .worktrees/ to .gitignore if not already ignored.
|
||||
func ensureWorktreesIgnored(repoRoot string) {
|
||||
if _, err := gitOutput(repoRoot, "check-ignore", ".worktrees"); err == nil {
|
||||
return
|
||||
}
|
||||
gi := filepath.Join(repoRoot, ".gitignore")
|
||||
f, err := os.OpenFile(gi, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.WriteString("\n.worktrees/\n"); err == nil {
|
||||
fmt.Fprintln(os.Stderr, "homelab: added .worktrees/ to .gitignore")
|
||||
}
|
||||
}
|
||||
32
cli/cmd_work_test.go
Normal file
32
cli/cmd_work_test.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRunVerifyRefusesWhenNothingToVerify(t *testing.T) {
|
||||
dir := t.TempDir() // no go.mod, no verify cmd
|
||||
if err := runVerify(dir, "", false); err == nil {
|
||||
t.Fatal("runVerify must refuse (error) when nothing to verify and --no-verify absent")
|
||||
}
|
||||
if err := runVerify(dir, "", true); err != nil {
|
||||
t.Fatalf("runVerify must skip when --no-verify set, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
args []string
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{[]string{"--verify-cmd", "go test ./..."}, "--verify-cmd", "go test ./..."},
|
||||
{[]string{"--verify-cmd=make test"}, "--verify-cmd", "make test"},
|
||||
{[]string{"topic", "--verify-cmd", "x"}, "--verify-cmd", "x"},
|
||||
{[]string{"topic"}, "--verify-cmd", ""},
|
||||
{[]string{"--verify-cmd"}, "--verify-cmd", ""}, // no value
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := flagValue(c.args, c.name); got != c.want {
|
||||
t.Errorf("flagValue(%v, %q) = %q, want %q", c.args, c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ func buildRegistry() []Command {
|
|||
var reg []Command
|
||||
reg = append(reg, claimCommands()...)
|
||||
reg = append(reg, tfCommands()...)
|
||||
reg = append(reg, workCommands()...)
|
||||
return reg
|
||||
}
|
||||
|
||||
|
|
|
|||
38
cli/repo.go
38
cli/repo.go
|
|
@ -1,7 +1,10 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -61,3 +64,38 @@ func gitRemotes(dir string) ([]string, error) {
|
|||
}
|
||||
return strings.Split(out, "\n"), nil
|
||||
}
|
||||
|
||||
// isGitCryptRepo reports whether the repo at repoRoot uses git-crypt.
|
||||
func isGitCryptRepo(repoRoot string) bool {
|
||||
b, err := os.ReadFile(filepath.Join(repoRoot, ".gitattributes"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return hasGitCryptAttr(string(b))
|
||||
}
|
||||
|
||||
// cryptFlagsFor returns the git-crypt filter flags when repoRoot is encrypted,
|
||||
// else nil. These are injected per-command and never persisted.
|
||||
func cryptFlagsFor(repoRoot string) []string {
|
||||
if isGitCryptRepo(repoRoot) {
|
||||
return gitCryptFlags()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitStream runs `git [cryptFlags] -C repoRoot <args>` with live output.
|
||||
func gitStream(repoRoot string, cryptFlags []string, args ...string) error {
|
||||
full := append(append([]string{}, cryptFlags...), append([]string{"-C", repoRoot}, args...)...)
|
||||
return runStreamingIn("", "git", full...)
|
||||
}
|
||||
|
||||
// currentUser returns the OS username for branch naming (<user>/<topic>).
|
||||
func currentUser() string {
|
||||
if u := os.Getenv("USER"); u != "" {
|
||||
return u
|
||||
}
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
return u.Username
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
|
|
|
|||
30
docs/adr/0004-homelab-unified-cli.md
Normal file
30
docs/adr/0004-homelab-unified-cli.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# homelab: a unified infra-ops CLI grown in place from infra/cli
|
||||
|
||||
Agents re-derive the same operational command boilerplate every session — mining
|
||||
51,116 bash commands across 2,225 past sessions showed dense, repeated patterns
|
||||
(the infra inner-loop alone is ~29%). We are building `homelab`, one CLI encoding
|
||||
the deterministic, repeated **actions** (not judgment) agents run — composable in
|
||||
bash, JSON-capable, and discovered progressively via `homelab manifest`. It is
|
||||
grown **in place** in `cli/` (the existing `infra-cli`), absorbing new verb-groups
|
||||
alongside the preserved legacy webhook use-cases. Versioned with a `cli/VERSION`
|
||||
file (the infra repo deploys continuously and does not cut semver tags).
|
||||
|
||||
## Considered options
|
||||
|
||||
- **Its own top-level repo** (the original plan) — rejected in favour of keeping
|
||||
it where the Terraform/Terragrunt and `scripts/tg` it drives already live; the
|
||||
Go source isn't git-crypt-encrypted and a provision-time build is unaffected by
|
||||
GitOps continuous-deploy.
|
||||
- **A fresh CLI ignoring infra-cli** — rejected: strands the VPN/DNS/email
|
||||
webhook use-cases.
|
||||
- **Raw kubectl/tg/ssh + skills + MCP only** — kept for everything outside the
|
||||
recurring action surface (methodology skills; third-party/owned MCP such as
|
||||
phpIPAM, which homelab does NOT duplicate).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The binary is dual-purpose: the agent-facing `homelab` verb surface AND the
|
||||
in-cluster `infra-cli` webhook image. `main()` front-dispatches homelab verbs
|
||||
and falls through to the legacy `-use-case` path verbatim.
|
||||
- Distribution: built from source to `/usr/local/bin/homelab` during devvm
|
||||
provisioning (`t3-dispatch` precedent), refreshed by `t3-autoupdate`.
|
||||
23
docs/adr/0005-homelab-v01-scope.md
Normal file
23
docs/adr/0005-homelab-v01-scope.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# homelab v0.1 scope: the infra inner-loop; everything allowed, tiers recorded
|
||||
|
||||
v0.1 ships only the highest-volume surface — the infra inner-loop: `work`
|
||||
(worktree lifecycle), `tf` (terragrunt via `scripts/tg` + fmt/validate/
|
||||
force-unlock), and `claim`/`release` (presence) — because it is ~29% of all mined
|
||||
commands and where agents lose the most time and leak the most presence claims.
|
||||
|
||||
v0.1 enforces **no** homelab-level permission gating: everything is allowed,
|
||||
relying on existing gates (harness permission mode, presence claims, plan
|
||||
approval). But every verb records a `read|write` tier (visible in `manifest`), so
|
||||
a PreToolUse classifier hook (auto-allow reads / prompt writes) can be added
|
||||
later with zero restructuring.
|
||||
|
||||
## Considered options
|
||||
|
||||
- **Reads-first vertical slice** (top read verb per domain) — lower risk, broad
|
||||
value, but defers the toil that motivated the project.
|
||||
- **One domain deep (k8s)** — cleanest template, narrow day-one value.
|
||||
|
||||
We chose the highest-volume-but-write-heavy infra loop deliberately, accepting
|
||||
the extra complexity (worktree lifecycle, git-crypt flag injection, presence
|
||||
coupling, branch-protection PR fallback) for the biggest immediate toil
|
||||
reduction. k8s/node/secret/net/ci verb-groups are deferred to later versions.
|
||||
29
docs/adr/0006-homelab-work-and-tf.md
Normal file
29
docs/adr/0006-homelab-work-and-tf.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# homelab work/tf behaviour: native worktree entry, gated auto-land, presence-coupled apply
|
||||
|
||||
Four behaviours of the infra-loop verbs are surprising enough to record:
|
||||
|
||||
1. **`work` owns worktree create/land/clean, but session *entry* delegates to the
|
||||
native harness worktree tool.** A CLI is a child process and cannot change the
|
||||
agent's working directory; `EnterWorktree` can. So `homelab work start <topic>`
|
||||
creates the worktree + branch off `<remote>/master` (git-crypt-aware) and
|
||||
prints the path — the agent enters it with native `EnterWorktree({path})`.
|
||||
|
||||
2. **`work land` is auto-land, but gated on verification.** It merges master in →
|
||||
runs verification → pushes `HEAD:master` (fetch+merge+retry on
|
||||
non-fast-forward) → falls back to pushing the feature branch for a PR when the
|
||||
direct push is rejected (branch protection). It **refuses to push when it
|
||||
cannot verify** (no `--verify-cmd` and no auto-detected suite) unless
|
||||
`--no-verify` is passed — added after an accidental smoke-test land pushed
|
||||
unverified WIP to master (benign: the infra CI applied 0 stacks because the
|
||||
diff was `cli/`-only, but an unverified land must be deliberate, not default).
|
||||
|
||||
3. **`tf apply` is first-class despite GitOps, and mandatorily presence-coupled.**
|
||||
Local applies are out-of-band (CI applies canonically on push) but happen
|
||||
constantly (~763× in the corpus). `tf apply <stack>` auto-claims `stack:<name>`,
|
||||
delegates to `scripts/tg apply --non-interactive`, and **always releases on
|
||||
exit** (normal, error, or signal via `sync.Once` + handler) — fixing the
|
||||
documented ~200-claim leak — and prints an out-of-band reminder.
|
||||
|
||||
4. **Known v0.1 limitation:** `work land` does not yet block on CI to green; that
|
||||
arrives with the ci/deploy watch verb-group. It prints a reminder to follow
|
||||
the pipeline manually.
|
||||
|
|
@ -175,6 +175,18 @@ if [[ ! -x /usr/local/bin/t3-dispatch ]]; then
|
|||
log "WARN: go absent -> cannot build t3-dispatch; install golang-go or deploy the binary"
|
||||
fi
|
||||
fi
|
||||
# 9b2) homelab: unified infra-ops CLI (agent-facing verbs + the in-cluster
|
||||
# infra-cli webhook image). Rebuilt from cli/ each run so it tracks the
|
||||
# repo; version stamped from cli/VERSION. See cli/README.md + docs/adr/0004-0006.
|
||||
if command -v go >/dev/null; then
|
||||
_hl_src="$SCRIPTS/../cli"
|
||||
_hl_ver="$(cat "$_hl_src/VERSION" 2>/dev/null || echo dev)"
|
||||
log "building homelab CLI ($_hl_ver)"
|
||||
( cd "$_hl_src" && go build -ldflags "-X main.version=$_hl_ver" -o /usr/local/bin/homelab . ) \
|
||||
|| log "WARN: homelab CLI build failed"
|
||||
else
|
||||
log "WARN: go absent -> cannot build homelab CLI"
|
||||
fi
|
||||
# 9c) sudoers: t3-dispatch may run ONLY t3-mint as root. A malformed file in
|
||||
# /etc/sudoers.d breaks ALL sudo, so validate with visudo when available.
|
||||
if ! command -v visudo >/dev/null || visudo -cf "$SCRIPTS/sudoers-t3-autopair" >/dev/null; then
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue