homelab: add k8s verb-group (v0.2) — the biggest remaining surface
Mining the post-v0.1 corpus showed kubectl is the dominant remaining domain by far: 11,291 commands across 243 sessions (more than everything else combined). This adds the full k8s verb-group built on an app→namespace→pod resolver (most namespaces hold one app, so <app> defaults to the namespace and the target defaults to deploy/<app>, letting kubectl resolve the pod; -n/--pod/-c/-l/--tty override). Read: status (pods + non-Normal events), get, logs, describe, debug (one-shot triage), pf, rollout-status. Write/operational: db (the dbaas psql/mysql exec pattern — PG via pg-cluster-rw -c postgres, MySQL via mysql-standalone-0 with the env-password bash wrapper, never inline), exec, rm-pod (pods/jobs ONLY), restart. Config-mutation verbs (apply/edit/patch/scale/create) are deliberately NOT exposed — they stay raw per the Terraform-only policy. Smoke-verified read verbs against the live cluster (get/logs/rollout-status); write verbs are unit-tested (resolver, db-plan, shell-quoting) but not fired at live state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
66caa0bf7f
commit
1f7438bb18
4 changed files with 473 additions and 0 deletions
280
cli/cmd_k8s.go
Normal file
280
cli/cmd_k8s.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func k8sCommands() []Command {
|
||||
return []Command{
|
||||
{Path: []string{"k8s", "status"}, Tier: TierRead,
|
||||
Summary: "pods (wide) + recent non-Normal events for a namespace (or -A)", Run: k8sStatus},
|
||||
{Path: []string{"k8s", "get"}, Tier: TierRead,
|
||||
Summary: "kubectl get in a namespace: k8s get <ns> <resource> [args]", Run: k8sGet},
|
||||
{Path: []string{"k8s", "logs"}, Tier: TierRead,
|
||||
Summary: "logs for <app> (deploy/<app>; --tail/-c/--previous/--since/-l)", Run: k8sLogs},
|
||||
{Path: []string{"k8s", "describe"}, Tier: TierRead,
|
||||
Summary: "describe <app>'s deployment (or an explicit resource)", Run: k8sDescribe},
|
||||
{Path: []string{"k8s", "debug"}, Tier: TierRead,
|
||||
Summary: "one-shot triage for <app>: pods+deploy+describe+logs+events", Run: k8sDebug},
|
||||
{Path: []string{"k8s", "pf"}, Tier: TierRead,
|
||||
Summary: "port-forward: k8s pf <app> <local:remote> [svc/pod target]", Run: k8sPortForward},
|
||||
{Path: []string{"k8s", "db"}, Tier: TierWrite,
|
||||
Summary: `query a dbaas DB: k8s db <app> [--mysql] [--db N] -- "<SQL>"`, Run: k8sDB},
|
||||
{Path: []string{"k8s", "exec"}, Tier: TierWrite,
|
||||
Summary: "exec in <app>'s pod: k8s exec <app> [--tty] -- <cmd>", Run: k8sExec},
|
||||
{Path: []string{"k8s", "rm-pod"}, Tier: TierWrite,
|
||||
Summary: "delete a stuck pod/job ONLY: k8s rm-pod <name> -n <ns> [--job] [--force]", Run: k8sRmPod},
|
||||
{Path: []string{"k8s", "rollout-status"}, Tier: TierRead,
|
||||
Summary: "rollout status of deploy/<app>", Run: k8sRolloutStatus},
|
||||
{Path: []string{"k8s", "restart"}, Tier: TierWrite,
|
||||
Summary: "rollout restart deploy/<app> then wait for status", Run: k8sRestart},
|
||||
{Path: []string{"k8s", "probe"}, Tier: TierRead,
|
||||
Summary: "in-cluster reachability: ephemeral curl pod to <app>.<ns>.svc", Run: k8sProbe},
|
||||
}
|
||||
}
|
||||
|
||||
func k8sStatus(args []string) error {
|
||||
t := parseK8sTarget(args)
|
||||
ns := t.namespace() // "" when no app/ns given → cluster-wide
|
||||
get := []string{"get", "pods", "-o", "wide"}
|
||||
ev := []string{"get", "events", "--field-selector", "type!=Normal", "--sort-by=.lastTimestamp"}
|
||||
if ns == "" {
|
||||
get = append(get, "-A")
|
||||
ev = append(ev, "-A")
|
||||
}
|
||||
if err := kubectlStream(ns, get...); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "\n--- recent events (type!=Normal) ---")
|
||||
_ = kubectlStream(ns, ev...) // best-effort
|
||||
return nil
|
||||
}
|
||||
|
||||
func k8sGet(args []string) error {
|
||||
t := parseK8sTarget(args)
|
||||
if t.app == "" || len(t.rest) == 0 {
|
||||
return fmt.Errorf("usage: homelab k8s get <ns> <resource> [args]")
|
||||
}
|
||||
return kubectlStream(t.app, append([]string{"get"}, t.rest...)...)
|
||||
}
|
||||
|
||||
func k8sLogs(args []string) error {
|
||||
t := parseK8sTarget(args)
|
||||
if t.app == "" {
|
||||
return fmt.Errorf("usage: homelab k8s logs <app> [--tail N] [-c ctr] [--previous] [--since 1h] [-l sel]")
|
||||
}
|
||||
a := []string{"logs"}
|
||||
if t.selector != "" {
|
||||
a = append(a, "-l", t.selector)
|
||||
} else {
|
||||
a = append(a, t.objectRef())
|
||||
}
|
||||
if t.container != "" {
|
||||
a = append(a, "-c", t.container)
|
||||
}
|
||||
if !containsPrefix(t.rest, "--tail") {
|
||||
a = append(a, "--tail=200")
|
||||
}
|
||||
a = append(a, t.rest...)
|
||||
return kubectlStream(t.namespace(), a...)
|
||||
}
|
||||
|
||||
func k8sDescribe(args []string) error {
|
||||
t := parseK8sTarget(args)
|
||||
if t.app == "" {
|
||||
return fmt.Errorf("usage: homelab k8s describe <app> [resource]")
|
||||
}
|
||||
if len(t.rest) > 0 {
|
||||
return kubectlStream(t.namespace(), append([]string{"describe"}, t.rest...)...)
|
||||
}
|
||||
return kubectlStream(t.namespace(), "describe", t.objectRef())
|
||||
}
|
||||
|
||||
func k8sDebug(args []string) error {
|
||||
t := parseK8sTarget(args)
|
||||
if t.app == "" {
|
||||
return fmt.Errorf("usage: homelab k8s debug <app>")
|
||||
}
|
||||
ns := t.namespace()
|
||||
sec := func(title string) { fmt.Fprintf(os.Stderr, "\n=== %s ===\n", title) }
|
||||
sec("pods")
|
||||
_ = kubectlStream(ns, "get", "pods", "-o", "wide")
|
||||
sec("workloads")
|
||||
_ = kubectlStream(ns, "get", "deploy,sts,ds", "-o", "wide")
|
||||
sec("describe "+t.objectRef())
|
||||
_ = kubectlStream(ns, "describe", t.objectRef())
|
||||
sec("recent logs (--tail=50)")
|
||||
_ = kubectlStream(ns, "logs", t.objectRef(), "--tail=50")
|
||||
sec("events (type!=Normal)")
|
||||
_ = kubectlStream(ns, "get", "events", "--field-selector", "type!=Normal", "--sort-by=.lastTimestamp")
|
||||
return nil
|
||||
}
|
||||
|
||||
func k8sPortForward(args []string) error {
|
||||
t := parseK8sTarget(args)
|
||||
if t.app == "" || len(t.rest) == 0 {
|
||||
return fmt.Errorf("usage: homelab k8s pf <app> <local:remote> [svc/pod target]")
|
||||
}
|
||||
ports := t.rest[0]
|
||||
target := "svc/" + t.app
|
||||
if len(t.rest) > 1 {
|
||||
target = t.rest[1]
|
||||
}
|
||||
return kubectlStream(t.namespace(), "port-forward", target, ports)
|
||||
}
|
||||
|
||||
func k8sDB(args []string) error {
|
||||
var app, dbName, sql string
|
||||
mysql := false
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--" {
|
||||
sql = strings.Join(args[i+1:], " ")
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case a == "--mysql":
|
||||
mysql = true
|
||||
case a == "--db":
|
||||
if i+1 < len(args) {
|
||||
dbName = args[i+1]
|
||||
i++
|
||||
}
|
||||
case strings.HasPrefix(a, "--db="):
|
||||
dbName = strings.TrimPrefix(a, "--db=")
|
||||
case !strings.HasPrefix(a, "-") && app == "":
|
||||
app = a
|
||||
}
|
||||
}
|
||||
if app == "" {
|
||||
return fmt.Errorf(`usage: homelab k8s db <app> [--mysql] [--db NAME] -- "<SQL>"`)
|
||||
}
|
||||
p := planDBExec(app, dbName, sql, mysql)
|
||||
exec := []string{"exec"}
|
||||
if sql == "" {
|
||||
exec = append(exec, "-it") // interactive client when no SQL given
|
||||
}
|
||||
exec = append(exec, p.pod)
|
||||
if p.container != "" {
|
||||
exec = append(exec, "-c", p.container)
|
||||
}
|
||||
exec = append(exec, "--")
|
||||
exec = append(exec, p.argv...)
|
||||
return kubectlStream(p.ns, exec...)
|
||||
}
|
||||
|
||||
func k8sExec(args []string) error {
|
||||
t := parseK8sTarget(args)
|
||||
if t.app == "" {
|
||||
return fmt.Errorf("usage: homelab k8s exec <app> [--pod p] [-c ctr] [--tty] -- <cmd>")
|
||||
}
|
||||
if len(t.rest) == 0 {
|
||||
return fmt.Errorf("provide a command after --, e.g. homelab k8s exec %s -- env", t.app)
|
||||
}
|
||||
a := []string{"exec"}
|
||||
if t.tty {
|
||||
a = append(a, "-it")
|
||||
}
|
||||
a = append(a, t.objectRef())
|
||||
if t.container != "" {
|
||||
a = append(a, "-c", t.container)
|
||||
}
|
||||
a = append(a, "--")
|
||||
a = append(a, t.rest...)
|
||||
return kubectlStream(t.namespace(), a...)
|
||||
}
|
||||
|
||||
func k8sRmPod(args []string) error {
|
||||
var pod, ns, grace string
|
||||
force, job := false, false
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "-n" || a == "--namespace":
|
||||
if i+1 < len(args) {
|
||||
ns = args[i+1]
|
||||
i++
|
||||
}
|
||||
case a == "--force":
|
||||
force = true
|
||||
case a == "--job":
|
||||
job = true
|
||||
case a == "--grace":
|
||||
if i+1 < len(args) {
|
||||
grace = args[i+1]
|
||||
i++
|
||||
}
|
||||
case !strings.HasPrefix(a, "-") && pod == "":
|
||||
pod = a
|
||||
}
|
||||
}
|
||||
if pod == "" || ns == "" {
|
||||
return fmt.Errorf("usage: homelab k8s rm-pod <name> -n <ns> [--job] [--force] [--grace N] (pods/jobs only)")
|
||||
}
|
||||
kind := "pod"
|
||||
if job {
|
||||
kind = "job"
|
||||
}
|
||||
a := []string{"delete", kind, pod}
|
||||
if grace != "" {
|
||||
a = append(a, "--grace-period="+grace)
|
||||
}
|
||||
if force {
|
||||
a = append(a, "--force")
|
||||
}
|
||||
return kubectlStream(ns, a...)
|
||||
}
|
||||
|
||||
func k8sRolloutStatus(args []string) error {
|
||||
t := parseK8sTarget(args)
|
||||
if t.app == "" {
|
||||
return fmt.Errorf("usage: homelab k8s rollout-status <app>")
|
||||
}
|
||||
return kubectlStream(t.namespace(), "rollout", "status", "deploy/"+t.app)
|
||||
}
|
||||
|
||||
func k8sRestart(args []string) error {
|
||||
t := parseK8sTarget(args)
|
||||
if t.app == "" {
|
||||
return fmt.Errorf("usage: homelab k8s restart <app>")
|
||||
}
|
||||
ns := t.namespace()
|
||||
if err := kubectlStream(ns, "rollout", "restart", "deploy/"+t.app); err != nil {
|
||||
return err
|
||||
}
|
||||
return kubectlStream(ns, "rollout", "status", "deploy/"+t.app)
|
||||
}
|
||||
|
||||
func k8sProbe(args []string) error {
|
||||
t := parseK8sTarget(args)
|
||||
if t.app == "" {
|
||||
return fmt.Errorf("usage: homelab k8s probe <app> [path] [--port N]")
|
||||
}
|
||||
ns := t.namespace()
|
||||
url := "http://" + t.app + "." + ns + ".svc.cluster.local"
|
||||
if port := flagValue(args, "--port"); port != "" {
|
||||
url += ":" + port
|
||||
}
|
||||
if len(t.rest) > 0 {
|
||||
p := t.rest[0]
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
url += p
|
||||
}
|
||||
return kubectlStream(ns, "run", "homelab-probe", "--rm", "-i", "--restart=Never",
|
||||
"--image=curlimages/curl:latest", "--",
|
||||
"curl", "-sS", "--max-time", "10", "-w", "\n[%{http_code}] %{time_total}s\n", url)
|
||||
}
|
||||
|
||||
// containsPrefix reports whether any arg starts with prefix.
|
||||
func containsPrefix(args []string, prefix string) bool {
|
||||
for _, a := range args {
|
||||
if strings.HasPrefix(a, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ func buildRegistry() []Command {
|
|||
reg = append(reg, claimCommands()...)
|
||||
reg = append(reg, tfCommands()...)
|
||||
reg = append(reg, workCommands()...)
|
||||
reg = append(reg, k8sCommands()...)
|
||||
return reg
|
||||
}
|
||||
|
||||
|
|
|
|||
129
cli/k8s.go
Normal file
129
cli/k8s.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// kubectl helpers use the ambient kubeconfig (no per-call auth flags).
|
||||
|
||||
func kubectlBase(ns string, args ...string) []string {
|
||||
var full []string
|
||||
if ns != "" {
|
||||
full = append(full, "-n", ns)
|
||||
}
|
||||
return append(full, args...)
|
||||
}
|
||||
|
||||
func kubectlStream(ns string, args ...string) error {
|
||||
return runStreamingIn("", "kubectl", kubectlBase(ns, args...)...)
|
||||
}
|
||||
|
||||
// k8sTarget is the parsed `<app>` + selectors shared by the k8s verbs.
|
||||
type k8sTarget struct {
|
||||
app string
|
||||
ns string
|
||||
pod string
|
||||
container string
|
||||
selector string
|
||||
tty bool
|
||||
rest []string // passthrough flags and, after `--`, the exec command
|
||||
}
|
||||
|
||||
// parseK8sTarget reads `<app> [-n ns] [--pod p] [-c ctr] [-l sel] [flags] [-- cmd]`.
|
||||
// The first bare token is the app; unknown flags pass through in rest.
|
||||
func parseK8sTarget(args []string) k8sTarget {
|
||||
t := k8sTarget{}
|
||||
i := 0
|
||||
take := func() string {
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
return args[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for i = 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "--":
|
||||
t.rest = append(t.rest, args[i+1:]...)
|
||||
return t
|
||||
case a == "-n" || a == "--namespace":
|
||||
t.ns = take()
|
||||
case strings.HasPrefix(a, "--namespace="):
|
||||
t.ns = strings.TrimPrefix(a, "--namespace=")
|
||||
case a == "--pod":
|
||||
t.pod = take()
|
||||
case strings.HasPrefix(a, "--pod="):
|
||||
t.pod = strings.TrimPrefix(a, "--pod=")
|
||||
case a == "-c" || a == "--container":
|
||||
t.container = take()
|
||||
case strings.HasPrefix(a, "--container="):
|
||||
t.container = strings.TrimPrefix(a, "--container=")
|
||||
case a == "-l" || a == "--selector":
|
||||
t.selector = take()
|
||||
case strings.HasPrefix(a, "--selector="):
|
||||
t.selector = strings.TrimPrefix(a, "--selector=")
|
||||
case a == "--tty" || a == "-it" || a == "-ti":
|
||||
t.tty = true
|
||||
case !strings.HasPrefix(a, "-") && t.app == "":
|
||||
t.app = a
|
||||
default:
|
||||
t.rest = append(t.rest, a)
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// namespace defaults to the app name (most namespaces hold exactly one app).
|
||||
func (t k8sTarget) namespace() string {
|
||||
if t.ns != "" {
|
||||
return t.ns
|
||||
}
|
||||
return t.app
|
||||
}
|
||||
|
||||
// objectRef is the kubectl object for logs/exec: an explicit pod, else
|
||||
// deploy/<app> (kubectl resolves a pod from the Deployment).
|
||||
func (t k8sTarget) objectRef() string {
|
||||
if t.pod != "" {
|
||||
return "pod/" + t.pod
|
||||
}
|
||||
return "deploy/" + t.app
|
||||
}
|
||||
|
||||
// --- database access (the dbaas exec pattern) ---
|
||||
|
||||
type dbPlan struct {
|
||||
ns string
|
||||
pod string
|
||||
container string // "" = default container
|
||||
argv []string // command + args to run inside the pod
|
||||
}
|
||||
|
||||
// planDBExec builds the in-pod command to run sql against app's database.
|
||||
// PG (default): CNPG primary pg-cluster-rw, psql -U postgres -d <db>.
|
||||
// MySQL: mysql-standalone-0, password from env (never on the command line).
|
||||
// dbName defaults to app. sql empty => interactive client.
|
||||
func planDBExec(app, dbName, sql string, mysql bool) dbPlan {
|
||||
if dbName == "" {
|
||||
dbName = app
|
||||
}
|
||||
if mysql {
|
||||
inner := fmt.Sprintf(`mysql -u root -p"$MYSQL_ROOT_PASSWORD" %s`, shellQuote(dbName))
|
||||
if sql != "" {
|
||||
inner += " -e " + shellQuote(sql)
|
||||
}
|
||||
return dbPlan{ns: "dbaas", pod: "mysql-standalone-0", argv: []string{"bash", "-c", inner}}
|
||||
}
|
||||
argv := []string{"psql", "-U", "postgres", "-d", dbName}
|
||||
if sql != "" {
|
||||
argv = append(argv, "-tAc", sql)
|
||||
}
|
||||
return dbPlan{ns: "dbaas", pod: "pg-cluster-rw", container: "postgres", argv: argv}
|
||||
}
|
||||
|
||||
// shellQuote single-quotes s for safe embedding in a bash -c string.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
63
cli/k8s_test.go
Normal file
63
cli/k8s_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseK8sTarget(t *testing.T) {
|
||||
got := parseK8sTarget([]string{"tripit", "-n", "prod", "--pod", "x-123", "-c", "app", "-l", "k=v", "--tail=50", "--", "ls", "-la"})
|
||||
want := k8sTarget{app: "tripit", ns: "prod", pod: "x-123", container: "app", selector: "k=v", rest: []string{"--tail=50", "ls", "-la"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseK8sTarget =\n %+v\nwant\n %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestK8sTargetNamespaceDefaultsToApp(t *testing.T) {
|
||||
if ns := parseK8sTarget([]string{"immich"}).namespace(); ns != "immich" {
|
||||
t.Errorf("namespace() = %q, want immich", ns)
|
||||
}
|
||||
if ns := parseK8sTarget([]string{"immich", "-n", "dbaas"}).namespace(); ns != "dbaas" {
|
||||
t.Errorf("namespace() = %q, want dbaas", ns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestK8sTargetObjectRef(t *testing.T) {
|
||||
if r := parseK8sTarget([]string{"tripit"}).objectRef(); r != "deploy/tripit" {
|
||||
t.Errorf("objectRef() = %q, want deploy/tripit", r)
|
||||
}
|
||||
if r := parseK8sTarget([]string{"tripit", "--pod", "tripit-abc"}).objectRef(); r != "pod/tripit-abc" {
|
||||
t.Errorf("objectRef() = %q, want pod/tripit-abc", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDBExecPostgresDefault(t *testing.T) {
|
||||
p := planDBExec("fire-planner", "", "SELECT 1", false)
|
||||
if p.ns != "dbaas" || p.pod != "pg-cluster-rw" || p.container != "postgres" {
|
||||
t.Fatalf("unexpected pg target: %+v", p)
|
||||
}
|
||||
// db name defaults to the app; SQL passed via -tAc
|
||||
joined := strings.Join(p.argv, " ")
|
||||
if !strings.Contains(joined, "-d fire-planner") || !strings.Contains(joined, "-tAc") {
|
||||
t.Fatalf("pg argv missing db/sql: %v", p.argv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDBExecMysqlEnvPassword(t *testing.T) {
|
||||
p := planDBExec("wrongmove", "wrongmove", "SHOW TABLES", true)
|
||||
if p.pod != "mysql-standalone-0" {
|
||||
t.Fatalf("unexpected mysql pod: %+v", p)
|
||||
}
|
||||
inner := strings.Join(p.argv, " ")
|
||||
// password must come from the env var, never inline
|
||||
if !strings.Contains(inner, `-p"$MYSQL_ROOT_PASSWORD"`) {
|
||||
t.Fatalf("mysql must use env password wrapper: %v", p.argv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellQuoteEscapes(t *testing.T) {
|
||||
if got := shellQuote("a'b"); got != `'a'\''b'` {
|
||||
t.Fatalf("shellQuote = %q", got)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue