recovery(metis): read node secrets from atlas kv
This commit is contained in:
parent
17069e4677
commit
fe1ee5ac99
@ -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
|
||||
- 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.
|
||||
- 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.
|
||||
- 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).
|
||||
|
||||
@ -15,7 +15,7 @@ import (
|
||||
|
||||
func TestFilesAndInjectWithSecretsAndOverlays(t *testing.T) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@ -14,11 +14,11 @@ func TestClientLoginAndFetchBranches(t *testing.T) {
|
||||
switch {
|
||||
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"}})
|
||||
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)
|
||||
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)
|
||||
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{
|
||||
"data": map[string]any{
|
||||
"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) {
|
||||
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`))
|
||||
return
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// 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 {
|
||||
SSHPassword string `json:"ssh_password,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
|
||||
// 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) {
|
||||
if err := c.LoginIfNeeded(ctx); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -11,7 +11,7 @@ import (
|
||||
func TestFetchNodeReturnsData(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/secret/data/nodes/n1":
|
||||
case "/v1/kv/data/atlas/nodes/n1":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{
|
||||
@ -52,7 +52,7 @@ func TestApproRoleLogin(t *testing.T) {
|
||||
"client_token": "newtoken",
|
||||
},
|
||||
})
|
||||
case "/v1/secret/data/nodes/n1":
|
||||
case "/v1/kv/data/atlas/nodes/n1":
|
||||
if r.Header.Get("X-Vault-Token") != "newtoken" {
|
||||
t.Fatalf("missing token after approle login")
|
||||
}
|
||||
@ -103,7 +103,7 @@ func TestFetchNodeAndLoginErrorBranches(t *testing.T) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/auth/approle/login":
|
||||
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)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
|
||||
@ -445,7 +445,7 @@ export METIS_SSH_KEY_HECATE_DB="{{ .Data.data.hecate_db_pub }}"
|
||||
}
|
||||
nodeHostname = strings.TrimSpace(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-template-metis-node-secrets-env.sh"] = `{{ with secret "` + secretPath + `" }}
|
||||
export METIS_NODE_SSH_PASSWORD="{{ .Data.data.ssh_password }}"
|
||||
|
||||
@ -270,7 +270,7 @@ func TestRemoteWorkspaceAndHostTmpPathsPreferUsbScratchRoots(t *testing.T) {
|
||||
t.Fatalf("expected desired metadata env, got %#v", buildEnv)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if !strings.Contains(metadataAnnotations["vault.hashicorp.com/agent-inject-template-metis-node-secrets-env.sh"], "METIS_NODE_ROOT_PASSWORD") {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user