recovery(metis): read node secrets from atlas kv

This commit is contained in:
codex 2026-04-24 17:38:14 -03:00
parent 17069e4677
commit fe1ee5ac99
7 changed files with 14 additions and 14 deletions

View File

@ -42,7 +42,7 @@ Metis produces fully configured recovery SD cards for any node in the lab (RPi 4
- `metis-sentinel` stays slim for the DaemonSet that samples node facts - `metis-sentinel` stays slim for the DaemonSet that samples node facts
- Class overlays: define `boot_overlay`/`root_overlay` on a class to merge static files into boot/root at burn time (e.g., cloud-init/netplan drop-ins, GPU driver configs). Per-node config still injects hostname/IP/k3s/SSH/Longhorn. - Class overlays: define `boot_overlay`/`root_overlay` on a class to merge static files into boot/root at burn time (e.g., cloud-init/netplan drop-ins, GPU driver configs). Per-node config still injects hostname/IP/k3s/SSH/Longhorn.
- Linux loop-mount helper (losetup/mount) exists for automation; wiring into CLI burn is next. Windows writer/GUI stub forthcoming. - Linux loop-mount helper (losetup/mount) exists for automation; wiring into CLI burn is next. Windows writer/GUI stub forthcoming.
- Vault: Metis can read per-node secrets from `secret/data/nodes/<hostname>` using VAULT_ADDR plus either VAULT_TOKEN or AppRole (VAULT_ROLE_ID/VAULT_SECRET_ID). Expected fields: ssh_password, k3s_token, cloud_init, extra map. - Vault: Metis can read per-node secrets from `kv/data/atlas/nodes/<hostname>` using VAULT_ADDR plus either VAULT_TOKEN or AppRole (VAULT_ROLE_ID/VAULT_SECRET_ID). Expected fields: ssh_password, k3s_token, cloud_init, extra map.
- Sentinel: `metis-sentinel` collects host facts and can either print them, write local history, or push them into the Metis service. The intended deployment shape is a DaemonSet on cluster nodes plus an Ariadne-triggered Metis watch that recomputes recommended class targets and drift history. - Sentinel: `metis-sentinel` collects host facts and can either print them, write local history, or push them into the Metis service. The intended deployment shape is a DaemonSet on cluster nodes plus an Ariadne-triggered Metis watch that recomputes recommended class targets and drift history.
- Facts aggregation: `metis facts --inventory inv.yaml --snapshots ./snapshots` reads sentinel snapshot JSON files and prints per-class drift summary (kernels, containerd, k3s, package samples). Use exported ConfigMaps or `METIS_SENTINEL_OUT` history as input. - Facts aggregation: `metis facts --inventory inv.yaml --snapshots ./snapshots` reads sentinel snapshot JSON files and prints per-class drift summary (kernels, containerd, k3s, package samples). Use exported ConfigMaps or `METIS_SENTINEL_OUT` history as input.
- `metis config --inventory inv.yaml --node titan-13` prints the merged node config (hostname/IP/k3s labels/taints/Longhorn UUIDs and optional USB scratch metadata). - `metis config --inventory inv.yaml --node titan-13` prints the merged node config (hostname/IP/k3s labels/taints/Longhorn UUIDs and optional USB scratch metadata).

View File

@ -15,7 +15,7 @@ import (
func TestFilesAndInjectWithSecretsAndOverlays(t *testing.T) { func TestFilesAndInjectWithSecretsAndOverlays(t *testing.T) {
vault := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { vault := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/secret/data/nodes/titan-15" { if r.URL.Path != "/v1/kv/data/atlas/nodes/titan-15" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }

View File

@ -14,11 +14,11 @@ func TestClientLoginAndFetchBranches(t *testing.T) {
switch { switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/auth/approle/login"): case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/auth/approle/login"):
_ = json.NewEncoder(w).Encode(map[string]any{"auth": map[string]any{"client_token": "token"}}) _ = json.NewEncoder(w).Encode(map[string]any{"auth": map[string]any{"client_token": "token"}})
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/secret/data/nodes/missing"): case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/kv/data/atlas/nodes/missing"):
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/secret/data/nodes/error"): case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/kv/data/atlas/nodes/error"):
http.Error(w, "boom", http.StatusInternalServerError) http.Error(w, "boom", http.StatusInternalServerError)
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/secret/data/nodes/node1"): case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/kv/data/atlas/nodes/node1"):
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{ "data": map[string]any{
"data": map[string]any{"k3s_token": "abc", "cloud_init": "ci"}, "data": map[string]any{"k3s_token": "abc", "cloud_init": "ci"},
@ -101,7 +101,7 @@ func TestClientFetchAdditionalErrorBranches(t *testing.T) {
} }
badJSON := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { badJSON := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/secret/data/nodes/node1") { if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/kv/data/atlas/nodes/node1") {
_, _ = w.Write([]byte(`{bad-json`)) _, _ = w.Write([]byte(`{bad-json`))
return return
} }

View File

@ -13,7 +13,7 @@ import (
) )
// NodeSecrets holds per-node secret material to inject at burn time. // NodeSecrets holds per-node secret material to inject at burn time.
// These should live in Vault at secret/data/nodes/<hostname>. // These should live in Vault at kv/data/atlas/nodes/<hostname>.
type NodeSecrets struct { type NodeSecrets struct {
SSHPassword string `json:"ssh_password,omitempty"` SSHPassword string `json:"ssh_password,omitempty"`
SSHPasswordHash string `json:"ssh_password_hash,omitempty"` SSHPasswordHash string `json:"ssh_password_hash,omitempty"`
@ -88,12 +88,12 @@ func (c *Client) LoginIfNeeded(ctx context.Context) error {
// FetchNode loads per-node secret material because burn-time injection needs // FetchNode loads per-node secret material because burn-time injection needs
// a single read path that can fall back to empty secrets when Vault has no row. // a single read path that can fall back to empty secrets when Vault has no row.
// FetchNode pulls secret/data/nodes/<hostname>. // FetchNode pulls kv/data/atlas/nodes/<hostname>.
func (c *Client) FetchNode(ctx context.Context, hostname string) (*NodeSecrets, error) { func (c *Client) FetchNode(ctx context.Context, hostname string) (*NodeSecrets, error) {
if err := c.LoginIfNeeded(ctx); err != nil { if err := c.LoginIfNeeded(ctx); err != nil {
return nil, err return nil, err
} }
url := fmt.Sprintf("%s/v1/secret/data/nodes/%s", strings.TrimSuffix(c.Addr, "/"), hostname) url := fmt.Sprintf("%s/v1/kv/data/atlas/nodes/%s", strings.TrimSuffix(c.Addr, "/"), hostname)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -11,7 +11,7 @@ import (
func TestFetchNodeReturnsData(t *testing.T) { func TestFetchNodeReturnsData(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/v1/secret/data/nodes/n1": case "/v1/kv/data/atlas/nodes/n1":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{ "data": map[string]any{
@ -52,7 +52,7 @@ func TestApproRoleLogin(t *testing.T) {
"client_token": "newtoken", "client_token": "newtoken",
}, },
}) })
case "/v1/secret/data/nodes/n1": case "/v1/kv/data/atlas/nodes/n1":
if r.Header.Get("X-Vault-Token") != "newtoken" { if r.Header.Get("X-Vault-Token") != "newtoken" {
t.Fatalf("missing token after approle login") t.Fatalf("missing token after approle login")
} }
@ -103,7 +103,7 @@ func TestFetchNodeAndLoginErrorBranches(t *testing.T) {
switch r.URL.Path { switch r.URL.Path {
case "/v1/auth/approle/login": case "/v1/auth/approle/login":
http.Error(w, "denied", http.StatusForbidden) http.Error(w, "denied", http.StatusForbidden)
case "/v1/secret/data/nodes/missing": case "/v1/kv/data/atlas/nodes/missing":
http.Error(w, "down", http.StatusInternalServerError) http.Error(w, "down", http.StatusInternalServerError)
default: default:
http.NotFound(w, r) http.NotFound(w, r)

View File

@ -445,7 +445,7 @@ export METIS_SSH_KEY_HECATE_DB="{{ .Data.data.hecate_db_pub }}"
} }
nodeHostname = strings.TrimSpace(nodeHostname) nodeHostname = strings.TrimSpace(nodeHostname)
if nodeHostname != "" { if nodeHostname != "" {
secretPath := fmt.Sprintf("secret/data/nodes/%s", nodeHostname) secretPath := fmt.Sprintf("kv/data/atlas/nodes/%s", nodeHostname)
annotations["vault.hashicorp.com/agent-inject-secret-metis-node-secrets-env.sh"] = secretPath annotations["vault.hashicorp.com/agent-inject-secret-metis-node-secrets-env.sh"] = secretPath
annotations["vault.hashicorp.com/agent-inject-template-metis-node-secrets-env.sh"] = `{{ with secret "` + secretPath + `" }} annotations["vault.hashicorp.com/agent-inject-template-metis-node-secrets-env.sh"] = `{{ with secret "` + secretPath + `" }}
export METIS_NODE_SSH_PASSWORD="{{ .Data.data.ssh_password }}" export METIS_NODE_SSH_PASSWORD="{{ .Data.data.ssh_password }}"

View File

@ -270,7 +270,7 @@ func TestRemoteWorkspaceAndHostTmpPathsPreferUsbScratchRoots(t *testing.T) {
t.Fatalf("expected desired metadata env, got %#v", buildEnv) t.Fatalf("expected desired metadata env, got %#v", buildEnv)
} }
metadataAnnotations := buildSpec["metadata"].(map[string]any)["annotations"].(map[string]string) metadataAnnotations := buildSpec["metadata"].(map[string]any)["annotations"].(map[string]string)
if metadataAnnotations["vault.hashicorp.com/agent-inject-secret-metis-node-secrets-env.sh"] != "secret/data/nodes/titan-10" { if metadataAnnotations["vault.hashicorp.com/agent-inject-secret-metis-node-secrets-env.sh"] != "kv/data/atlas/nodes/titan-10" {
t.Fatalf("unexpected node secret annotation: %#v", metadataAnnotations) t.Fatalf("unexpected node secret annotation: %#v", metadataAnnotations)
} }
if !strings.Contains(metadataAnnotations["vault.hashicorp.com/agent-inject-template-metis-node-secrets-env.sh"], "METIS_NODE_ROOT_PASSWORD") { if !strings.Contains(metadataAnnotations["vault.hashicorp.com/agent-inject-template-metis-node-secrets-env.sh"], "METIS_NODE_ROOT_PASSWORD") {