@meshxdata/fops 0.1.45 → 0.1.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +202 -17
- package/package.json +1 -1
- package/src/commands/lifecycle.js +81 -5
- package/src/commands/setup.js +45 -4
- package/src/plugins/bundled/fops-plugin-azure/index.js +29 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +1185 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +1180 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-ingress.js +393 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +104 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-network.js +296 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +768 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-reconcilers.js +538 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-secrets.js +849 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-stacks.js +643 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-state.js +145 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +496 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-terraform.js +1032 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +155 -4245
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +186 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +5 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +758 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/registry-cmds.js +250 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +2 -1
- package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +3 -2
- package/src/plugins/bundled/fops-plugin-foundation/lib/helpers.js +21 -0
- package/src/plugins/bundled/fops-plugin-foundation/lib/tools-read.js +3 -5
- package/src/ui/tui/App.js +13 -13
- package/src/web/dist/assets/index-NXC8Hvnp.css +1 -0
- package/src/web/dist/assets/index-QH1N4ejK.js +112 -0
- package/src/web/dist/index.html +2 -2
- package/src/web/server.js +4 -4
- package/src/web/dist/assets/index-BphVaAUd.css +0 -1
- package/src/web/dist/assets/index-CSckLzuG.js +0 -129
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* azure-aks-secrets.js - Key Vault, SecretStore, and secrets management
|
|
3
|
+
*
|
|
4
|
+
* Depends on: azure-aks-naming.js, azure-aks-state.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { DEFAULTS, OK, WARN, DIM, hint, subArgs } from "./azure.js";
|
|
8
|
+
import { kvName, generatePassword, PG_DEFAULTS } from "./azure-aks-naming.js";
|
|
9
|
+
import { writeClusterState } from "./azure-aks-state.js";
|
|
10
|
+
|
|
11
|
+
// ── Secret Store Configuration ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export const SECRET_STORE_NAMESPACES_BASE = ["foundation"];
|
|
14
|
+
export const SECRET_STORE_NAMESPACES_DAI = ["dai"];
|
|
15
|
+
export const SECRET_STORE_NAME = "azure-secretsmanager";
|
|
16
|
+
|
|
17
|
+
// ── Key Vault seed secrets mapping ────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export const KV_SEED_SECRETS = [
|
|
20
|
+
{ name: "foundation-secrets", envKeys: {
|
|
21
|
+
password: "POSTGRES_PASSWORD",
|
|
22
|
+
secret_key: "MX_SECRET_KEY",
|
|
23
|
+
"auth0.secret": "AUTH0_SECRET",
|
|
24
|
+
"auth0.client_id": "AUTH0_CLIENT_ID",
|
|
25
|
+
"auth0.client_secret": "AUTH0_CLIENT_SECRET",
|
|
26
|
+
"auth0.domain": "AUTH0_DOMAIN",
|
|
27
|
+
"auth0.audience": "AUTH0_AUDIENCE",
|
|
28
|
+
"auth0.issuer_base_url": "AUTH0_ISSUER_BASE_URL",
|
|
29
|
+
"auth0.base_url": "AUTH0_BASE_URL",
|
|
30
|
+
}},
|
|
31
|
+
{ name: "auth0", envKeys: {
|
|
32
|
+
client_id: "AUTH0_CLIENT_ID",
|
|
33
|
+
client_secret: "AUTH0_CLIENT_SECRET",
|
|
34
|
+
}},
|
|
35
|
+
{ name: "foundation-trino-jwt", envKeys: {
|
|
36
|
+
"jwt-secret.pem": "MX_SECRET_KEY",
|
|
37
|
+
secret_key: "MX_SECRET_KEY",
|
|
38
|
+
}},
|
|
39
|
+
{ name: "foundation-nats", envKeys: {
|
|
40
|
+
"nkeys-secret": "MX_SECRET_KEY",
|
|
41
|
+
secret_key: "MX_SECRET_KEY",
|
|
42
|
+
}},
|
|
43
|
+
{ name: "foundation-storage-engine-auth", envKeys: {
|
|
44
|
+
AUTH_IDENTITY: "AUTH_IDENTITY",
|
|
45
|
+
AUTH_CREDENTIAL: "AUTH_CREDENTIAL",
|
|
46
|
+
}},
|
|
47
|
+
{ name: "foundation-storage-engine-secrets", envKeys: {
|
|
48
|
+
AZURE_ACCOUNT_NAME: "AZURE_STORAGE_ACCOUNT",
|
|
49
|
+
AZURE_ACCOUNT_KEY: "AZURE_STORAGE_KEY",
|
|
50
|
+
AZURE_SAS_TOKEN: "AZURE_STORAGE_SAS_TOKEN",
|
|
51
|
+
}},
|
|
52
|
+
{ name: "dai-secrets", daiOnly: true, envKeys: {
|
|
53
|
+
AUTH0_CLIENT_ID: "AUTH0_CLIENT_ID",
|
|
54
|
+
AUTH0_CLIENT_SECRET: "AUTH0_CLIENT_SECRET",
|
|
55
|
+
AUTH0_SECRET: "AUTH0_SECRET",
|
|
56
|
+
NATS_NKEYS_SECRET: "MX_SECRET_KEY",
|
|
57
|
+
AZURE_OPENAI_API_KEY: "MX_OPENAI_API_KEY",
|
|
58
|
+
}},
|
|
59
|
+
{ name: "foundation-scheduler-secrets", envKeys: {
|
|
60
|
+
secretKey: "MX_SECRET_KEY",
|
|
61
|
+
}},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// ── SecretStore reconciliation ────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export async function reconcileSecretStore(ctx) {
|
|
67
|
+
const { execa, clusterName, rg, sub, opts } = ctx;
|
|
68
|
+
const vaultName = kvName(clusterName);
|
|
69
|
+
const location = ctx.cluster?.location || DEFAULTS.location;
|
|
70
|
+
const ssNamespaces = opts?.dai
|
|
71
|
+
? [...SECRET_STORE_NAMESPACES_BASE, ...SECRET_STORE_NAMESPACES_DAI]
|
|
72
|
+
: SECRET_STORE_NAMESPACES_BASE;
|
|
73
|
+
|
|
74
|
+
const kubectl = (args, kopts = {}) =>
|
|
75
|
+
execa("kubectl", ["--context", clusterName, ...args], { timeout: 30000, reject: false, ...kopts });
|
|
76
|
+
|
|
77
|
+
// 1. Ensure Key Vault exists
|
|
78
|
+
const { exitCode: kvExists } = await execa("az", [
|
|
79
|
+
"keyvault", "show", "--name", vaultName, "--output", "none",
|
|
80
|
+
...subArgs(sub),
|
|
81
|
+
], { reject: false, timeout: 30000 });
|
|
82
|
+
|
|
83
|
+
if (kvExists !== 0) {
|
|
84
|
+
hint(`Creating Key Vault "${vaultName}"…`);
|
|
85
|
+
const { exitCode, stderr } = await execa("az", [
|
|
86
|
+
"keyvault", "create",
|
|
87
|
+
"--name", vaultName,
|
|
88
|
+
"--resource-group", rg,
|
|
89
|
+
"--location", location,
|
|
90
|
+
"--enable-rbac-authorization", "true",
|
|
91
|
+
"--output", "none",
|
|
92
|
+
...subArgs(sub),
|
|
93
|
+
], { timeout: 120000, reject: false });
|
|
94
|
+
if (exitCode !== 0) {
|
|
95
|
+
console.log(WARN(` ⚠ Key Vault creation failed: ${(stderr || "").split("\n")[0]}`));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
console.log(OK(` ✓ Key Vault "${vaultName}" created`));
|
|
99
|
+
} else {
|
|
100
|
+
console.log(OK(` ✓ Key Vault "${vaultName}" exists`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. Get Key Vault resource ID and ensure SP has Secrets Officer role
|
|
104
|
+
const { stdout: kvJson } = await execa("az", [
|
|
105
|
+
"keyvault", "show", "--name", vaultName, "--query", "id", "-o", "tsv",
|
|
106
|
+
...subArgs(sub),
|
|
107
|
+
], { reject: false, timeout: 30000 });
|
|
108
|
+
const kvId = (kvJson || "").trim();
|
|
109
|
+
|
|
110
|
+
const spClientId = await getSpClientId(kubectl);
|
|
111
|
+
if (spClientId && kvId) {
|
|
112
|
+
const { stdout: spObjId } = await execa("az", [
|
|
113
|
+
"ad", "sp", "show", "--id", spClientId, "--query", "id", "-o", "tsv",
|
|
114
|
+
], { reject: false, timeout: 30000 });
|
|
115
|
+
const objectId = (spObjId || "").trim();
|
|
116
|
+
|
|
117
|
+
if (objectId) {
|
|
118
|
+
const { exitCode: roleExists } = await execa("az", [
|
|
119
|
+
"role", "assignment", "list",
|
|
120
|
+
"--assignee", objectId,
|
|
121
|
+
"--role", "Key Vault Secrets Officer",
|
|
122
|
+
"--scope", kvId,
|
|
123
|
+
"--query", "[0].id", "-o", "tsv",
|
|
124
|
+
...subArgs(sub),
|
|
125
|
+
], { reject: false, timeout: 30000 });
|
|
126
|
+
|
|
127
|
+
const hasRole = roleExists === 0;
|
|
128
|
+
if (!hasRole) {
|
|
129
|
+
await execa("az", [
|
|
130
|
+
"role", "assignment", "create",
|
|
131
|
+
"--assignee-object-id", objectId,
|
|
132
|
+
"--assignee-principal-type", "ServicePrincipal",
|
|
133
|
+
"--role", "Key Vault Secrets Officer",
|
|
134
|
+
"--scope", kvId,
|
|
135
|
+
...subArgs(sub),
|
|
136
|
+
], { reject: false, timeout: 30000 });
|
|
137
|
+
console.log(OK(" ✓ SP granted Key Vault Secrets Officer role"));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2b. Grant AKS kubelet identity access to Key Vault (for ExternalSecrets with ManagedIdentity auth)
|
|
143
|
+
if (kvId) {
|
|
144
|
+
const { stdout: kubeletId } = await execa("az", [
|
|
145
|
+
"aks", "show", "-g", rg, "-n", clusterName,
|
|
146
|
+
"--query", "identityProfile.kubeletidentity.objectId", "-o", "tsv",
|
|
147
|
+
...subArgs(sub),
|
|
148
|
+
], { reject: false, timeout: 30000 });
|
|
149
|
+
const kubeletObjectId = (kubeletId || "").trim();
|
|
150
|
+
|
|
151
|
+
if (kubeletObjectId) {
|
|
152
|
+
const { stdout: hasKubeletRole } = await execa("az", [
|
|
153
|
+
"role", "assignment", "list",
|
|
154
|
+
"--assignee", kubeletObjectId,
|
|
155
|
+
"--role", "Key Vault Secrets User",
|
|
156
|
+
"--scope", kvId,
|
|
157
|
+
"--query", "[0].id", "-o", "tsv",
|
|
158
|
+
...subArgs(sub),
|
|
159
|
+
], { reject: false, timeout: 30000 });
|
|
160
|
+
|
|
161
|
+
if (!hasKubeletRole?.trim()) {
|
|
162
|
+
await execa("az", [
|
|
163
|
+
"role", "assignment", "create",
|
|
164
|
+
"--assignee-object-id", kubeletObjectId,
|
|
165
|
+
"--assignee-principal-type", "ServicePrincipal",
|
|
166
|
+
"--role", "Key Vault Secrets User",
|
|
167
|
+
"--scope", kvId,
|
|
168
|
+
...subArgs(sub),
|
|
169
|
+
], { reject: false, timeout: 30000 });
|
|
170
|
+
console.log(OK(" ✓ AKS kubelet identity granted Key Vault Secrets User role"));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 3. Ensure azure-secret-sp exists in each target namespace
|
|
176
|
+
const { stdout: spSecretJson } = await kubectl([
|
|
177
|
+
"get", "secret", "azure-secret-sp", "-n", "foundation", "-o", "json",
|
|
178
|
+
]);
|
|
179
|
+
const spSecret = spSecretJson ? JSON.parse(spSecretJson) : null;
|
|
180
|
+
|
|
181
|
+
if (!spSecret || !spSecret.data?.ClientID) {
|
|
182
|
+
console.log(WARN(" ⚠ Secret 'azure-secret-sp' not found in foundation — SecretStore needs SP credentials"));
|
|
183
|
+
hint("Create it with: kubectl create secret generic azure-secret-sp -n foundation --from-literal=ClientID=<sp-client-id> --from-literal=ClientSecret=<sp-secret>");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Replicate azure-secret-sp to other namespaces
|
|
188
|
+
for (const ns of ssNamespaces) {
|
|
189
|
+
await kubectl(["create", "namespace", ns, "--dry-run=client", "-o", "yaml"]);
|
|
190
|
+
const { exitCode: nsExists } = await kubectl(["get", "namespace", ns]);
|
|
191
|
+
if (nsExists !== 0) {
|
|
192
|
+
await kubectl(["create", "namespace", ns]);
|
|
193
|
+
}
|
|
194
|
+
if (ns === "foundation") continue;
|
|
195
|
+
const { exitCode: spExists } = await kubectl(["get", "secret", "azure-secret-sp", "-n", ns]);
|
|
196
|
+
if (spExists !== 0) {
|
|
197
|
+
await kubectl([
|
|
198
|
+
"create", "secret", "generic", "azure-secret-sp",
|
|
199
|
+
"-n", ns,
|
|
200
|
+
"--from-literal", `ClientID=${Buffer.from(spSecret.data.ClientID, "base64").toString()}`,
|
|
201
|
+
"--from-literal", `ClientSecret=${Buffer.from(spSecret.data.ClientSecret, "base64").toString()}`,
|
|
202
|
+
]);
|
|
203
|
+
console.log(OK(` ✓ Replicated azure-secret-sp to ${ns}`));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 4. Get tenant ID
|
|
208
|
+
const { stdout: tenantId } = await execa("az", [
|
|
209
|
+
"account", "show", "--query", "tenantId", "-o", "tsv",
|
|
210
|
+
...subArgs(sub),
|
|
211
|
+
], { reject: false, timeout: 15000 });
|
|
212
|
+
|
|
213
|
+
// 5. Create SecretStore in each namespace
|
|
214
|
+
const apiVersion = await detectEsApiVersion(kubectl);
|
|
215
|
+
|
|
216
|
+
for (const ns of ssNamespaces) {
|
|
217
|
+
const { exitCode: ssExists } = await kubectl([
|
|
218
|
+
"get", "secretstore", SECRET_STORE_NAME, "-n", ns,
|
|
219
|
+
]);
|
|
220
|
+
if (ssExists === 0) continue;
|
|
221
|
+
|
|
222
|
+
const manifest = `apiVersion: ${apiVersion}
|
|
223
|
+
kind: SecretStore
|
|
224
|
+
metadata:
|
|
225
|
+
name: ${SECRET_STORE_NAME}
|
|
226
|
+
namespace: ${ns}
|
|
227
|
+
spec:
|
|
228
|
+
provider:
|
|
229
|
+
azurekv:
|
|
230
|
+
authType: ServicePrincipal
|
|
231
|
+
vaultUrl: https://${vaultName}.vault.azure.net
|
|
232
|
+
tenantId: ${(tenantId || "").trim()}
|
|
233
|
+
authSecretRef:
|
|
234
|
+
clientId:
|
|
235
|
+
name: azure-secret-sp
|
|
236
|
+
key: ClientID
|
|
237
|
+
clientSecret:
|
|
238
|
+
name: azure-secret-sp
|
|
239
|
+
key: ClientSecret
|
|
240
|
+
`;
|
|
241
|
+
const { exitCode: applyCode, stderr } = await kubectl(
|
|
242
|
+
["apply", "-f", "-"],
|
|
243
|
+
{ input: manifest },
|
|
244
|
+
);
|
|
245
|
+
if (applyCode === 0) {
|
|
246
|
+
console.log(OK(` ✓ SecretStore "${SECRET_STORE_NAME}" created in ${ns}`));
|
|
247
|
+
} else {
|
|
248
|
+
console.log(WARN(` ⚠ SecretStore creation failed in ${ns}: ${(stderr || "").split("\n")[0]}`));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 6. Seed Key Vault secrets from a running VM if the vault is empty
|
|
253
|
+
await seedKeyVaultFromVm(execa, { vaultName, sub, dai: opts?.dai });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── K8s secrets reconciliation ────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export async function reconcileK8sSecrets(ctx) {
|
|
259
|
+
const { execa, clusterName, rg, sub } = ctx;
|
|
260
|
+
const { pgServerName } = await import("./azure-aks-naming.js");
|
|
261
|
+
const { PG_SERVICE_DBS } = await import("./azure-aks-postgres.js");
|
|
262
|
+
|
|
263
|
+
const kubectl = (args, opts = {}) =>
|
|
264
|
+
execa("kubectl", ["--context", clusterName, ...args], { timeout: 30000, reject: false, ...opts });
|
|
265
|
+
|
|
266
|
+
// Read current postgres password
|
|
267
|
+
const { stdout: pwB64 } = await kubectl([
|
|
268
|
+
"get", "secret", "postgres", "-n", "foundation",
|
|
269
|
+
"-o", "jsonpath={.data.password}",
|
|
270
|
+
]);
|
|
271
|
+
if (!pwB64) return;
|
|
272
|
+
let pgPass = Buffer.from(pwB64, "base64").toString();
|
|
273
|
+
const pgHost = `${pgServerName(clusterName)}.postgres.database.azure.com`;
|
|
274
|
+
const serverName = pgServerName(clusterName);
|
|
275
|
+
|
|
276
|
+
// Check for URL-unsafe characters that break pogo-migrate connection strings
|
|
277
|
+
const URL_UNSAFE = /[^a-zA-Z0-9]/;
|
|
278
|
+
if (URL_UNSAFE.test(pgPass)) {
|
|
279
|
+
console.log(WARN(" ⚠ Postgres password contains URL-unsafe characters — regenerating…"));
|
|
280
|
+
const newPass = generatePassword();
|
|
281
|
+
|
|
282
|
+
// Update the Azure Flexible Server admin password
|
|
283
|
+
await execa("az", [
|
|
284
|
+
"postgres", "flexible-server", "update",
|
|
285
|
+
"--name", serverName, "--resource-group", rg,
|
|
286
|
+
"--admin-password", newPass,
|
|
287
|
+
"--output", "none", ...subArgs(sub),
|
|
288
|
+
], { reject: false, timeout: 120000 });
|
|
289
|
+
|
|
290
|
+
// Update all database role passwords via psql job
|
|
291
|
+
const sqlStatements = PG_SERVICE_DBS
|
|
292
|
+
.map(role => `ALTER ROLE ${role} WITH PASSWORD '${newPass}';`)
|
|
293
|
+
.join(" ");
|
|
294
|
+
const jobYaml = JSON.stringify({
|
|
295
|
+
apiVersion: "batch/v1", kind: "Job",
|
|
296
|
+
metadata: { name: "fops-pg-repass", namespace: "foundation" },
|
|
297
|
+
spec: {
|
|
298
|
+
backoffLimit: 2, ttlSecondsAfterFinished: 120,
|
|
299
|
+
template: {
|
|
300
|
+
spec: {
|
|
301
|
+
restartPolicy: "Never",
|
|
302
|
+
containers: [{
|
|
303
|
+
name: "psql", image: "postgres:16-alpine",
|
|
304
|
+
command: ["sh", "-c", `export PGPASSWORD='${newPass}' PGHOST='${pgHost}' PGUSER=foundation PGSSLMODE=require; psql -d foundation -c "${sqlStatements}"`],
|
|
305
|
+
}],
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
await kubectl(["delete", "job", "fops-pg-repass", "-n", "foundation", "--ignore-not-found"]);
|
|
311
|
+
await kubectl(["apply", "-f", "-"], { input: jobYaml, timeout: 60000 });
|
|
312
|
+
// Wait for the job to complete
|
|
313
|
+
await kubectl(["wait", "--for=condition=complete", "job/fops-pg-repass", "-n", "foundation", "--timeout=60s"], { timeout: 70000 });
|
|
314
|
+
|
|
315
|
+
pgPass = newPass;
|
|
316
|
+
|
|
317
|
+
// Update local state
|
|
318
|
+
writeClusterState(clusterName, {
|
|
319
|
+
postgres: { adminPassword: newPass },
|
|
320
|
+
});
|
|
321
|
+
console.log(OK(" ✓ Postgres password regenerated (URL-safe)"));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Ensure postgres secret has all required keys (mlflow needs mlflow-username etc.)
|
|
325
|
+
const { stdout: existingKeys } = await kubectl([
|
|
326
|
+
"get", "secret", "postgres", "-n", "foundation",
|
|
327
|
+
"-o", "jsonpath={.data}",
|
|
328
|
+
]);
|
|
329
|
+
const keys = existingKeys ? Object.keys(JSON.parse(existingKeys)) : [];
|
|
330
|
+
const requiredKeys = [
|
|
331
|
+
"host", "user", "password", "superUserPassword",
|
|
332
|
+
"postgres-password", "mlflow-username", "mlflow-password",
|
|
333
|
+
];
|
|
334
|
+
const missing = requiredKeys.filter(k => !keys.includes(k));
|
|
335
|
+
const needsUpdate = missing.length > 0 || URL_UNSAFE.test(Buffer.from(pwB64, "base64").toString());
|
|
336
|
+
|
|
337
|
+
if (needsUpdate) {
|
|
338
|
+
if (missing.length) hint(`Adding missing keys to postgres secret: ${missing.join(", ")}`);
|
|
339
|
+
const args = [
|
|
340
|
+
"create", "secret", "generic", "postgres", "-n", "foundation",
|
|
341
|
+
"--from-literal", `host=${pgHost}`,
|
|
342
|
+
"--from-literal", `user=foundation`,
|
|
343
|
+
"--from-literal", `password=${pgPass}`,
|
|
344
|
+
"--from-literal", `superUserPassword=${pgPass}`,
|
|
345
|
+
"--from-literal", `postgres-password=${pgPass}`,
|
|
346
|
+
"--from-literal", `mlflow-username=mlflow`,
|
|
347
|
+
"--from-literal", `mlflow-password=${pgPass}`,
|
|
348
|
+
"--dry-run=client", "-o", "yaml",
|
|
349
|
+
];
|
|
350
|
+
const { stdout: secretYaml } = await kubectl(args);
|
|
351
|
+
if (secretYaml) {
|
|
352
|
+
await kubectl(["apply", "-f", "-"], { input: secretYaml });
|
|
353
|
+
console.log(OK(" ✓ Postgres secret updated with all required keys"));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Ensure OPA keypair secret exists
|
|
358
|
+
const { exitCode: opaExists } = await kubectl([
|
|
359
|
+
"get", "secret", "foundation-opa-keypair", "-n", "foundation",
|
|
360
|
+
]);
|
|
361
|
+
if (opaExists !== 0) {
|
|
362
|
+
hint("Creating OPA keypair secret…");
|
|
363
|
+
// Use the OPA AWS-style keys from the VM env or generate placeholders
|
|
364
|
+
const { listVms } = await import("./azure-state.js");
|
|
365
|
+
const { vms: vmMap } = listVms();
|
|
366
|
+
const vms = Object.entries(vmMap || {}).filter(([, v]) => v.ip);
|
|
367
|
+
|
|
368
|
+
let opaAccessKey = "placeholder";
|
|
369
|
+
let opaSecretKey = "placeholder";
|
|
370
|
+
|
|
371
|
+
if (vms.length > 0) {
|
|
372
|
+
const [, vm] = vms[0];
|
|
373
|
+
const { sshCmd } = await import("./azure-helpers.js");
|
|
374
|
+
const user = vm.adminUser || "azureuser";
|
|
375
|
+
const { stdout: envContent } = await sshCmd(
|
|
376
|
+
execa, vm.ip, user,
|
|
377
|
+
"grep -E '^OPA_(ACCESS_KEY_ID|SECRET_ACCESS_KEY)=' /opt/foundation-compose/.env 2>/dev/null",
|
|
378
|
+
15000,
|
|
379
|
+
);
|
|
380
|
+
if (envContent) {
|
|
381
|
+
const m1 = envContent.match(/OPA_ACCESS_KEY_ID=(.+)/);
|
|
382
|
+
const m2 = envContent.match(/OPA_SECRET_ACCESS_KEY=(.+)/);
|
|
383
|
+
if (m1) opaAccessKey = m1[1].trim();
|
|
384
|
+
if (m2) opaSecretKey = m2[1].trim();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const { stdout: opaYaml } = await kubectl([
|
|
389
|
+
"create", "secret", "generic", "foundation-opa-keypair", "-n", "foundation",
|
|
390
|
+
"--from-literal", `OPA_ACCESS_KEY_ID=${opaAccessKey}`,
|
|
391
|
+
"--from-literal", `OPA_SECRET_ACCESS_KEY=${opaSecretKey}`,
|
|
392
|
+
"--dry-run=client", "-o", "yaml",
|
|
393
|
+
]);
|
|
394
|
+
if (opaYaml) {
|
|
395
|
+
await kubectl(["apply", "-f", "-"], { input: opaYaml });
|
|
396
|
+
console.log(OK(" ✓ OPA keypair secret created"));
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
console.log(OK(" ✓ OPA keypair secret exists"));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Ensure foundation-postgres ExternalName service exists (hive-metastore needs this)
|
|
403
|
+
const { exitCode: pgSvcExists } = await kubectl([
|
|
404
|
+
"get", "service", "foundation-postgres", "-n", "foundation",
|
|
405
|
+
]);
|
|
406
|
+
if (pgSvcExists !== 0) {
|
|
407
|
+
const svcYaml = JSON.stringify({
|
|
408
|
+
apiVersion: "v1",
|
|
409
|
+
kind: "Service",
|
|
410
|
+
metadata: { name: "foundation-postgres", namespace: "foundation" },
|
|
411
|
+
spec: {
|
|
412
|
+
type: "ExternalName",
|
|
413
|
+
externalName: pgHost,
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
await kubectl(["apply", "-f", "-"], { input: svcYaml });
|
|
417
|
+
console.log(OK(" ✓ foundation-postgres ExternalName service created"));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── Vault unseal config reconciliation ────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
export async function reconcileVaultUnseal(ctx) {
|
|
424
|
+
const { execa, clusterName, rg, sub } = ctx;
|
|
425
|
+
const kubectl = (args, opts = {}) =>
|
|
426
|
+
execa("kubectl", ["--context", clusterName, ...args], { timeout: 30000, reject: false, ...opts });
|
|
427
|
+
|
|
428
|
+
const correctKv = kvName(clusterName);
|
|
429
|
+
|
|
430
|
+
// Check if Vault CR exists
|
|
431
|
+
const { exitCode, stdout } = await kubectl([
|
|
432
|
+
"get", "vault", "vault", "-n", "foundation",
|
|
433
|
+
"-o", "jsonpath={.spec.unsealConfig.azure.keyVaultName}",
|
|
434
|
+
]);
|
|
435
|
+
if (exitCode !== 0) return;
|
|
436
|
+
|
|
437
|
+
const currentKv = (stdout || "").trim();
|
|
438
|
+
if (currentKv === correctKv) {
|
|
439
|
+
console.log(OK(` ✓ Vault unseal config → ${correctKv}`));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
hint(`Patching Vault unsealConfig: ${currentKv || "(unset)"} → ${correctKv}`);
|
|
444
|
+
await kubectl([
|
|
445
|
+
"patch", "vault", "vault", "-n", "foundation",
|
|
446
|
+
"--type", "merge", "-p",
|
|
447
|
+
JSON.stringify({ spec: { unsealConfig: { azure: { keyVaultName: correctKv } } } }),
|
|
448
|
+
]);
|
|
449
|
+
|
|
450
|
+
// Ensure the Vault SP has Secrets Officer on the KV
|
|
451
|
+
const spClientId = await getSpClientId(kubectl);
|
|
452
|
+
if (spClientId) {
|
|
453
|
+
// Also check envsConfig for AZURE_CLIENT_ID which vault uses directly
|
|
454
|
+
const { stdout: vaultSpId } = await kubectl([
|
|
455
|
+
"get", "vault", "vault", "-n", "foundation",
|
|
456
|
+
"-o", "jsonpath={.spec.envsConfig[?(@.name=='AZURE_CLIENT_ID')].value}",
|
|
457
|
+
]);
|
|
458
|
+
const targetSp = (vaultSpId || "").trim() || spClientId;
|
|
459
|
+
|
|
460
|
+
const { stdout: kvId } = await execa("az", [
|
|
461
|
+
"keyvault", "show", "--name", correctKv, "--query", "id", "-o", "tsv",
|
|
462
|
+
...subArgs(sub),
|
|
463
|
+
], { reject: false, timeout: 15000 });
|
|
464
|
+
|
|
465
|
+
if (kvId?.trim()) {
|
|
466
|
+
await execa("az", [
|
|
467
|
+
"role", "assignment", "create",
|
|
468
|
+
"--assignee", targetSp,
|
|
469
|
+
"--role", "Key Vault Secrets Officer",
|
|
470
|
+
"--scope", kvId.trim(),
|
|
471
|
+
"--output", "none", ...subArgs(sub),
|
|
472
|
+
], { reject: false, timeout: 30000 });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Restart vault pod to pick up the new unseal config
|
|
477
|
+
await kubectl(["delete", "pod", "vault-0", "-n", "foundation", "--ignore-not-found"]);
|
|
478
|
+
console.log(OK(` ✓ Vault unseal config patched → ${correctKv}`));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Seed Key Vault from VM ────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
export async function seedKeyVaultFromVm(execa, { vaultName, sub, dai }) {
|
|
484
|
+
// Check if vault already has secrets
|
|
485
|
+
const { stdout: existingSecrets } = await execa("az", [
|
|
486
|
+
"keyvault", "secret", "list", "--vault-name", vaultName,
|
|
487
|
+
"--query", "length(@)", "-o", "tsv",
|
|
488
|
+
...subArgs(sub),
|
|
489
|
+
], { reject: false, timeout: 30000 });
|
|
490
|
+
|
|
491
|
+
if (parseInt(existingSecrets || "0", 10) > 0) {
|
|
492
|
+
console.log(OK(` ✓ Key Vault has ${existingSecrets.trim()} secret(s) — skipping seed`));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Try to pull .env from a running VM
|
|
497
|
+
const { listVms } = await import("./azure-state.js");
|
|
498
|
+
const { vms: vmMap } = listVms();
|
|
499
|
+
const vms = Object.entries(vmMap || {}).filter(([, v]) => v.ip);
|
|
500
|
+
if (vms.length === 0) {
|
|
501
|
+
hint("No VMs tracked — seed Key Vault manually or run fops azure aks seed-secrets");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const [vmName, vm] = vms[0];
|
|
506
|
+
hint(`Seeding Key Vault from VM "${vmName}" (${vm.ip})…`);
|
|
507
|
+
|
|
508
|
+
const { sshCmd } = await import("./azure-helpers.js");
|
|
509
|
+
const user = vm.adminUser || "azureuser";
|
|
510
|
+
const { stdout: envContent, exitCode } = await sshCmd(
|
|
511
|
+
execa, vm.ip, user,
|
|
512
|
+
"cat /opt/foundation-compose/.env 2>/dev/null",
|
|
513
|
+
30000,
|
|
514
|
+
);
|
|
515
|
+
if (exitCode !== 0 || !envContent) {
|
|
516
|
+
console.log(WARN(` ⚠ Could not read .env from ${vmName} — seed manually`));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const envMap = {};
|
|
521
|
+
for (const line of envContent.split("\n")) {
|
|
522
|
+
const m = line.match(/^([A-Z0-9_]+)=(.+)$/);
|
|
523
|
+
if (m) envMap[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
let seeded = 0;
|
|
527
|
+
const secrets = dai ? KV_SEED_SECRETS : KV_SEED_SECRETS.filter(s => !s.daiOnly);
|
|
528
|
+
for (const { name: secretName, envKeys } of secrets) {
|
|
529
|
+
const secretObj = {};
|
|
530
|
+
for (const [prop, envKey] of Object.entries(envKeys)) {
|
|
531
|
+
if (envMap[envKey]) secretObj[prop] = envMap[envKey];
|
|
532
|
+
}
|
|
533
|
+
if (Object.keys(secretObj).length === 0) continue;
|
|
534
|
+
|
|
535
|
+
const value = JSON.stringify(secretObj);
|
|
536
|
+
const { exitCode: setCode } = await execa("az", [
|
|
537
|
+
"keyvault", "secret", "set",
|
|
538
|
+
"--vault-name", vaultName,
|
|
539
|
+
"--name", secretName,
|
|
540
|
+
"--value", value,
|
|
541
|
+
"--content-type", "application/json",
|
|
542
|
+
...subArgs(sub),
|
|
543
|
+
], { reject: false, timeout: 30000 });
|
|
544
|
+
|
|
545
|
+
if (setCode === 0) seeded++;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (seeded > 0) {
|
|
549
|
+
console.log(OK(` ✓ Seeded ${seeded} secret(s) into Key Vault from VM "${vmName}"`));
|
|
550
|
+
} else {
|
|
551
|
+
console.log(WARN(" ⚠ No matching env vars found on VM — seed secrets manually"));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ── Helper functions ──────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
export async function getSpClientId(kubectl) {
|
|
558
|
+
const { stdout } = await kubectl([
|
|
559
|
+
"get", "secret", "azure-secret-sp", "-n", "foundation",
|
|
560
|
+
"-o", "jsonpath={.data.ClientID}",
|
|
561
|
+
]);
|
|
562
|
+
if (!stdout) return null;
|
|
563
|
+
return Buffer.from(stdout, "base64").toString();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export async function detectEsApiVersion(kubectl) {
|
|
567
|
+
const { exitCode } = await kubectl([
|
|
568
|
+
"api-resources", "--api-group=external-secrets.io",
|
|
569
|
+
"-o", "name",
|
|
570
|
+
]);
|
|
571
|
+
if (exitCode !== 0) return "external-secrets.io/v1beta1";
|
|
572
|
+
const { stdout } = await kubectl([
|
|
573
|
+
"get", "crd", "secretstores.external-secrets.io",
|
|
574
|
+
"-o", "jsonpath={.spec.versions[*].name}",
|
|
575
|
+
]);
|
|
576
|
+
const versions = (stdout || "").split(/\s+/);
|
|
577
|
+
if (versions.includes("v1")) return "external-secrets.io/v1";
|
|
578
|
+
if (versions.includes("v1beta1")) return "external-secrets.io/v1beta1";
|
|
579
|
+
return "external-secrets.io/v1";
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── Vault auto-unseal bootstrap ──────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
export const VAULT_UNSEAL_KEY_NAME = "vault-unseal";
|
|
585
|
+
|
|
586
|
+
export async function aksVaultInit(opts = {}) {
|
|
587
|
+
const { lazyExeca, ensureAzCli, ensureAzAuth, banner, subArgs } = await import("./azure.js");
|
|
588
|
+
const { requireCluster, writeClusterState } = await import("./azure-aks-state.js");
|
|
589
|
+
const { kvName } = await import("./azure-aks-naming.js");
|
|
590
|
+
|
|
591
|
+
const execa = await lazyExeca();
|
|
592
|
+
const sub = opts.profile;
|
|
593
|
+
await ensureAzCli(execa);
|
|
594
|
+
await ensureAzAuth(execa, { subscription: sub });
|
|
595
|
+
|
|
596
|
+
const { clusterName, resourceGroup: rg } = requireCluster(opts.clusterName);
|
|
597
|
+
const vaultName = kvName(clusterName);
|
|
598
|
+
|
|
599
|
+
banner(`Vault Auto-Unseal Bootstrap: ${clusterName}`);
|
|
600
|
+
|
|
601
|
+
const kubectl = (args, kopts = {}) =>
|
|
602
|
+
execa("kubectl", ["--context", clusterName, ...args], { timeout: 60000, reject: false, ...kopts });
|
|
603
|
+
|
|
604
|
+
// 1. Ensure Key Vault exists
|
|
605
|
+
const { exitCode: kvExists } = await execa("az", [
|
|
606
|
+
"keyvault", "show", "--name", vaultName, "--output", "none",
|
|
607
|
+
...subArgs(sub),
|
|
608
|
+
], { reject: false, timeout: 30000 });
|
|
609
|
+
|
|
610
|
+
if (kvExists !== 0) {
|
|
611
|
+
console.log(WARN(` ⚠ Key Vault "${vaultName}" not found. Run "fops azure aks doctor" first.`));
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
console.log(OK(` ✓ Key Vault "${vaultName}" exists`));
|
|
615
|
+
|
|
616
|
+
// 2. Get tenant ID
|
|
617
|
+
const { stdout: tenantIdRaw } = await execa("az", [
|
|
618
|
+
"account", "show", "--query", "tenantId", "-o", "tsv", ...subArgs(sub),
|
|
619
|
+
], { reject: false, timeout: 15000 });
|
|
620
|
+
const tenantId = (tenantIdRaw || "").trim();
|
|
621
|
+
|
|
622
|
+
// 3. Get Key Vault resource ID
|
|
623
|
+
const { stdout: kvIdRaw } = await execa("az", [
|
|
624
|
+
"keyvault", "show", "--name", vaultName, "--query", "id", "-o", "tsv", ...subArgs(sub),
|
|
625
|
+
], { reject: false, timeout: 15000 });
|
|
626
|
+
const kvId = (kvIdRaw || "").trim();
|
|
627
|
+
|
|
628
|
+
// 4. Create or verify the unseal key in Key Vault
|
|
629
|
+
const { exitCode: keyExists } = await execa("az", [
|
|
630
|
+
"keyvault", "key", "show", "--vault-name", vaultName, "--name", VAULT_UNSEAL_KEY_NAME,
|
|
631
|
+
"--output", "none", ...subArgs(sub),
|
|
632
|
+
], { reject: false, timeout: 15000 });
|
|
633
|
+
|
|
634
|
+
if (keyExists !== 0) {
|
|
635
|
+
hint(`Creating Key Vault key "${VAULT_UNSEAL_KEY_NAME}" for Vault auto-unseal…`);
|
|
636
|
+
const { exitCode: createCode, stderr } = await execa("az", [
|
|
637
|
+
"keyvault", "key", "create",
|
|
638
|
+
"--vault-name", vaultName,
|
|
639
|
+
"--name", VAULT_UNSEAL_KEY_NAME,
|
|
640
|
+
"--kty", "RSA",
|
|
641
|
+
"--size", "2048",
|
|
642
|
+
"--ops", "wrapKey", "unwrapKey",
|
|
643
|
+
"--output", "none", ...subArgs(sub),
|
|
644
|
+
], { reject: false, timeout: 30000 });
|
|
645
|
+
if (createCode !== 0) {
|
|
646
|
+
console.log(WARN(` ⚠ Failed to create unseal key: ${(stderr || "").split("\n")[0]}`));
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
console.log(OK(` ✓ Created Key Vault key "${VAULT_UNSEAL_KEY_NAME}"`));
|
|
650
|
+
} else {
|
|
651
|
+
console.log(OK(` ✓ Key Vault key "${VAULT_UNSEAL_KEY_NAME}" exists`));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// 5. Grant AKS kubelet identity Crypto User role on Key Vault
|
|
655
|
+
const { stdout: kubeletIdRaw } = await execa("az", [
|
|
656
|
+
"aks", "show", "-g", rg, "-n", clusterName,
|
|
657
|
+
"--query", "identityProfile.kubeletidentity.objectId", "-o", "tsv", ...subArgs(sub),
|
|
658
|
+
], { reject: false, timeout: 30000 });
|
|
659
|
+
const kubeletObjectId = (kubeletIdRaw || "").trim();
|
|
660
|
+
|
|
661
|
+
if (kubeletObjectId && kvId) {
|
|
662
|
+
const { stdout: hasCryptoRole } = await execa("az", [
|
|
663
|
+
"role", "assignment", "list",
|
|
664
|
+
"--assignee", kubeletObjectId,
|
|
665
|
+
"--role", "Key Vault Crypto User",
|
|
666
|
+
"--scope", kvId,
|
|
667
|
+
"--query", "[0].id", "-o", "tsv", ...subArgs(sub),
|
|
668
|
+
], { reject: false, timeout: 30000 });
|
|
669
|
+
|
|
670
|
+
if (!hasCryptoRole?.trim()) {
|
|
671
|
+
await execa("az", [
|
|
672
|
+
"role", "assignment", "create",
|
|
673
|
+
"--assignee-object-id", kubeletObjectId,
|
|
674
|
+
"--assignee-principal-type", "ServicePrincipal",
|
|
675
|
+
"--role", "Key Vault Crypto User",
|
|
676
|
+
"--scope", kvId,
|
|
677
|
+
"--output", "none", ...subArgs(sub),
|
|
678
|
+
], { reject: false, timeout: 30000 });
|
|
679
|
+
console.log(OK(" ✓ Granted AKS kubelet identity Key Vault Crypto User role"));
|
|
680
|
+
} else {
|
|
681
|
+
console.log(OK(" ✓ AKS kubelet identity has Key Vault Crypto User role"));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// 6. Check current Vault pod status
|
|
686
|
+
const { stdout: vaultPodJson } = await kubectl([
|
|
687
|
+
"get", "pod", "-n", "foundation", "-l", "app.kubernetes.io/name=vault",
|
|
688
|
+
"-o", "json",
|
|
689
|
+
]);
|
|
690
|
+
const vaultPods = vaultPodJson ? JSON.parse(vaultPodJson).items : [];
|
|
691
|
+
const vaultPod = vaultPods[0];
|
|
692
|
+
|
|
693
|
+
if (!vaultPod) {
|
|
694
|
+
console.log(WARN(" ⚠ No Vault pod found. Deploy Vault first via Flux."));
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// 7. Check if Vault is already initialized
|
|
699
|
+
const { stdout: vaultStatusRaw, exitCode: statusCode } = await kubectl([
|
|
700
|
+
"exec", "-n", "foundation", vaultPod.metadata.name, "--",
|
|
701
|
+
"vault", "status", "-format=json",
|
|
702
|
+
]);
|
|
703
|
+
|
|
704
|
+
let vaultStatus = null;
|
|
705
|
+
try { vaultStatus = JSON.parse(vaultStatusRaw || "{}"); } catch {}
|
|
706
|
+
|
|
707
|
+
const isInitialized = vaultStatus?.initialized === true;
|
|
708
|
+
const isSealed = vaultStatus?.sealed !== false;
|
|
709
|
+
|
|
710
|
+
console.log(DIM(` Vault status: initialized=${isInitialized}, sealed=${isSealed}`));
|
|
711
|
+
|
|
712
|
+
// 8. Update Vault CR or HelmRelease with auto-unseal config
|
|
713
|
+
const { exitCode: vaultCrExists } = await kubectl([
|
|
714
|
+
"get", "vault", "vault", "-n", "foundation",
|
|
715
|
+
]);
|
|
716
|
+
|
|
717
|
+
if (vaultCrExists === 0) {
|
|
718
|
+
// Vault Operator CR mode - patch unsealConfig
|
|
719
|
+
hint("Patching Vault CR with Azure Key Vault auto-unseal config…");
|
|
720
|
+
const patch = {
|
|
721
|
+
spec: {
|
|
722
|
+
unsealConfig: {
|
|
723
|
+
azure: {
|
|
724
|
+
keyVaultName: vaultName,
|
|
725
|
+
keyName: VAULT_UNSEAL_KEY_NAME,
|
|
726
|
+
useManagedIdentity: true,
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
await kubectl([
|
|
732
|
+
"patch", "vault", "vault", "-n", "foundation",
|
|
733
|
+
"--type", "merge", "-p", JSON.stringify(patch),
|
|
734
|
+
]);
|
|
735
|
+
console.log(OK(" ✓ Vault CR patched with auto-unseal config"));
|
|
736
|
+
} else {
|
|
737
|
+
// HelmRelease mode - check for ConfigMap/values
|
|
738
|
+
hint("Creating Vault auto-unseal ConfigMap for Helm values…");
|
|
739
|
+
const vaultConfig = {
|
|
740
|
+
seal: {
|
|
741
|
+
azurekeyvault: {
|
|
742
|
+
tenant_id: tenantId,
|
|
743
|
+
vault_name: vaultName,
|
|
744
|
+
key_name: VAULT_UNSEAL_KEY_NAME,
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
const configMapYaml = `apiVersion: v1
|
|
749
|
+
kind: ConfigMap
|
|
750
|
+
metadata:
|
|
751
|
+
name: vault-auto-unseal-config
|
|
752
|
+
namespace: foundation
|
|
753
|
+
labels:
|
|
754
|
+
app.kubernetes.io/name: vault
|
|
755
|
+
data:
|
|
756
|
+
vault-auto-unseal.hcl: |
|
|
757
|
+
seal "azurekeyvault" {
|
|
758
|
+
tenant_id = "${tenantId}"
|
|
759
|
+
vault_name = "${vaultName}"
|
|
760
|
+
key_name = "${VAULT_UNSEAL_KEY_NAME}"
|
|
761
|
+
}
|
|
762
|
+
`;
|
|
763
|
+
await kubectl(["apply", "-f", "-"], { input: configMapYaml });
|
|
764
|
+
console.log(OK(" ✓ Vault auto-unseal ConfigMap created"));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// 9. Initialize Vault if not yet initialized
|
|
768
|
+
if (!isInitialized) {
|
|
769
|
+
hint("Initializing Vault with recovery keys…");
|
|
770
|
+
const { stdout: initOutput, exitCode: initCode } = await kubectl([
|
|
771
|
+
"exec", "-n", "foundation", vaultPod.metadata.name, "--",
|
|
772
|
+
"vault", "operator", "init",
|
|
773
|
+
"-recovery-shares=5",
|
|
774
|
+
"-recovery-threshold=3",
|
|
775
|
+
"-format=json",
|
|
776
|
+
]);
|
|
777
|
+
|
|
778
|
+
if (initCode !== 0) {
|
|
779
|
+
console.log(WARN(` ⚠ Vault initialization failed. May need to restart pod after config change.`));
|
|
780
|
+
hint("Restart Vault: kubectl delete pod -n foundation -l app.kubernetes.io/name=vault");
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
let initResult;
|
|
785
|
+
try { initResult = JSON.parse(initOutput); } catch {
|
|
786
|
+
console.log(WARN(" ⚠ Could not parse Vault init output"));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
console.log(OK(" ✓ Vault initialized with auto-unseal"));
|
|
791
|
+
|
|
792
|
+
// 10. Store recovery keys in Key Vault
|
|
793
|
+
const recoveryKeys = initResult.recovery_keys_b64 || initResult.recovery_keys || [];
|
|
794
|
+
const rootToken = initResult.root_token;
|
|
795
|
+
|
|
796
|
+
if (recoveryKeys.length > 0) {
|
|
797
|
+
hint("Storing recovery keys in Azure Key Vault…");
|
|
798
|
+
const recoverySecret = JSON.stringify({
|
|
799
|
+
recovery_keys: recoveryKeys,
|
|
800
|
+
root_token: rootToken,
|
|
801
|
+
initialized_at: new Date().toISOString(),
|
|
802
|
+
cluster: clusterName,
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
await execa("az", [
|
|
806
|
+
"keyvault", "secret", "set",
|
|
807
|
+
"--vault-name", vaultName,
|
|
808
|
+
"--name", "vault-recovery-keys",
|
|
809
|
+
"--value", recoverySecret,
|
|
810
|
+
"--content-type", "application/json",
|
|
811
|
+
"--output", "none", ...subArgs(sub),
|
|
812
|
+
], { reject: false, timeout: 30000 });
|
|
813
|
+
console.log(OK(" ✓ Recovery keys stored in Key Vault as 'vault-recovery-keys'"));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Store in local cluster state too
|
|
817
|
+
writeClusterState(clusterName, {
|
|
818
|
+
vault: {
|
|
819
|
+
initialized: true,
|
|
820
|
+
autoUnseal: true,
|
|
821
|
+
keyVaultName: vaultName,
|
|
822
|
+
unsealKeyName: VAULT_UNSEAL_KEY_NAME,
|
|
823
|
+
initializedAt: new Date().toISOString(),
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
} else {
|
|
827
|
+
console.log(OK(" ✓ Vault already initialized"));
|
|
828
|
+
|
|
829
|
+
// Update state
|
|
830
|
+
writeClusterState(clusterName, {
|
|
831
|
+
vault: {
|
|
832
|
+
initialized: true,
|
|
833
|
+
autoUnseal: true,
|
|
834
|
+
keyVaultName: vaultName,
|
|
835
|
+
unsealKeyName: VAULT_UNSEAL_KEY_NAME,
|
|
836
|
+
},
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// 11. Restart Vault pod to pick up new config
|
|
841
|
+
if (isSealed || !isInitialized) {
|
|
842
|
+
hint("Restarting Vault pod to apply auto-unseal config…");
|
|
843
|
+
await kubectl(["delete", "pod", "-n", "foundation", "-l", "app.kubernetes.io/name=vault"]);
|
|
844
|
+
console.log(OK(" ✓ Vault pod restarted"));
|
|
845
|
+
hint("Vault should auto-unseal within ~30 seconds. Check: kubectl get pod -n foundation -l app.kubernetes.io/name=vault");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
console.log(OK("\n ✓ Vault auto-unseal bootstrap complete\n"));
|
|
849
|
+
}
|