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
|
- `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).
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 }}"
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user