Compare commits
2 commits
46166c63b2
...
6d5d3726d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5d3726d6 | ||
|
|
48225f2dea |
9 changed files with 378 additions and 9 deletions
|
|
@ -7,6 +7,7 @@ Control and query Home Assistant entities on ha-sofia.viktorbarzin.me.
|
|||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
|
@ -17,13 +18,29 @@ except ImportError:
|
|||
print(" pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
# Configuration from environment variables (ha-sofia specific)
|
||||
HA_URL = os.environ.get("HOME_ASSISTANT_SOFIA_URL", "").rstrip("/")
|
||||
HA_TOKEN = os.environ.get("HOME_ASSISTANT_SOFIA_TOKEN")
|
||||
|
||||
if not HA_URL or not HA_TOKEN:
|
||||
print("ERROR: HOME_ASSISTANT_SOFIA_URL and HOME_ASSISTANT_SOFIA_TOKEN environment variables must be set.")
|
||||
print("These should be set when activating the Claude venv (~/.venvs/claude)")
|
||||
def _token_from_homelab():
|
||||
"""Resolve the token via the homelab CLI when the env var isn't set, so the
|
||||
script works from any directory / unprovisioned session (see ADR-0012)."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["homelab", "ha", "token", "--instance", "sofia"],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
if out.returncode == 0 and out.stdout.strip():
|
||||
return out.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# Configuration: prefer env vars (set by the Claude venv); otherwise fall back to
|
||||
# defaults + the homelab CLI so the script is not cwd/env dependent (ADR-0012).
|
||||
HA_URL = os.environ.get("HOME_ASSISTANT_SOFIA_URL", "").rstrip("/") or "https://ha-sofia.viktorbarzin.me"
|
||||
HA_TOKEN = os.environ.get("HOME_ASSISTANT_SOFIA_TOKEN") or _token_from_homelab()
|
||||
|
||||
if not HA_TOKEN:
|
||||
print("ERROR: no ha-sofia API token available.")
|
||||
print("Set HOME_ASSISTANT_SOFIA_TOKEN, or ensure `homelab ha token` works (kubeconfig reachable).")
|
||||
sys.exit(1)
|
||||
|
||||
HEADERS = {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ There are **two** Home Assistant instances:
|
|||
- Environment variables for each instance:
|
||||
- **ha-london**: `HOME_ASSISTANT_URL` and `HOME_ASSISTANT_TOKEN`
|
||||
- **ha-sofia**: `HOME_ASSISTANT_SOFIA_URL` and `HOME_ASSISTANT_SOFIA_TOKEN`
|
||||
- If those env vars aren't set (e.g. you're not in the infra repo / Claude venv), don't hand-roll a `kubectl | base64 | jq` token pipeline — use the global **`homelab` CLI** instead (on `$PATH` in any directory):
|
||||
|
||||
## homelab CLI (preferred — works from any directory)
|
||||
- **Token**: `homelab ha token [--instance sofia|london]` resolves the long-lived API token live from the cluster. Use it directly in curl: `curl -H "Authorization: Bearer $(homelab ha token)" https://ha-sofia.viktorbarzin.me/api/states`. (The `home-assistant-sofia.py` script also auto-falls-back to this when its env var is unset.)
|
||||
- **Host shell** (ha-sofia): `homelab ha ssh -- <cmd>` runs a command on the HA host with deterministic non-interactive ssh (no host-key prompt) — e.g. `homelab ha ssh -- "sudo docker ps"`, `homelab ha ssh -- "cat /config/configuration.yaml"`. Replaces bespoke `ssh -o StrictHostKeyChecking=no …` invocations.
|
||||
- **Cluster metrics/logs** (not HA-specific): prefer `homelab metrics query "<promql>"` / `homelab logs query "<logql>"` over hand-rolled `curl …/api/v1/query`, and `homelab claim`/`release` over calling `scripts/presence` directly.
|
||||
|
||||
## API Control
|
||||
|
||||
|
|
|
|||
|
|
@ -289,7 +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). Infra 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`). Kubernetes (v0.2): `homelab k8s status|get|logs|describe|debug|pf|rollout-status <app>` (read; `<app>` defaults to the namespace, target to `deploy/<app>`), `homelab k8s db <app> [--mysql] -- "<SQL>"`, `k8s exec`, `k8s restart`, `k8s rm-pod` (pods/jobs only) — config-mutation kubectl verbs are intentionally absent (Terraform-only). Memory (v0.3): `homelab memory recall "<context>"` (semantic search), `memory list|categories|tags|stats|secret`, `memory store|update|delete` — a direct HTTP client to claude-memory that works even when the memory MCP is down. CI/deploy (v0.4): `homelab ci status|watch [commit]` (Woodpecker, repo resolved from cwd), `homelab deploy wait <ns>/<deploy> [--sha]` (image-sha + rollout) — `work land` now auto-watches CI to green. Net/obs (v0.5): `homelab net check <host> [path]` (external-CF vs internal-LB reachability), `dns lookup <name>` (Technitium vs public diff), `metrics query "<promql>"` / `metrics alerts` (Prometheus via LB), `logs query "<logql>" [--since]` (Loki via LB) — endpoint resolution baked in, no port-forward. Usage telemetry (v0.6): every dispatched verb fire-and-forgets a Loki line (`{user,verb}` + exit only, NO args/secrets; opt-out `HOMELAB_TELEMETRY=0`); `homelab usage top [--since][--user]` ranks verb usage across all users — evidence for what to build next, queryable without reading anyone's home. Full docs: `cli/README.md`.
|
||||
- **`homelab` CLI** (`/usr/local/bin/homelab`, source `cli/`): unified infra-ops verbs — run `homelab manifest` to discover the surface (each verb tagged read/write). Infra 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`). Kubernetes (v0.2): `homelab k8s status|get|logs|describe|debug|pf|rollout-status <app>` (read; `<app>` defaults to the namespace, target to `deploy/<app>`), `homelab k8s db <app> [--mysql] -- "<SQL>"`, `k8s exec`, `k8s restart`, `k8s rm-pod` (pods/jobs only) — config-mutation kubectl verbs are intentionally absent (Terraform-only). Memory (v0.3): `homelab memory recall "<context>"` (semantic search), `memory list|categories|tags|stats|secret`, `memory store|update|delete` — a direct HTTP client to claude-memory that works even when the memory MCP is down. CI/deploy (v0.4): `homelab ci status|watch [commit]` (Woodpecker, repo resolved from cwd), `homelab deploy wait <ns>/<deploy> [--sha]` (image-sha + rollout) — `work land` now auto-watches CI to green. Net/obs (v0.5): `homelab net check <host> [path]` (external-CF vs internal-LB reachability), `dns lookup <name>` (Technitium vs public diff), `metrics query "<promql>"` / `metrics alerts` (Prometheus via LB), `logs query "<logql>" [--since]` (Loki via LB) — endpoint resolution baked in, no port-forward. Usage telemetry (v0.6): every dispatched verb fire-and-forgets a Loki line (`{user,verb}` + exit only, NO args/secrets; opt-out `HOMELAB_TELEMETRY=0`); `homelab usage top [--since][--user]` ranks verb usage across all users — evidence for what to build next, queryable without reading anyone's home. Home Assistant (v0.7): `homelab ha token [--instance sofia|london]` (prints the long-lived API token, resolved live from k8s Secret `openclaw/openclaw-secrets` — use as `curl -H "Authorization: Bearer $(homelab ha token)"`), `homelab ha ssh [--instance sofia|london] -- <cmd>` (run a command on the HA host; deterministic non-interactive ssh, the invoking user's `~/.ssh/id_ed25519`, sofia=`vbarzin@192.168.1.8` default) — entity state/control stays with the `ha` MCP, these cover only what an API-only MCP can't (token + host shell). 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.
|
||||
|
|
|
|||
|
|
@ -147,6 +147,30 @@ the privacy-preserving answer to "what does the team use."
|
|||
|---|---|---|
|
||||
| `usage top [--since 30d] [--user U] [--json]` | read | rank verbs by invocation count across all users (or one), via `sum by (verb) (count_over_time({job="homelab-usage"}[…]))` |
|
||||
|
||||
### v0.7 verbs — Home Assistant
|
||||
|
||||
Cover exactly the two things the `ha` **MCP server can't**: resolving the
|
||||
long-lived API token out of the cluster, and SSH to the HA host for host-level
|
||||
work (config files, docker, add-ons). Entity state and control (`turn_on`,
|
||||
`get_state`, services) stay with the MCP — *actions an MCP already encodes are
|
||||
out of scope* (see top of this doc). The value here is the same as `net`/`dns`:
|
||||
the non-obvious *which secret, which host, which key, which flags* you'd
|
||||
otherwise re-derive every session — agents were hand-rolling a
|
||||
`kubectl | base64 | jq` token pipeline and a bespoke `ssh -o …` invocation on
|
||||
every run because the existing `home-assistant-sofia.py` needs an env var set
|
||||
and a cwd-relative path, neither of which holds in an arbitrary session.
|
||||
|
||||
| Command | Tier | What it does |
|
||||
|---|---|---|
|
||||
| `ha token [--instance sofia\|london]` | read | print the long-lived HA API token, resolved live from k8s Secret `openclaw/openclaw-secrets` (`skill_secrets` JSON) via the ambient kubeconfig — no pre-set env var. Use as `curl -H "Authorization: Bearer $(homelab ha token)" …` |
|
||||
| `ha ssh [--instance sofia\|london] [-i KEY] -- <cmd>` | write | run `<cmd>` on the HA host over ssh with deterministic non-interactive flags (explicit key = the invoking user's `~/.ssh/id_ed25519`, no user ssh-config, no known_hosts prompt). sofia (`vbarzin@192.168.1.8`) is reachable from the devvm LAN; london is documented but generally remote |
|
||||
|
||||
`--instance` defaults to **sofia** (the devvm shares the Sofia LAN). `ha token`
|
||||
prints the bare token to stdout so it composes in `$(…)`; it's read-tier like
|
||||
`memory secret`. `ha ssh` resolves the *invoking user's* key, so it's per-user,
|
||||
not tied to whoever first wrote the workflow (the user's key must be enrolled on
|
||||
the HA host).
|
||||
|
||||
## Build / install
|
||||
|
||||
Built from source to `/usr/local/bin/homelab` during devvm provisioning
|
||||
|
|
@ -166,4 +190,4 @@ original flag-based path unchanged, so the webhook handler is unaffected.
|
|||
|
||||
## Design
|
||||
|
||||
See `infra/docs/adr/0004`–`0011` for the architecture decisions.
|
||||
See `infra/docs/adr/0004`–`0012` for the architecture decisions.
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
v0.6.0
|
||||
v0.7.0
|
||||
|
|
|
|||
180
cli/cmd_ha.go
Normal file
180
cli/cmd_ha.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Home Assistant verbs cover the two things the `ha` MCP server can't: resolving
|
||||
// the long-lived API token out of the cluster, and SSH to the HA host for
|
||||
// host-level work (config files, docker, add-ons). Entity state/control stays
|
||||
// with the MCP — see docs/adr/0012.
|
||||
//
|
||||
// The token lives in a k8s Secret (a JSON blob of several skill tokens), the
|
||||
// same place the openclaw agent reads it from. `ha token` resolves it on demand
|
||||
// via the ambient kubeconfig, so it never depends on a pre-set env var (the gap
|
||||
// that made agents re-derive the kubectl|base64|jq pipeline every session).
|
||||
|
||||
type haInstance struct {
|
||||
name string // sofia | london
|
||||
sshUser string // SSH login on the HA host
|
||||
sshHost string // host reachable from the devvm (Sofia LAN)
|
||||
secretKey string // key inside skill_secrets holding this instance's token
|
||||
}
|
||||
|
||||
const (
|
||||
haDefaultInstance = "sofia"
|
||||
haSecretNamespace = "openclaw"
|
||||
haSecretName = "openclaw-secrets"
|
||||
haSecretField = "skill_secrets" // a base64 JSON blob: {token-name: token}
|
||||
)
|
||||
|
||||
// haInstances maps instance name → connection/secret facts. sofia is the default
|
||||
// because the devvm is on the Sofia LAN; london is documented but its host
|
||||
// (192.168.8.x) is only reachable remotely, so `ha ssh --instance london`
|
||||
// generally won't connect from here (token resolution still works).
|
||||
var haInstances = map[string]haInstance{
|
||||
"sofia": {name: "sofia", sshUser: "vbarzin", sshHost: "192.168.1.8", secretKey: "home_assistant_sofia_token"},
|
||||
"london": {name: "london", sshUser: "hassio", sshHost: "192.168.8.103", secretKey: "home_assistant_token"},
|
||||
}
|
||||
|
||||
func haCommands() []Command {
|
||||
return []Command{
|
||||
{Path: []string{"ha", "token"}, Tier: TierRead,
|
||||
Summary: "reveal the HA long-lived API token from the cluster: ha token [--instance sofia|london]", Run: haToken},
|
||||
{Path: []string{"ha", "ssh"}, Tier: TierWrite,
|
||||
Summary: "run a command on the HA host over ssh: ha ssh [--instance sofia|london] [-i KEY] -- <cmd>", Run: haSSH},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveHAInstance looks up an instance by name; "" yields the default (sofia).
|
||||
func resolveHAInstance(name string) (haInstance, error) {
|
||||
if name == "" {
|
||||
name = haDefaultInstance
|
||||
}
|
||||
inst, ok := haInstances[name]
|
||||
if !ok {
|
||||
return haInstance{}, fmt.Errorf("unknown HA instance %q (want sofia or london)", name)
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// parseSkillSecret decodes the base64 skill_secrets blob (as returned by kubectl
|
||||
// jsonpath, trailing whitespace tolerated) and returns the value for key.
|
||||
func parseSkillSecret(b64, key string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode %s: %w", haSecretField, err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return "", fmt.Errorf("parse %s json: %w", haSecretField, err)
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("key %q not present in %s", key, haSecretField)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func haToken(args []string) error {
|
||||
name, _ := firstPositional(args) // accept `ha token sofia` as well as `--instance sofia`
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--instance" && i+1 < len(args) {
|
||||
name = args[i+1]
|
||||
} else if strings.HasPrefix(args[i], "--instance=") {
|
||||
name = strings.TrimPrefix(args[i], "--instance=")
|
||||
}
|
||||
}
|
||||
inst, err := resolveHAInstance(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b64, err := kubectlCapture(haSecretNamespace, "get", "secret", haSecretName,
|
||||
"-o", "jsonpath={.data."+haSecretField+"}")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read secret %s/%s (kubeconfig set?): %w", haSecretNamespace, haSecretName, err)
|
||||
}
|
||||
if b64 == "" {
|
||||
return fmt.Errorf("secret %s/%s has no %q field", haSecretNamespace, haSecretName, haSecretField)
|
||||
}
|
||||
tok, err := parseSkillSecret(b64, inst.secretKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(tok)
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultHAKeyPath is the invoking user's ed25519 key, so the verb is per-user
|
||||
// rather than tied to whoever first wrote the workflow.
|
||||
func defaultHAKeyPath() string {
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
return filepath.Join(home, ".ssh", "id_ed25519")
|
||||
}
|
||||
return filepath.Join("~", ".ssh", "id_ed25519")
|
||||
}
|
||||
|
||||
// parseHASSH reads `[--instance X] [-i|--key PATH] [-- ] <cmd...>`. Tokens after
|
||||
// `--` are taken verbatim; bare tokens before it are also the remote command.
|
||||
func parseHASSH(args []string) (inst haInstance, keyPath string, remote []string, err error) {
|
||||
name := haDefaultInstance
|
||||
keyPath = defaultHAKeyPath()
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "--":
|
||||
remote = append(remote, args[i+1:]...)
|
||||
i = len(args)
|
||||
case a == "--instance":
|
||||
if i+1 < len(args) {
|
||||
name = args[i+1]
|
||||
i++
|
||||
}
|
||||
case strings.HasPrefix(a, "--instance="):
|
||||
name = strings.TrimPrefix(a, "--instance=")
|
||||
case a == "--key" || a == "-i":
|
||||
if i+1 < len(args) {
|
||||
keyPath = args[i+1]
|
||||
i++
|
||||
}
|
||||
case strings.HasPrefix(a, "--key="):
|
||||
keyPath = strings.TrimPrefix(a, "--key=")
|
||||
default:
|
||||
remote = append(remote, a)
|
||||
}
|
||||
}
|
||||
inst, err = resolveHAInstance(name)
|
||||
return inst, keyPath, remote, err
|
||||
}
|
||||
|
||||
// buildHASSHArgs assembles deterministic, non-interactive ssh args: an explicit
|
||||
// key, no user ssh config, and no known_hosts prompt/record — so it runs
|
||||
// unattended in an agent session without hanging on a host-key prompt.
|
||||
func buildHASSHArgs(inst haInstance, keyPath string, remote []string) []string {
|
||||
args := []string{
|
||||
"-F", "/dev/null",
|
||||
"-o", "IdentityFile=" + keyPath,
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-o", "BatchMode=yes",
|
||||
inst.sshUser + "@" + inst.sshHost,
|
||||
}
|
||||
return append(args, remote...)
|
||||
}
|
||||
|
||||
func haSSH(args []string) error {
|
||||
inst, keyPath, remote, err := parseHASSH(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(remote) == 0 {
|
||||
return fmt.Errorf(`usage: homelab ha ssh [--instance sofia|london] [-i KEY] -- <command>`)
|
||||
}
|
||||
return runStreaming("ssh", buildHASSHArgs(inst, keyPath, remote)...)
|
||||
}
|
||||
95
cli/cmd_ha_test.go
Normal file
95
cli/cmd_ha_test.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveHAInstance(t *testing.T) {
|
||||
// empty defaults to sofia (the devvm sits on the Sofia LAN)
|
||||
if got, err := resolveHAInstance(""); err != nil || got.name != "sofia" {
|
||||
t.Fatalf(`resolveHAInstance("") = %+v, %v; want sofia`, got, err)
|
||||
}
|
||||
if got, err := resolveHAInstance("sofia"); err != nil || got.secretKey != "home_assistant_sofia_token" {
|
||||
t.Fatalf("sofia secretKey = %q, %v", got.secretKey, err)
|
||||
}
|
||||
if got, err := resolveHAInstance("london"); err != nil || got.secretKey != "home_assistant_token" || got.sshUser != "hassio" {
|
||||
t.Fatalf("london = %+v, %v", got, err)
|
||||
}
|
||||
if _, err := resolveHAInstance("paris"); err == nil {
|
||||
t.Fatalf("resolveHAInstance(paris) should error on unknown instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSkillSecret(t *testing.T) {
|
||||
blob := base64.StdEncoding.EncodeToString([]byte(
|
||||
`{"home_assistant_sofia_token":"tok-sofia","home_assistant_token":"tok-london","slack_webhook":"https://x"}`))
|
||||
|
||||
if got, err := parseSkillSecret(blob, "home_assistant_sofia_token"); err != nil || got != "tok-sofia" {
|
||||
t.Fatalf("parseSkillSecret sofia = %q, %v; want tok-sofia", got, err)
|
||||
}
|
||||
// kubectl jsonpath output can carry trailing whitespace/newline — must tolerate it
|
||||
if got, err := parseSkillSecret(blob+"\n", "home_assistant_token"); err != nil || got != "tok-london" {
|
||||
t.Fatalf("parseSkillSecret london (trailing ws) = %q, %v; want tok-london", got, err)
|
||||
}
|
||||
if _, err := parseSkillSecret(blob, "missing_key"); err == nil {
|
||||
t.Fatalf("parseSkillSecret should error on a key absent from the blob")
|
||||
}
|
||||
if _, err := parseSkillSecret("not-base64!!", "home_assistant_sofia_token"); err == nil {
|
||||
t.Fatalf("parseSkillSecret should error on undecodable base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHASSHArgs(t *testing.T) {
|
||||
inst, _ := resolveHAInstance("sofia")
|
||||
got := buildHASSHArgs(inst, "/home/u/.ssh/id_ed25519", []string{"cat", "/config/configuration.yaml"})
|
||||
want := []string{
|
||||
"-F", "/dev/null",
|
||||
"-o", "IdentityFile=/home/u/.ssh/id_ed25519",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-o", "BatchMode=yes",
|
||||
"vbarzin@192.168.1.8",
|
||||
"cat", "/config/configuration.yaml",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildHASSHArgs =\n %v\nwant\n %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHASSH(t *testing.T) {
|
||||
// instance flag + everything after `--` is the verbatim remote command
|
||||
inst, key, remote, err := parseHASSH([]string{"--instance", "sofia", "--", "docker", "ps", "-a"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseHASSH err: %v", err)
|
||||
}
|
||||
if inst.name != "sofia" {
|
||||
t.Errorf("instance = %q, want sofia", inst.name)
|
||||
}
|
||||
if !strings.HasSuffix(key, "/.ssh/id_ed25519") {
|
||||
t.Errorf("default key = %q, want it to end in /.ssh/id_ed25519", key)
|
||||
}
|
||||
if !reflect.DeepEqual(remote, []string{"docker", "ps", "-a"}) {
|
||||
t.Errorf("remote = %v, want [docker ps -a]", remote)
|
||||
}
|
||||
|
||||
// bare args (no `--`) are also taken as the remote command; -i overrides the key
|
||||
_, key2, remote2, err := parseHASSH([]string{"-i", "/tmp/k", "uptime"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseHASSH err: %v", err)
|
||||
}
|
||||
if key2 != "/tmp/k" {
|
||||
t.Errorf("key = %q, want /tmp/k", key2)
|
||||
}
|
||||
if !reflect.DeepEqual(remote2, []string{"uptime"}) {
|
||||
t.Errorf("remote = %v, want [uptime]", remote2)
|
||||
}
|
||||
|
||||
// unknown instance surfaces as an error
|
||||
if _, _, _, err := parseHASSH([]string{"--instance", "paris", "--", "ls"}); err == nil {
|
||||
t.Errorf("parseHASSH should error on unknown instance")
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ func buildRegistry() []Command {
|
|||
reg = append(reg, netCommands()...)
|
||||
reg = append(reg, obsCommands()...)
|
||||
reg = append(reg, usageCommands()...)
|
||||
reg = append(reg, haCommands()...)
|
||||
return reg
|
||||
}
|
||||
|
||||
|
|
|
|||
46
docs/adr/0012-homelab-ha-verbs.md
Normal file
46
docs/adr/0012-homelab-ha-verbs.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# homelab Home Assistant verbs: token resolution + host SSH, not entity control
|
||||
|
||||
v0.7 adds `ha token` and `ha ssh`. They were chosen by mining a heavy HA
|
||||
operator's sessions: across ~1,900 shell commands the single most-repeated line
|
||||
(420×) was a hand-rolled `kubectl … | base64 -d | python -c '…token'` pipeline,
|
||||
and a bespoke `ssh -o StrictHostKeyChecking=no -o …` invocation was redefined as
|
||||
a shell function ~30× — both re-derived from scratch every session. The existing
|
||||
`home-assistant-sofia.py` already covers the *API*, but it goes unused from an
|
||||
arbitrary cwd (it needs `HOME_ASSISTANT_SOFIA_TOKEN` set and is referenced by a
|
||||
cwd-relative path), so agents bypassed it. A global verb on `$PATH` closes that
|
||||
gap for every user in every directory.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Only the two gaps the `ha` MCP can't fill.** The `ha` MCP server already
|
||||
does entity state and control (`get_state`, `call_service`, history, logs).
|
||||
Per the CLI's founding rule — *MCP-encoded actions are out of scope* (ADR-0004)
|
||||
— we do **not** reimplement `on`/`off`/`list`/`state`. We add only token
|
||||
*resolution* and host *SSH*, neither of which an API-only MCP can provide. The
|
||||
value is endpoint/secret/host resolution, exactly like `net`/`dns` (ADR-0010).
|
||||
- **`ha token` resolves live from the cluster, not from an env var.** It reads
|
||||
k8s Secret `openclaw/openclaw-secrets`, field `skill_secrets` (a base64 JSON
|
||||
blob of several tokens), and prints the per-instance key
|
||||
(`home_assistant_sofia_token` / `home_assistant_token`) via the ambient
|
||||
kubeconfig. This is robust to env drift — the precise failure that made agents
|
||||
re-derive the pipeline. Read-tier, prints the bare token to stdout so it
|
||||
composes in `$(…)`, mirroring `memory secret`.
|
||||
- **`ha ssh` is deterministic and per-user.** Flags are fixed for unattended
|
||||
use: `-F /dev/null` (ignore user ssh-config), `StrictHostKeyChecking=no` +
|
||||
`UserKnownHostsFile=/dev/null` (no host-key prompt/record — agents have no
|
||||
TTY), `BatchMode=yes` + `ConnectTimeout=10` (fail fast, never hang). The key
|
||||
is the **invoking user's** `~/.ssh/id_ed25519`, so the verb isn't tied to
|
||||
whoever first wrote the workflow; that user's key must be enrolled on the HA
|
||||
host. Write-tier (runs an arbitrary remote command).
|
||||
- **sofia is the default; london is structural.** The devvm sits on the Sofia
|
||||
LAN, so `vbarzin@192.168.1.8` is reachable and is the default instance. london
|
||||
(`hassio@192.168.8.103`) is in the instance map so `ha token --instance london`
|
||||
works (a pure secret read), but `ha ssh --instance london` generally won't
|
||||
connect from here — london is remote. We model it correctly rather than
|
||||
pretend it's reachable.
|
||||
- **Scope held at two verbs.** `ha api` (an authenticated curl passthrough for
|
||||
the endpoints the MCP/script don't cover — `/api/template`, `/reload`,
|
||||
`check_config`, `/error_log`) was deferred: once `ha token` exists, raw curl is
|
||||
already unblocked, and a generic passthrough overlaps the MCP. Re-measure via
|
||||
`usage top` (ADR-0011); add targeted sugar verbs only if those endpoints are
|
||||
still hand-rolled often.
|
||||
Loading…
Add table
Add a link
Reference in a new issue