@f5-sales-demo/pi-resource-management 19.51.2
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/package.json +61 -0
- package/src/arg-parser.ts +131 -0
- package/src/defaults-metadata.generated.ts +2529 -0
- package/src/defaults-metadata.ts +76 -0
- package/src/diff-engine.ts +160 -0
- package/src/file-reader.ts +163 -0
- package/src/index.ts +43 -0
- package/src/kind-resolver.ts +157 -0
- package/src/manifest-export.ts +194 -0
- package/src/manifest-parser.ts +72 -0
- package/src/manifest-validator.ts +109 -0
- package/src/output-formatter.ts +209 -0
- package/src/resource-client.ts +441 -0
- package/src/types.ts +175 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { computeResourceDiff } from "./diff-engine";
|
|
2
|
+
import type { ExportedManifest } from "./manifest-export";
|
|
3
|
+
import { toManifest, toManifestList } from "./manifest-export";
|
|
4
|
+
|
|
5
|
+
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
HttpTransport,
|
|
9
|
+
HttpTransportRequest,
|
|
10
|
+
HttpTransportResponse,
|
|
11
|
+
KindResolver,
|
|
12
|
+
OperationResult,
|
|
13
|
+
ResolvedKind,
|
|
14
|
+
ResourceClientOptions,
|
|
15
|
+
ResourceDiff,
|
|
16
|
+
ResourceError,
|
|
17
|
+
ResourceManifest,
|
|
18
|
+
} from "./types";
|
|
19
|
+
|
|
20
|
+
export class FetchTransport implements HttpTransport {
|
|
21
|
+
readonly #apiToken: string;
|
|
22
|
+
|
|
23
|
+
constructor(apiToken: string) {
|
|
24
|
+
this.#apiToken = apiToken;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async request(req: HttpTransportRequest): Promise<HttpTransportResponse> {
|
|
28
|
+
const headers: Record<string, string> = {
|
|
29
|
+
Authorization: `APIToken ${this.#apiToken}`,
|
|
30
|
+
Accept: "application/json",
|
|
31
|
+
"X-Request-ID": crypto.randomUUID(),
|
|
32
|
+
...req.headers,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let resolvedBody: string | undefined;
|
|
36
|
+
if (req.body && req.method !== "GET") {
|
|
37
|
+
headers["Content-Type"] = "application/json";
|
|
38
|
+
resolvedBody = JSON.stringify(req.body);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let lastError: Error | undefined;
|
|
42
|
+
|
|
43
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
44
|
+
try {
|
|
45
|
+
const init: RequestInit = {
|
|
46
|
+
method: req.method,
|
|
47
|
+
headers,
|
|
48
|
+
signal: AbortSignal.timeout(30_000),
|
|
49
|
+
body: resolvedBody,
|
|
50
|
+
};
|
|
51
|
+
const response = await fetch(req.url, init);
|
|
52
|
+
|
|
53
|
+
if (RETRYABLE_STATUSES.has(response.status) && attempt < MAX_RETRIES) {
|
|
54
|
+
const retryAfter = response.headers.get("retry-after");
|
|
55
|
+
const seconds = retryAfter ? Number.parseInt(retryAfter, 10) : Number.NaN;
|
|
56
|
+
const delayMs =
|
|
57
|
+
Number.isFinite(seconds) && seconds > 0
|
|
58
|
+
? Math.min(seconds * 1000, 10_000)
|
|
59
|
+
: Math.min(1000 * 2 ** attempt, 10_000);
|
|
60
|
+
await sleep(delayMs);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const raw = await response.text();
|
|
65
|
+
let parsed: Record<string, unknown> | undefined;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
68
|
+
} catch {
|
|
69
|
+
// Non-JSON response
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { httpStatus: response.status, body: parsed ?? {} };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
lastError = err as Error;
|
|
75
|
+
if (attempt >= MAX_RETRIES) break;
|
|
76
|
+
await sleep(Math.min(1000 * 2 ** attempt, 10_000));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw lastError ?? new Error("Request failed after retries");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const XCSH_ERROR_CODES: Record<number, string> = {
|
|
85
|
+
3: "INVALID_ARGUMENT",
|
|
86
|
+
5: "NOT_FOUND",
|
|
87
|
+
6: "ALREADY_EXISTS",
|
|
88
|
+
7: "PERMISSION_DENIED",
|
|
89
|
+
8: "RESOURCE_EXHAUSTED",
|
|
90
|
+
9: "FAILED_PRECONDITION",
|
|
91
|
+
13: "INTERNAL",
|
|
92
|
+
14: "UNAVAILABLE",
|
|
93
|
+
16: "UNAUTHENTICATED",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const MAX_RETRIES = 3;
|
|
97
|
+
const RETRYABLE_STATUSES = new Set([429, 503, 408]);
|
|
98
|
+
|
|
99
|
+
export class ResourceClient {
|
|
100
|
+
readonly #apiUrl: string;
|
|
101
|
+
readonly #defaultNamespace: string;
|
|
102
|
+
readonly #resolvePayloadVars?: (json: string) => string;
|
|
103
|
+
readonly #transport: HttpTransport;
|
|
104
|
+
|
|
105
|
+
constructor(options: ResourceClientOptions) {
|
|
106
|
+
// Strip trailing slash(es) so `#buildUrl`'s `${apiUrl}${path}` concat (path
|
|
107
|
+
// templates begin with `/api/...`) cannot emit `//`, which a consumer's
|
|
108
|
+
// `new URL()` would parse as a protocol-relative authority and collapse the
|
|
109
|
+
// request host to a bare label.
|
|
110
|
+
this.#apiUrl = options.apiUrl.replace(/\/+$/, "");
|
|
111
|
+
this.#defaultNamespace = options.namespace;
|
|
112
|
+
this.#resolvePayloadVars = options.resolvePayloadVars;
|
|
113
|
+
this.#transport = options.transport ?? new FetchTransport(options.apiToken);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async apply(
|
|
117
|
+
manifest: ResourceManifest,
|
|
118
|
+
resolved: ResolvedKind,
|
|
119
|
+
namespaceOverride?: string,
|
|
120
|
+
dryRun?: "client" | "server",
|
|
121
|
+
): Promise<OperationResult> {
|
|
122
|
+
const namespace = this.#resolveNamespace(manifest, namespaceOverride);
|
|
123
|
+
const name = manifest.metadata.name;
|
|
124
|
+
const getUrl = this.#buildUrl(resolved.paths.get, namespace, name);
|
|
125
|
+
|
|
126
|
+
const startMs = performance.now();
|
|
127
|
+
const existing = await this.#fetchResource(getUrl);
|
|
128
|
+
|
|
129
|
+
if (existing.status === 404) {
|
|
130
|
+
if (dryRun) return { status: "dry-run", action: "create" };
|
|
131
|
+
return this.#createResource(manifest, resolved, namespace, startMs);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (existing.error) return { status: "error", error: existing.error };
|
|
135
|
+
|
|
136
|
+
const diff = this.#computeManifestDiff(existing.body!, manifest);
|
|
137
|
+
|
|
138
|
+
if (!diff.hasDifferences) {
|
|
139
|
+
return { status: "unchanged", resource: existing.body! };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (dryRun) return { status: "dry-run", action: "update", diff };
|
|
143
|
+
return this.#updateResource(manifest, resolved, namespace, diff, startMs);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async create(
|
|
147
|
+
manifest: ResourceManifest,
|
|
148
|
+
resolved: ResolvedKind,
|
|
149
|
+
namespaceOverride?: string,
|
|
150
|
+
dryRun?: "client" | "server",
|
|
151
|
+
): Promise<OperationResult> {
|
|
152
|
+
if (dryRun) return { status: "dry-run", action: "create" };
|
|
153
|
+
|
|
154
|
+
const namespace = this.#resolveNamespace(manifest, namespaceOverride);
|
|
155
|
+
const startMs = performance.now();
|
|
156
|
+
return this.#createResource(manifest, resolved, namespace, startMs);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async delete(
|
|
160
|
+
kind: string,
|
|
161
|
+
name: string,
|
|
162
|
+
resolved: ResolvedKind,
|
|
163
|
+
namespaceOverride?: string,
|
|
164
|
+
): Promise<OperationResult> {
|
|
165
|
+
const namespace = namespaceOverride ?? this.#defaultNamespace;
|
|
166
|
+
const url = this.#buildUrl(resolved.paths.delete, namespace, name);
|
|
167
|
+
|
|
168
|
+
const startMs = performance.now();
|
|
169
|
+
const result = await this.#fetch(url, "DELETE");
|
|
170
|
+
const durationMs = Math.round(performance.now() - startMs);
|
|
171
|
+
|
|
172
|
+
if (result.error) return { status: "error", error: result.error };
|
|
173
|
+
return { status: "deleted", name, kind, durationMs };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async get(
|
|
177
|
+
resolved: ResolvedKind,
|
|
178
|
+
name?: string,
|
|
179
|
+
namespaceOverride?: string,
|
|
180
|
+
): Promise<{ items?: Record<string, unknown>[]; resource?: Record<string, unknown>; error?: ResourceError }> {
|
|
181
|
+
const namespace = namespaceOverride ?? this.#defaultNamespace;
|
|
182
|
+
|
|
183
|
+
if (name) {
|
|
184
|
+
const url = this.#buildUrl(resolved.paths.get, namespace, name);
|
|
185
|
+
const result = await this.#fetchResource(url);
|
|
186
|
+
if (result.error) return { error: result.error };
|
|
187
|
+
return { resource: result.body! };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const url = this.#buildUrl(resolved.paths.list, namespace);
|
|
191
|
+
const result = await this.#fetchResource(url);
|
|
192
|
+
if (result.error) return { error: result.error };
|
|
193
|
+
|
|
194
|
+
const body = result.body!;
|
|
195
|
+
const items = Array.isArray(body.items) ? (body.items as Record<string, unknown>[]) : [];
|
|
196
|
+
return { items };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async exportOne(
|
|
200
|
+
kind: string,
|
|
201
|
+
resolved: ResolvedKind,
|
|
202
|
+
name: string,
|
|
203
|
+
namespaceOverride?: string,
|
|
204
|
+
): Promise<{ manifest?: ExportedManifest; error?: ResourceError }> {
|
|
205
|
+
const namespace = namespaceOverride ?? this.#defaultNamespace;
|
|
206
|
+
const url = this.#buildUrl(resolved.paths.get, namespace, name);
|
|
207
|
+
const result = await this.#fetchResource(url);
|
|
208
|
+
if (result.error) return { error: result.error };
|
|
209
|
+
return { manifest: toManifest(result.body!, kind) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async exportAll(
|
|
213
|
+
kindResolver: KindResolver,
|
|
214
|
+
namespaceOverride?: string,
|
|
215
|
+
onProgress?: (kind: string, count: number) => void,
|
|
216
|
+
): Promise<{ manifests: ExportedManifest[]; errors: Array<{ kind: string; error: ResourceError }> }> {
|
|
217
|
+
const namespace = namespaceOverride ?? this.#defaultNamespace;
|
|
218
|
+
const kinds = kindResolver.getKindsWithApiPaths();
|
|
219
|
+
const manifests: ExportedManifest[] = [];
|
|
220
|
+
const errors: Array<{ kind: string; error: ResourceError }> = [];
|
|
221
|
+
|
|
222
|
+
for (const kind of kinds) {
|
|
223
|
+
try {
|
|
224
|
+
const resolved = kindResolver.resolveKind(kind);
|
|
225
|
+
const url = this.#buildUrl(resolved.paths.list, namespace);
|
|
226
|
+
const result = await this.#fetchResource(url);
|
|
227
|
+
|
|
228
|
+
if (result.error) {
|
|
229
|
+
errors.push({ kind, error: result.error });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const exported = toManifestList(result.body!, kind);
|
|
234
|
+
if (exported.length > 0) {
|
|
235
|
+
manifests.push(...exported);
|
|
236
|
+
onProgress?.(kind, exported.length);
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
errors.push({ kind, error: { kind: "api", message: `Failed to export ${kind}` } });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { manifests, errors };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async diff(
|
|
247
|
+
manifest: ResourceManifest,
|
|
248
|
+
resolved: ResolvedKind,
|
|
249
|
+
namespaceOverride?: string,
|
|
250
|
+
): Promise<{ diff?: ResourceDiff; isNew: boolean; error?: ResourceError }> {
|
|
251
|
+
const namespace = this.#resolveNamespace(manifest, namespaceOverride);
|
|
252
|
+
const name = manifest.metadata.name;
|
|
253
|
+
const url = this.#buildUrl(resolved.paths.get, namespace, name);
|
|
254
|
+
|
|
255
|
+
const existing = await this.#fetchResource(url);
|
|
256
|
+
if (existing.status === 404) {
|
|
257
|
+
return { isNew: true };
|
|
258
|
+
}
|
|
259
|
+
if (existing.error) return { isNew: false, error: existing.error };
|
|
260
|
+
|
|
261
|
+
const diff = this.#computeManifestDiff(existing.body!, manifest);
|
|
262
|
+
return { diff, isNew: false };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#computeManifestDiff(serverResource: Record<string, unknown>, manifest: ResourceManifest): ResourceDiff {
|
|
266
|
+
const currentSpec = (serverResource.spec ?? {}) as Record<string, unknown>;
|
|
267
|
+
const filteredSpec = filterToManifestKeys(currentSpec, manifest.spec) as Record<string, unknown>;
|
|
268
|
+
const specDiff = computeResourceDiff(filteredSpec, manifest.spec);
|
|
269
|
+
|
|
270
|
+
const currentMeta = (serverResource.metadata ?? {}) as Record<string, unknown>;
|
|
271
|
+
const desiredMeta = manifest.rawObject.metadata as Record<string, unknown>;
|
|
272
|
+
const metaDiff = computeResourceDiff(
|
|
273
|
+
filterToManifestKeys(currentMeta, desiredMeta) as Record<string, unknown>,
|
|
274
|
+
desiredMeta,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
hasDifferences: specDiff.hasDifferences || metaDiff.hasDifferences,
|
|
279
|
+
added: [...specDiff.added, ...metaDiff.added],
|
|
280
|
+
removed: [...specDiff.removed, ...metaDiff.removed],
|
|
281
|
+
changed: [...specDiff.changed, ...metaDiff.changed],
|
|
282
|
+
unchangedCount: specDiff.unchangedCount + metaDiff.unchangedCount,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#resolveNamespace(manifest: ResourceManifest, override?: string): string {
|
|
287
|
+
return override ?? manifest.metadata.namespace ?? this.#defaultNamespace;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#buildUrl(pathTemplate: string, namespace: string, name?: string): string {
|
|
291
|
+
let resolved = pathTemplate.replace(/\{namespace\}/g, encodeURIComponent(namespace));
|
|
292
|
+
if (name) {
|
|
293
|
+
resolved = resolved.replace(/\{name\}/g, encodeURIComponent(name));
|
|
294
|
+
}
|
|
295
|
+
return `${this.#apiUrl}${resolved}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async #createResource(
|
|
299
|
+
manifest: ResourceManifest,
|
|
300
|
+
resolved: ResolvedKind,
|
|
301
|
+
namespace: string,
|
|
302
|
+
startMs: number,
|
|
303
|
+
): Promise<OperationResult> {
|
|
304
|
+
const url = this.#buildUrl(resolved.paths.create, namespace);
|
|
305
|
+
const body = this.#buildRequestBody(manifest, namespace);
|
|
306
|
+
const result = await this.#fetch(url, "POST", body);
|
|
307
|
+
const durationMs = Math.round(performance.now() - startMs);
|
|
308
|
+
|
|
309
|
+
if (result.error) {
|
|
310
|
+
if (result.error.httpStatus === 409) {
|
|
311
|
+
return this.#updateResource(manifest, resolved, namespace, undefined, startMs);
|
|
312
|
+
}
|
|
313
|
+
return { status: "error", error: result.error };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return { status: "created", resource: result.body ?? {}, durationMs };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async #updateResource(
|
|
320
|
+
manifest: ResourceManifest,
|
|
321
|
+
resolved: ResolvedKind,
|
|
322
|
+
namespace: string,
|
|
323
|
+
diff: ResourceDiff | undefined,
|
|
324
|
+
startMs: number,
|
|
325
|
+
): Promise<OperationResult> {
|
|
326
|
+
const name = manifest.metadata.name;
|
|
327
|
+
const url = this.#buildUrl(resolved.paths.update, namespace, name);
|
|
328
|
+
const body = this.#buildRequestBody(manifest, namespace);
|
|
329
|
+
const result = await this.#fetch(url, "PUT", body);
|
|
330
|
+
const durationMs = Math.round(performance.now() - startMs);
|
|
331
|
+
|
|
332
|
+
if (result.error) return { status: "error", error: result.error };
|
|
333
|
+
|
|
334
|
+
const actualDiff = diff ?? {
|
|
335
|
+
hasDifferences: true,
|
|
336
|
+
added: [],
|
|
337
|
+
removed: [],
|
|
338
|
+
changed: [],
|
|
339
|
+
unchangedCount: 0,
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
return { status: "updated", resource: result.body ?? {}, diff: actualDiff, durationMs };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#buildRequestBody(manifest: ResourceManifest, namespace: string): Record<string, unknown> {
|
|
346
|
+
const { kind: _kind, ...rest } = manifest.rawObject;
|
|
347
|
+
const body = {
|
|
348
|
+
...rest,
|
|
349
|
+
metadata: { ...(rest.metadata as Record<string, unknown>), namespace },
|
|
350
|
+
};
|
|
351
|
+
return body;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async #fetchResource(
|
|
355
|
+
url: string,
|
|
356
|
+
): Promise<{ status: number; body?: Record<string, unknown>; error?: ResourceError }> {
|
|
357
|
+
const result = await this.#fetch(url, "GET");
|
|
358
|
+
return { status: result.httpStatus, body: result.body, error: result.error };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async #fetch(
|
|
362
|
+
url: string,
|
|
363
|
+
method: string,
|
|
364
|
+
body?: Record<string, unknown>,
|
|
365
|
+
): Promise<{ httpStatus: number; body?: Record<string, unknown>; error?: ResourceError }> {
|
|
366
|
+
let resolvedBody = body;
|
|
367
|
+
if (body && this.#resolvePayloadVars) {
|
|
368
|
+
const jsonBody = this.#resolvePayloadVars(JSON.stringify(body));
|
|
369
|
+
resolvedBody = JSON.parse(jsonBody) as Record<string, unknown>;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const result = await this.#transport.request({
|
|
374
|
+
method: method as "GET" | "POST" | "PUT" | "DELETE",
|
|
375
|
+
url,
|
|
376
|
+
body: resolvedBody,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (result.httpStatus >= 200 && result.httpStatus < 300) {
|
|
380
|
+
return { httpStatus: result.httpStatus, body: result.body ?? {} };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
httpStatus: result.httpStatus,
|
|
385
|
+
error: this.#toResourceError(result.httpStatus, result.body, url),
|
|
386
|
+
};
|
|
387
|
+
} catch (err) {
|
|
388
|
+
return {
|
|
389
|
+
httpStatus: 0,
|
|
390
|
+
error: { kind: "network", message: `Network error: ${(err as Error).message}` },
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#toResourceError(status: number, body: Record<string, unknown> | undefined, _url: string): ResourceError {
|
|
396
|
+
const code = body?.code as number | undefined;
|
|
397
|
+
const codeLabel = code != null ? XCSH_ERROR_CODES[code] : undefined;
|
|
398
|
+
const message = (body?.message as string) ?? (body?.error as string) ?? `HTTP ${status}`;
|
|
399
|
+
const prefix = codeLabel ? `[${codeLabel}] ` : "";
|
|
400
|
+
|
|
401
|
+
if (status === 401 || status === 403) {
|
|
402
|
+
return {
|
|
403
|
+
kind: "auth",
|
|
404
|
+
message: `${prefix}${message}. Check your API token and context credentials.`,
|
|
405
|
+
httpStatus: status,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (status === 404) {
|
|
409
|
+
return { kind: "not_found", message: `${prefix}Resource not found.`, httpStatus: status };
|
|
410
|
+
}
|
|
411
|
+
if (status === 409) {
|
|
412
|
+
return { kind: "conflict", message: `${prefix}${message}`, httpStatus: status };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return { kind: "api", message: `${prefix}${message}`, httpStatus: status };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function filterToManifestKeys(serverVal: unknown, manifestVal: unknown): unknown {
|
|
420
|
+
if (manifestVal === null || manifestVal === undefined) return manifestVal;
|
|
421
|
+
if (serverVal === null || serverVal === undefined) return serverVal;
|
|
422
|
+
if (typeof manifestVal !== "object" || typeof serverVal !== "object") return serverVal;
|
|
423
|
+
|
|
424
|
+
if (Array.isArray(manifestVal) && Array.isArray(serverVal)) {
|
|
425
|
+
return serverVal.map((item, i) => (i < manifestVal.length ? filterToManifestKeys(item, manifestVal[i]) : item));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (Array.isArray(manifestVal) || Array.isArray(serverVal)) return serverVal;
|
|
429
|
+
|
|
430
|
+
const mObj = manifestVal as Record<string, unknown>;
|
|
431
|
+
const sObj = serverVal as Record<string, unknown>;
|
|
432
|
+
const mKeys = Object.keys(mObj);
|
|
433
|
+
if (mKeys.length === 0) return {};
|
|
434
|
+
const filtered: Record<string, unknown> = {};
|
|
435
|
+
for (const key of mKeys) {
|
|
436
|
+
if (key in sObj) {
|
|
437
|
+
filtered[key] = filterToManifestKeys(sObj[key], mObj[key]);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return filtered;
|
|
441
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
export interface ApiSpecDomainResource {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly description: string;
|
|
4
|
+
readonly schemaComponents?: readonly string[];
|
|
5
|
+
readonly apiPaths?: readonly string[];
|
|
6
|
+
readonly tier?: string;
|
|
7
|
+
readonly icon?: string;
|
|
8
|
+
readonly descriptionShort?: string;
|
|
9
|
+
readonly supportsLogs?: boolean;
|
|
10
|
+
readonly supportsMetrics?: boolean;
|
|
11
|
+
readonly dependencies?: {
|
|
12
|
+
readonly required: readonly string[];
|
|
13
|
+
readonly optional: readonly string[];
|
|
14
|
+
};
|
|
15
|
+
readonly relationshipHints?: readonly string[];
|
|
16
|
+
readonly catalogCategories?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ApiSpecDomainEntry {
|
|
20
|
+
readonly domain: string;
|
|
21
|
+
readonly title: string;
|
|
22
|
+
readonly description: string;
|
|
23
|
+
readonly descriptionShort: string;
|
|
24
|
+
readonly category: string;
|
|
25
|
+
readonly pathCount: number;
|
|
26
|
+
readonly schemaCount: number;
|
|
27
|
+
readonly complexity: string;
|
|
28
|
+
readonly resources: readonly ApiSpecDomainResource[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApiSpecValidationResourceEntry {
|
|
32
|
+
readonly create?: readonly string[];
|
|
33
|
+
readonly update?: readonly string[];
|
|
34
|
+
readonly minimum_config?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ApiSpecIndex {
|
|
38
|
+
readonly version: string;
|
|
39
|
+
readonly timestamp: string;
|
|
40
|
+
readonly domains: readonly ApiSpecDomainEntry[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ResourceManifest {
|
|
44
|
+
kind: string;
|
|
45
|
+
metadata: {
|
|
46
|
+
name: string;
|
|
47
|
+
namespace?: string;
|
|
48
|
+
labels?: Record<string, string>;
|
|
49
|
+
annotations?: Record<string, string>;
|
|
50
|
+
description?: string;
|
|
51
|
+
disable?: boolean;
|
|
52
|
+
};
|
|
53
|
+
spec: Record<string, unknown>;
|
|
54
|
+
rawObject: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ParsedResourceArgs {
|
|
58
|
+
filenames: string[];
|
|
59
|
+
namespace?: string;
|
|
60
|
+
outputFormat: "json" | "yaml" | "table" | "wide";
|
|
61
|
+
dryRun?: "client" | "server";
|
|
62
|
+
recursive: boolean;
|
|
63
|
+
force: boolean;
|
|
64
|
+
kind?: string;
|
|
65
|
+
name?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ParsedExportArgs {
|
|
69
|
+
kind?: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
namespace?: string;
|
|
72
|
+
outputFormat: "json" | "yaml";
|
|
73
|
+
outputFile?: string;
|
|
74
|
+
all: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ResolvedKind {
|
|
78
|
+
kind: string;
|
|
79
|
+
domain: string;
|
|
80
|
+
resource: ApiSpecDomainResource;
|
|
81
|
+
paths: {
|
|
82
|
+
list: string;
|
|
83
|
+
get: string;
|
|
84
|
+
create: string;
|
|
85
|
+
update: string;
|
|
86
|
+
delete: string;
|
|
87
|
+
};
|
|
88
|
+
validation?: ApiSpecValidationResourceEntry;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ValidationErrorCode = "MISSING_FIELD" | "UNKNOWN_KIND" | "INVALID_TYPE" | "PARSE_ERROR" | "DUPLICATE_NAME";
|
|
92
|
+
|
|
93
|
+
export type ValidationWarningCode = "EXTRA_FIELD" | "DEPRECATED_FIELD";
|
|
94
|
+
|
|
95
|
+
export interface ValidationError {
|
|
96
|
+
path: string;
|
|
97
|
+
message: string;
|
|
98
|
+
code: ValidationErrorCode;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ValidationWarning {
|
|
102
|
+
path: string;
|
|
103
|
+
message: string;
|
|
104
|
+
code: ValidationWarningCode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ManifestValidationResult {
|
|
108
|
+
valid: boolean;
|
|
109
|
+
errors: ValidationError[];
|
|
110
|
+
warnings: ValidationWarning[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface DiffEntry {
|
|
114
|
+
path: string;
|
|
115
|
+
oldValue?: unknown;
|
|
116
|
+
newValue?: unknown;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ResourceDiff {
|
|
120
|
+
hasDifferences: boolean;
|
|
121
|
+
added: DiffEntry[];
|
|
122
|
+
removed: DiffEntry[];
|
|
123
|
+
changed: DiffEntry[];
|
|
124
|
+
unchangedCount: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type ResourceErrorKind = "validation" | "api" | "network" | "auth" | "conflict" | "not_found";
|
|
128
|
+
|
|
129
|
+
export interface ResourceError {
|
|
130
|
+
kind: ResourceErrorKind;
|
|
131
|
+
message: string;
|
|
132
|
+
httpStatus?: number;
|
|
133
|
+
manifestIndex?: number;
|
|
134
|
+
resourceName?: string;
|
|
135
|
+
resourceKind?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type OperationResult =
|
|
139
|
+
| { status: "created"; resource: Record<string, unknown>; durationMs: number }
|
|
140
|
+
| { status: "updated"; resource: Record<string, unknown>; diff: ResourceDiff; durationMs: number }
|
|
141
|
+
| { status: "unchanged"; resource: Record<string, unknown> }
|
|
142
|
+
| { status: "deleted"; name: string; kind: string; durationMs: number }
|
|
143
|
+
| { status: "error"; error: ResourceError }
|
|
144
|
+
| { status: "dry-run"; action: "create" | "update"; diff?: ResourceDiff };
|
|
145
|
+
|
|
146
|
+
export interface HttpTransportRequest {
|
|
147
|
+
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
148
|
+
url: string;
|
|
149
|
+
body?: Record<string, unknown>;
|
|
150
|
+
headers?: Record<string, string>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface HttpTransportResponse {
|
|
154
|
+
httpStatus: number;
|
|
155
|
+
body?: Record<string, unknown>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface HttpTransport {
|
|
159
|
+
request(req: HttpTransportRequest): Promise<HttpTransportResponse>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ResourceClientOptions {
|
|
163
|
+
apiUrl: string;
|
|
164
|
+
apiToken: string;
|
|
165
|
+
namespace: string;
|
|
166
|
+
dryRun?: "client" | "server";
|
|
167
|
+
resolvePayloadVars?: (json: string) => string;
|
|
168
|
+
transport?: HttpTransport;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface KindResolver {
|
|
172
|
+
resolveKind(kind: string): ResolvedKind;
|
|
173
|
+
getAllKnownKinds(): string[];
|
|
174
|
+
getKindsWithApiPaths(): string[];
|
|
175
|
+
}
|