homelab: scaffold unified CLI (registry, manifest, claim/release) in infra/cli
Begin evolving the existing infra/cli into the agent-facing "homelab" CLI decided in the design/grilling session: one composable, JSON-capable surface for the operations agents run over and over (mined from 51k commands across 2,225 past sessions; the infra inner-loop is ~29% of them). v0.1 targets that loop — work/tf/claim — and ships here, in place, in infra/cli. This first slice: - command registry + dispatcher (longest-prefix verb matching) and a `manifest`/`manifest --json` progressive-discovery entrypoint; every verb declares a read|write tier so write-gating can be added later (everything is allowed for now). - claim/release verbs wrapping the existing presence script (not reimplemented), with label-taxonomy validation. - main() front-dispatches the homelab verb surface but falls through to the legacy webhook -use-case path verbatim, so the in-cluster infra-cli image is unaffected. - fix a pre-existing vet error (glog.Infof missing format directive) that blocked `go test`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
70e217db24
commit
ed6f22fd53
9 changed files with 426 additions and 3 deletions
56
cli/cmd_claim.go
Normal file
56
cli/cmd_claim.go
Normal file
|
|
@ -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 `<kind>:<name> --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 <kind>:<name> --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 <kind>:<name>")
|
||||
}
|
||||
return presenceRelease(label)
|
||||
}
|
||||
101
cli/command.go
Normal file
101
cli/command.go
Normal file
|
|
@ -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: "<path> <tier> <summary>".
|
||||
// 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
|
||||
}
|
||||
73
cli/command_test.go
Normal file
73
cli/command_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Tracer bullet: the dispatcher must route `homelab <path...> <args...>` 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)
|
||||
}
|
||||
}
|
||||
86
cli/homelab.go
Normal file
86
cli/homelab.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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()...)
|
||||
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 <command> [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=<name>.\n")
|
||||
return b.String()
|
||||
}
|
||||
12
cli/main.go
12
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)
|
||||
}
|
||||
|
|
|
|||
58
cli/presence.go
Normal file
58
cli/presence.go
Normal file
|
|
@ -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 <kind>:<name> 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 <kind>:<name> (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)
|
||||
}
|
||||
24
cli/presence_test.go
Normal file
24
cli/presence_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
cli/run.go
Normal file
17
cli/run.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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 {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue