@openpalm/lib 0.11.0-beta.3 → 0.11.0-beta.7
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/README.md +2 -0
- package/package.json +4 -1
- package/src/control-plane/akm-vault.ts +5 -1
- package/src/control-plane/channels.ts +8 -6
- package/src/control-plane/compose-args.test.ts +0 -9
- package/src/control-plane/compose-args.ts +0 -4
- package/src/control-plane/config-persistence.ts +48 -11
- package/src/control-plane/core-assets.ts +63 -7
- package/src/control-plane/docker.ts +15 -4
- package/src/control-plane/env.ts +4 -1
- package/src/control-plane/install-edge-cases.test.ts +3 -3
- package/src/control-plane/lifecycle.ts +31 -9
- package/src/control-plane/operator-ids.test.ts +130 -0
- package/src/control-plane/operator-ids.ts +89 -0
- package/src/control-plane/registry.test.ts +134 -4
- package/src/control-plane/registry.ts +220 -4
- package/src/control-plane/secrets.ts +4 -4
- package/src/control-plane/setup.test.ts +6 -6
- package/src/control-plane/setup.ts +4 -6
- package/src/control-plane/spec-to-env.test.ts +25 -9
- package/src/control-plane/spec-to-env.ts +28 -17
- package/src/control-plane/types.ts +0 -4
- package/src/control-plane/ui-assets.ts +45 -9
- package/src/index.ts +13 -9
- package/src/logger.test.ts +12 -12
- package/src/control-plane/admin-token.ts +0 -73
- package/src/control-plane/lock.test.ts +0 -194
- package/src/control-plane/lock.ts +0 -176
- package/src/control-plane/provider-config.ts +0 -34
- package/src/control-plane/secret-backend.test.ts +0 -346
- package/src/control-plane/secret-backend.ts +0 -362
- package/src/control-plane/spec-validator.ts +0 -62
|
@@ -1,362 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
-
import { execFile as execFileCb, spawn } from 'node:child_process';
|
|
4
|
-
import { promisify } from 'node:util';
|
|
5
|
-
import { join, normalize, resolve } from 'node:path';
|
|
6
|
-
import type { ControlPlaneState } from './types.js';
|
|
7
|
-
import {
|
|
8
|
-
classifySecretKey,
|
|
9
|
-
classifySecretScope,
|
|
10
|
-
ensurePlaintextSecretEntry,
|
|
11
|
-
findCoreSecretByKey,
|
|
12
|
-
getCoreSecretMappings,
|
|
13
|
-
readPlaintextSecretIndex,
|
|
14
|
-
removePlaintextSecretEntry,
|
|
15
|
-
type SecretEntryMetadata,
|
|
16
|
-
type SecretScope,
|
|
17
|
-
} from './secret-mappings.js';
|
|
18
|
-
import { readSecretProviderConfig } from './provider-config.js';
|
|
19
|
-
import {
|
|
20
|
-
readStackEnv,
|
|
21
|
-
updateSecretsEnv,
|
|
22
|
-
updateSystemSecretsEnv,
|
|
23
|
-
} from './secrets.js';
|
|
24
|
-
import { readUserVaultSync } from './akm-vault.js';
|
|
25
|
-
|
|
26
|
-
const execFile = promisify(execFileCb);
|
|
27
|
-
|
|
28
|
-
/** Run a command with stdin input, returning a promise. */
|
|
29
|
-
function execWithInput(
|
|
30
|
-
cmd: string,
|
|
31
|
-
args: string[],
|
|
32
|
-
input: string,
|
|
33
|
-
env: NodeJS.ProcessEnv,
|
|
34
|
-
): Promise<void> {
|
|
35
|
-
return new Promise((resolve, reject) => {
|
|
36
|
-
const child = spawn(cmd, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
37
|
-
let stderr = '';
|
|
38
|
-
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
39
|
-
child.on('error', reject);
|
|
40
|
-
child.on('close', (code) => {
|
|
41
|
-
if (code === 0) resolve();
|
|
42
|
-
else reject(new Error(`${cmd} exited with code ${code}: ${stderr}`));
|
|
43
|
-
});
|
|
44
|
-
child.stdin?.end(input);
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
type ResolvedSecretTarget = {
|
|
49
|
-
key: string;
|
|
50
|
-
scope: SecretScope;
|
|
51
|
-
envKey?: string;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Public shape returned by `detectSecretBackend`. Both the plaintext and
|
|
56
|
-
* pass implementations expose the same surface — a small set of async
|
|
57
|
-
* methods plus a `provider` tag and a flat `capabilities` object.
|
|
58
|
-
*
|
|
59
|
-
* Kept as a `type` alias of an inline object literal so consumers don't
|
|
60
|
-
* have to import a separate interface or capabilities type.
|
|
61
|
-
*/
|
|
62
|
-
export type SecretBackend = {
|
|
63
|
-
readonly provider: 'plaintext' | 'pass';
|
|
64
|
-
readonly capabilities: { generate: boolean; remove: boolean; rename: boolean };
|
|
65
|
-
list(prefix?: string): Promise<SecretEntryMetadata[]>;
|
|
66
|
-
write(key: string, value: string): Promise<SecretEntryMetadata>;
|
|
67
|
-
generate(key: string, length?: number): Promise<SecretEntryMetadata>;
|
|
68
|
-
remove(key: string): Promise<void>;
|
|
69
|
-
exists(key: string): Promise<boolean>;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
function generateSecretValue(length = 32): string {
|
|
73
|
-
// Hex encoding produces two output characters per byte. Clamp to at least
|
|
74
|
-
// 16 bytes (32 hex chars) so generated secrets stay comfortably strong.
|
|
75
|
-
return randomBytes(Math.max(16, Math.ceil(length / 2))).toString('hex').slice(0, length);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function resolvePlaintextTarget(state: ControlPlaneState, key: string): ResolvedSecretTarget {
|
|
79
|
-
const systemEnv = readStackEnv(state.stackDir);
|
|
80
|
-
const coreMapping = findCoreSecretByKey(key, systemEnv);
|
|
81
|
-
if (coreMapping) {
|
|
82
|
-
return { key, scope: coreMapping.scope, envKey: coreMapping.envKey };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const indexed = ensurePlaintextSecretEntry(state, key);
|
|
86
|
-
return { key, scope: indexed.scope, envKey: indexed.envKey };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function currentValueForTarget(state: ControlPlaneState, target: ResolvedSecretTarget): string {
|
|
90
|
-
if (!target.envKey) return '';
|
|
91
|
-
if (target.scope === 'system') {
|
|
92
|
-
return readStackEnv(state.stackDir)[target.envKey] ?? '';
|
|
93
|
-
}
|
|
94
|
-
// User scope: the akm `vault:user` store is the canonical user-managed env
|
|
95
|
-
// namespace post-#421. Fall back to stack.env for legacy/consolidated
|
|
96
|
-
// secrets so older layouts keep resolving.
|
|
97
|
-
const userEnv = readUserVaultSync(state);
|
|
98
|
-
if (target.envKey in userEnv) return userEnv[target.envKey];
|
|
99
|
-
return readStackEnv(state.stackDir)[target.envKey] ?? '';
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ── Plaintext backend ─────────────────────────────────────────────────────
|
|
103
|
-
|
|
104
|
-
export async function plaintextList(state: ControlPlaneState, prefix = 'openpalm/'): Promise<SecretEntryMetadata[]> {
|
|
105
|
-
const systemEnv = readStackEnv(state.stackDir);
|
|
106
|
-
const userEnvFile = readUserVaultSync(state);
|
|
107
|
-
// Legacy/consolidated secrets may live in stack.env even for user scope.
|
|
108
|
-
// Layer the user vault on top so explicit user-managed values win.
|
|
109
|
-
const userEnv: Record<string, string> = { ...systemEnv, ...userEnvFile };
|
|
110
|
-
const index = readPlaintextSecretIndex(state);
|
|
111
|
-
const entries: SecretEntryMetadata[] = [];
|
|
112
|
-
|
|
113
|
-
for (const mapping of getCoreSecretMappings(systemEnv)) {
|
|
114
|
-
if (!mapping.secretKey.startsWith(prefix)) continue;
|
|
115
|
-
const env = mapping.scope === 'system' ? systemEnv : userEnv;
|
|
116
|
-
entries.push({
|
|
117
|
-
key: mapping.secretKey,
|
|
118
|
-
scope: mapping.scope,
|
|
119
|
-
kind: 'core',
|
|
120
|
-
provider: 'plaintext',
|
|
121
|
-
present: Boolean(env[mapping.envKey]),
|
|
122
|
-
envKey: mapping.envKey,
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
for (const [key, entry] of Object.entries(index.entries)) {
|
|
127
|
-
if (!key.startsWith(prefix)) continue;
|
|
128
|
-
const env = entry.scope === 'system' ? systemEnv : userEnv;
|
|
129
|
-
entries.push({
|
|
130
|
-
key,
|
|
131
|
-
scope: entry.scope,
|
|
132
|
-
kind: entry.kind,
|
|
133
|
-
provider: 'plaintext',
|
|
134
|
-
present: Boolean(env[entry.envKey]),
|
|
135
|
-
envKey: entry.envKey,
|
|
136
|
-
updatedAt: entry.updatedAt,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
entries.sort((a, b) => a.key.localeCompare(b.key));
|
|
141
|
-
return entries;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export async function plaintextWrite(state: ControlPlaneState, key: string, value: string): Promise<SecretEntryMetadata> {
|
|
145
|
-
const target = resolvePlaintextTarget(state, key);
|
|
146
|
-
if (!target.envKey) {
|
|
147
|
-
throw new Error(`Unable to resolve env key for secret ${key}`);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (target.scope === 'system') {
|
|
151
|
-
updateSystemSecretsEnv(state, { [target.envKey]: value });
|
|
152
|
-
} else {
|
|
153
|
-
updateSecretsEnv(state, { [target.envKey]: value });
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
key,
|
|
158
|
-
scope: target.scope,
|
|
159
|
-
kind: key.startsWith('openpalm/component/') ? 'component' : key.startsWith('openpalm/custom/') ? 'custom' : 'core',
|
|
160
|
-
provider: 'plaintext',
|
|
161
|
-
present: true,
|
|
162
|
-
envKey: target.envKey,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export async function plaintextGenerate(state: ControlPlaneState, key: string, length = 32): Promise<SecretEntryMetadata> {
|
|
167
|
-
return plaintextWrite(state, key, generateSecretValue(length));
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export async function plaintextRemove(state: ControlPlaneState, key: string): Promise<void> {
|
|
171
|
-
const target = resolvePlaintextTarget(state, key);
|
|
172
|
-
if (target.envKey) {
|
|
173
|
-
if (target.scope === 'system') {
|
|
174
|
-
updateSystemSecretsEnv(state, { [target.envKey]: '' });
|
|
175
|
-
} else {
|
|
176
|
-
updateSecretsEnv(state, { [target.envKey]: '' });
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (!findCoreSecretByKey(key, readStackEnv(state.stackDir))) {
|
|
180
|
-
removePlaintextSecretEntry(state, key);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export async function plaintextExists(state: ControlPlaneState, key: string): Promise<boolean> {
|
|
185
|
-
const target = resolvePlaintextTarget(state, key);
|
|
186
|
-
return currentValueForTarget(state, target).length > 0;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function makePlaintextBackend(state: ControlPlaneState): SecretBackend {
|
|
190
|
-
return {
|
|
191
|
-
provider: 'plaintext',
|
|
192
|
-
capabilities: { generate: true, remove: true, rename: false },
|
|
193
|
-
list: (prefix) => plaintextList(state, prefix),
|
|
194
|
-
write: (key, value) => plaintextWrite(state, key, value),
|
|
195
|
-
generate: (key, length) => plaintextGenerate(state, key, length),
|
|
196
|
-
remove: (key) => plaintextRemove(state, key),
|
|
197
|
-
exists: (key) => plaintextExists(state, key),
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ── Pass backend ──────────────────────────────────────────────────────────
|
|
202
|
-
|
|
203
|
-
export function validatePassEntryName(entry: string): string {
|
|
204
|
-
const trimmed = entry.trim().replace(/^\/+|\/+$/g, '');
|
|
205
|
-
if (!trimmed) {
|
|
206
|
-
throw new Error('Secret key must not be empty');
|
|
207
|
-
}
|
|
208
|
-
if (trimmed.includes('..')) {
|
|
209
|
-
throw new Error('Secret key must not contain path traversal');
|
|
210
|
-
}
|
|
211
|
-
if (!/^[a-z0-9._/-]+$/.test(trimmed)) {
|
|
212
|
-
throw new Error('Secret key contains invalid characters');
|
|
213
|
-
}
|
|
214
|
-
return trimmed;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function walkPassStore(dir: string, prefix = ''): string[] {
|
|
218
|
-
if (!existsSync(dir)) return [];
|
|
219
|
-
const entries: string[] = [];
|
|
220
|
-
for (const entry of readdirSync(dir)) {
|
|
221
|
-
const fullPath = join(dir, entry);
|
|
222
|
-
const stat = statSync(fullPath);
|
|
223
|
-
if (stat.isDirectory()) {
|
|
224
|
-
entries.push(...walkPassStore(fullPath, prefix ? `${prefix}/${entry}` : entry));
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
if (!entry.endsWith('.gpg')) continue;
|
|
228
|
-
const name = entry.replace(/\.gpg$/, '');
|
|
229
|
-
entries.push(prefix ? `${prefix}/${name}` : name);
|
|
230
|
-
}
|
|
231
|
-
return entries;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Resolved pass-backend configuration. Kept as a small in-file struct so the
|
|
236
|
-
* five `pass*` helpers below share one place where defaults
|
|
237
|
-
* (`${dataDir}/secrets/pass-store`, empty prefix) are applied — inlining the
|
|
238
|
-
* `?? defaults` logic into each helper would multiply the truth source.
|
|
239
|
-
* The two strings travel together everywhere (passEnv, prefixedEntry,
|
|
240
|
-
* passKeyPath) so they're worth bundling.
|
|
241
|
-
*/
|
|
242
|
-
type PassContext = {
|
|
243
|
-
passwordStoreDir: string;
|
|
244
|
-
passPrefix: string;
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
function passContext(state: ControlPlaneState): PassContext {
|
|
248
|
-
const config = readSecretProviderConfig(state);
|
|
249
|
-
return {
|
|
250
|
-
passwordStoreDir: config?.passwordStoreDir ?? `${state.stateDir}/secrets/pass-store`,
|
|
251
|
-
passPrefix: config?.passPrefix ?? '',
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function passEnv(ctx: PassContext): NodeJS.ProcessEnv {
|
|
256
|
-
return {
|
|
257
|
-
...process.env,
|
|
258
|
-
PASSWORD_STORE_DIR: ctx.passwordStoreDir,
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** Prepend passPrefix to a canonical key for pass store operations. */
|
|
263
|
-
function prefixedEntry(ctx: PassContext, canonicalKey: string): string {
|
|
264
|
-
const entry = validatePassEntryName(canonicalKey);
|
|
265
|
-
return ctx.passPrefix ? `${ctx.passPrefix}/${entry}` : entry;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function passKeyPath(ctx: PassContext, key: string): string {
|
|
269
|
-
const prefixed = prefixedEntry(ctx, key);
|
|
270
|
-
const normalizedEntry = normalize(prefixed);
|
|
271
|
-
const resolvedPath = resolve(ctx.passwordStoreDir, `${normalizedEntry}.gpg`);
|
|
272
|
-
const resolvedStore = resolve(ctx.passwordStoreDir);
|
|
273
|
-
if (!resolvedPath.startsWith(`${resolvedStore}/`)) {
|
|
274
|
-
throw new Error('Secret key resolves outside the password store');
|
|
275
|
-
}
|
|
276
|
-
return resolvedPath;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
export async function passList(state: ControlPlaneState, prefix = 'openpalm/'): Promise<SecretEntryMetadata[]> {
|
|
280
|
-
const ctx = passContext(state);
|
|
281
|
-
// Scope walk to the passPrefix subdirectory
|
|
282
|
-
const walkDir = ctx.passPrefix
|
|
283
|
-
? join(ctx.passwordStoreDir, ctx.passPrefix)
|
|
284
|
-
: ctx.passwordStoreDir;
|
|
285
|
-
return walkPassStore(walkDir)
|
|
286
|
-
.filter((entry) => entry.startsWith(prefix))
|
|
287
|
-
.sort((a, b) => a.localeCompare(b))
|
|
288
|
-
.map((key) => ({
|
|
289
|
-
key,
|
|
290
|
-
scope: classifySecretScope(key),
|
|
291
|
-
kind: classifySecretKey(key),
|
|
292
|
-
provider: 'pass',
|
|
293
|
-
present: true,
|
|
294
|
-
}));
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export async function passWrite(state: ControlPlaneState, key: string, value: string): Promise<SecretEntryMetadata> {
|
|
298
|
-
const ctx = passContext(state);
|
|
299
|
-
const canonicalKey = validatePassEntryName(key);
|
|
300
|
-
const storeEntry = prefixedEntry(ctx, canonicalKey);
|
|
301
|
-
await execWithInput('pass', ['insert', '-m', '-f', storeEntry], `${value}\n`, passEnv(ctx));
|
|
302
|
-
return {
|
|
303
|
-
key: canonicalKey,
|
|
304
|
-
scope: classifySecretScope(canonicalKey),
|
|
305
|
-
kind: classifySecretKey(canonicalKey),
|
|
306
|
-
provider: 'pass',
|
|
307
|
-
present: true,
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export async function passGenerate(state: ControlPlaneState, key: string, length = 32): Promise<SecretEntryMetadata> {
|
|
312
|
-
const ctx = passContext(state);
|
|
313
|
-
const canonicalKey = validatePassEntryName(key);
|
|
314
|
-
const storeEntry = prefixedEntry(ctx, canonicalKey);
|
|
315
|
-
await execFile('pass', ['generate', '-n', '-f', storeEntry, String(length)], {
|
|
316
|
-
env: passEnv(ctx),
|
|
317
|
-
});
|
|
318
|
-
return {
|
|
319
|
-
key: canonicalKey,
|
|
320
|
-
scope: classifySecretScope(canonicalKey),
|
|
321
|
-
kind: classifySecretKey(canonicalKey),
|
|
322
|
-
provider: 'pass',
|
|
323
|
-
present: true,
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export async function passRemove(state: ControlPlaneState, key: string): Promise<void> {
|
|
328
|
-
const ctx = passContext(state);
|
|
329
|
-
const storeEntry = prefixedEntry(ctx, key);
|
|
330
|
-
await execFile('pass', ['rm', '-f', storeEntry], {
|
|
331
|
-
env: passEnv(ctx),
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export async function passExists(state: ControlPlaneState, key: string): Promise<boolean> {
|
|
336
|
-
const ctx = passContext(state);
|
|
337
|
-
return existsSync(passKeyPath(ctx, key));
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function makePassBackend(state: ControlPlaneState): SecretBackend {
|
|
341
|
-
return {
|
|
342
|
-
provider: 'pass',
|
|
343
|
-
capabilities: { generate: true, remove: true, rename: false },
|
|
344
|
-
list: (prefix) => passList(state, prefix),
|
|
345
|
-
write: (key, value) => passWrite(state, key, value),
|
|
346
|
-
generate: (key, length) => passGenerate(state, key, length),
|
|
347
|
-
remove: (key) => passRemove(state, key),
|
|
348
|
-
exists: (key) => passExists(state, key),
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
export function detectSecretBackend(state: ControlPlaneState): SecretBackend {
|
|
353
|
-
const providerConfig = readSecretProviderConfig(state);
|
|
354
|
-
if (providerConfig?.provider === 'pass') {
|
|
355
|
-
return makePassBackend(state);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Historical fallback: pre-#391 we sniffed `.env.schema` files for a
|
|
359
|
-
// `@varlock/pass-plugin` marker. Schemas are gone; operators who want
|
|
360
|
-
// `pass` set `secret-provider.json` to `{ "provider": "pass" }`.
|
|
361
|
-
return makePlaintextBackend(state);
|
|
362
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* StackSpec v2 validation.
|
|
3
|
-
*
|
|
4
|
-
* Returns structured, actionable error messages with codes
|
|
5
|
-
* so users can quickly identify and fix configuration issues.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { StackSpec } from "./stack-spec.js";
|
|
9
|
-
|
|
10
|
-
export type ValidationError = {
|
|
11
|
-
code: string;
|
|
12
|
-
message: string;
|
|
13
|
-
path?: string;
|
|
14
|
-
hint?: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const IMAGE_NS_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
18
|
-
|
|
19
|
-
export function validateStackSpec(input: unknown): ValidationError[] {
|
|
20
|
-
const errors: ValidationError[] = [];
|
|
21
|
-
|
|
22
|
-
if (typeof input !== "object" || input === null) {
|
|
23
|
-
errors.push({
|
|
24
|
-
code: "OP-CFG-000",
|
|
25
|
-
message: "Configuration must be an object",
|
|
26
|
-
hint: "Check that the YAML file starts with valid configuration keys",
|
|
27
|
-
});
|
|
28
|
-
return errors;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const spec = input as Record<string, unknown>;
|
|
32
|
-
|
|
33
|
-
// Version check
|
|
34
|
-
if (spec.version !== 2) {
|
|
35
|
-
errors.push({
|
|
36
|
-
code: "OP-CFG-020",
|
|
37
|
-
message: `Expected version: 2, got: ${spec.version ?? "(missing)"}`,
|
|
38
|
-
path: "version",
|
|
39
|
-
hint: "Set version: 2 at the top of your config file",
|
|
40
|
-
});
|
|
41
|
-
return errors;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Image
|
|
45
|
-
if (spec.image && typeof spec.image === "object") {
|
|
46
|
-
const img = spec.image as Record<string, unknown>;
|
|
47
|
-
if (
|
|
48
|
-
typeof img.namespace === "string" &&
|
|
49
|
-
!IMAGE_NS_RE.test(img.namespace)
|
|
50
|
-
) {
|
|
51
|
-
errors.push({
|
|
52
|
-
code: "OP-CFG-012",
|
|
53
|
-
message: `image.namespace "${img.namespace}" contains invalid characters`,
|
|
54
|
-
path: "image.namespace",
|
|
55
|
-
hint: "Use lowercase letters, numbers, dots, hyphens, or underscores",
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
void (spec as unknown as StackSpec); // reserved for future validations
|
|
61
|
-
return errors;
|
|
62
|
-
}
|