@f5-sales-demo/pi-resource-management 20.3.2 → 20.4.0
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 +1 -1
- package/src/arg-parser.ts +2 -6
- package/src/index.ts +7 -0
- package/src/manifest-validator.ts +9 -4
- package/src/operation-runner.ts +371 -0
- package/src/output-formatter.ts +3 -0
- package/src/resource-client.ts +26 -8
- package/src/types.ts +45 -4
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/pi-resource-management",
|
|
4
|
-
"version": "20.
|
|
4
|
+
"version": "20.4.0",
|
|
5
5
|
"description": "Shared resource management for F5 XC manifest parsing, validation, diff, and CRUD operations",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Robin Mordasiewicz",
|
package/src/arg-parser.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ParsedExportArgs, ParsedResourceArgs } from "./types";
|
|
|
2
2
|
|
|
3
3
|
const VALID_OUTPUT_FORMATS = new Set(["json", "yaml", "table", "wide"]);
|
|
4
4
|
const VALID_EXPORT_FORMATS = new Set(["json", "yaml"]);
|
|
5
|
-
const VALID_DRY_RUN_MODES = new Set(["client"
|
|
5
|
+
const VALID_DRY_RUN_MODES = new Set(["client"]);
|
|
6
6
|
|
|
7
7
|
export function parseResourceArgs(argsString: string): ParsedResourceArgs | { error: string } {
|
|
8
8
|
const tokens = argsString.split(/\s+/).filter(Boolean);
|
|
@@ -11,7 +11,6 @@ export function parseResourceArgs(argsString: string): ParsedResourceArgs | { er
|
|
|
11
11
|
let outputFormat: ParsedResourceArgs["outputFormat"] = "table";
|
|
12
12
|
let dryRun: ParsedResourceArgs["dryRun"];
|
|
13
13
|
let recursive = false;
|
|
14
|
-
let force = false;
|
|
15
14
|
const positionals: string[] = [];
|
|
16
15
|
|
|
17
16
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -44,7 +43,7 @@ export function parseResourceArgs(argsString: string): ParsedResourceArgs | { er
|
|
|
44
43
|
if (token.includes("=")) {
|
|
45
44
|
const mode = token.split("=")[1];
|
|
46
45
|
if (!VALID_DRY_RUN_MODES.has(mode)) {
|
|
47
|
-
return { error: `Invalid --dry-run mode: "${mode}". Must be "client"
|
|
46
|
+
return { error: `Invalid --dry-run mode: "${mode}". Must be "client".` };
|
|
48
47
|
}
|
|
49
48
|
dryRun = mode as ParsedResourceArgs["dryRun"];
|
|
50
49
|
} else {
|
|
@@ -52,8 +51,6 @@ export function parseResourceArgs(argsString: string): ParsedResourceArgs | { er
|
|
|
52
51
|
}
|
|
53
52
|
} else if (token === "-R" || token === "--recursive") {
|
|
54
53
|
recursive = true;
|
|
55
|
-
} else if (token === "--force") {
|
|
56
|
-
force = true;
|
|
57
54
|
} else if (token.startsWith("-")) {
|
|
58
55
|
return { error: `Unknown flag: "${token}".` };
|
|
59
56
|
} else {
|
|
@@ -67,7 +64,6 @@ export function parseResourceArgs(argsString: string): ParsedResourceArgs | { er
|
|
|
67
64
|
outputFormat,
|
|
68
65
|
dryRun,
|
|
69
66
|
recursive,
|
|
70
|
-
force,
|
|
71
67
|
kind: positionals[0],
|
|
72
68
|
name: positionals[1],
|
|
73
69
|
};
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type { ExportedManifest, ManifestOutputFormat, MinimalExportFilter } from
|
|
|
9
9
|
export { applyMinimalExportFilter, formatManifestOutput, toManifest, toManifestList } from "./manifest-export";
|
|
10
10
|
export { ManifestParseError, parseManifests } from "./manifest-parser";
|
|
11
11
|
export { formatValidationErrors, validateManifest, validateManifests } from "./manifest-validator";
|
|
12
|
+
export type { RunResourceOperationOptions } from "./operation-runner";
|
|
13
|
+
export { formatResourceOperationReport, readManifestInputs, runResourceOperation } from "./operation-runner";
|
|
12
14
|
export {
|
|
13
15
|
formatMultiOperationSummary,
|
|
14
16
|
formatOperationResult,
|
|
@@ -26,6 +28,7 @@ export type {
|
|
|
26
28
|
HttpTransportRequest,
|
|
27
29
|
HttpTransportResponse,
|
|
28
30
|
KindResolver,
|
|
31
|
+
ManifestOperationInput,
|
|
29
32
|
ManifestValidationResult,
|
|
30
33
|
OperationResult,
|
|
31
34
|
ParsedExportArgs,
|
|
@@ -36,6 +39,10 @@ export type {
|
|
|
36
39
|
ResourceError,
|
|
37
40
|
ResourceErrorKind,
|
|
38
41
|
ResourceManifest,
|
|
42
|
+
ResourceOperation,
|
|
43
|
+
ResourceOperationCounts,
|
|
44
|
+
ResourceOperationItem,
|
|
45
|
+
ResourceOperationReport,
|
|
39
46
|
ValidationError,
|
|
40
47
|
ValidationErrorCode,
|
|
41
48
|
ValidationWarning,
|
|
@@ -12,6 +12,7 @@ export function validateManifest(
|
|
|
12
12
|
manifest: ResourceManifest,
|
|
13
13
|
resolver: KindResolver,
|
|
14
14
|
namespaceOverride?: string,
|
|
15
|
+
options: { operation?: "create" | "update" | "identity" } = {},
|
|
15
16
|
): { result: ManifestValidationResult; resolved?: ResolvedKind } {
|
|
16
17
|
const errors: ValidationError[] = [];
|
|
17
18
|
const warnings: ValidationWarning[] = [];
|
|
@@ -53,15 +54,18 @@ export function validateManifest(
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
if (resolved?.validation) {
|
|
57
|
-
const requiredFields =
|
|
57
|
+
if (resolved?.validation && options.operation !== "identity") {
|
|
58
|
+
const requiredFields =
|
|
59
|
+
options.operation === "update"
|
|
60
|
+
? (resolved.validation.update ?? resolved.validation.minimum_config ?? [])
|
|
61
|
+
: (resolved.validation.create ?? resolved.validation.minimum_config ?? []);
|
|
58
62
|
for (const fieldPath of requiredFields) {
|
|
59
63
|
if (fieldPath.startsWith("metadata.")) continue;
|
|
60
64
|
const value = getNestedValue(manifest.rawObject, fieldPath);
|
|
61
65
|
if (value === undefined || value === null) {
|
|
62
66
|
errors.push({
|
|
63
67
|
path: fieldPath,
|
|
64
|
-
message: `Required field "${fieldPath}" is missing for ${manifest.kind}
|
|
68
|
+
message: `Required field "${fieldPath}" is missing for ${manifest.kind} ${options.operation ?? "create"}.`,
|
|
65
69
|
code: "MISSING_FIELD",
|
|
66
70
|
});
|
|
67
71
|
}
|
|
@@ -75,11 +79,12 @@ export function validateManifests(
|
|
|
75
79
|
manifests: ResourceManifest[],
|
|
76
80
|
resolver: KindResolver,
|
|
77
81
|
namespaceOverride?: string,
|
|
82
|
+
options: { operation?: "create" | "update" | "identity" } = {},
|
|
78
83
|
): { results: ManifestValidationResult[]; resolved: (ResolvedKind | undefined)[] } {
|
|
79
84
|
const results: ManifestValidationResult[] = [];
|
|
80
85
|
const resolved: (ResolvedKind | undefined)[] = [];
|
|
81
86
|
for (const manifest of manifests) {
|
|
82
|
-
const v = validateManifest(manifest, resolver, namespaceOverride);
|
|
87
|
+
const v = validateManifest(manifest, resolver, namespaceOverride, options);
|
|
83
88
|
results.push(v.result);
|
|
84
89
|
resolved.push(v.resolved);
|
|
85
90
|
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { stringify as yamlStringify } from "yaml";
|
|
2
|
+
import { formatDiff } from "./diff-engine";
|
|
3
|
+
import { readManifestFiles } from "./file-reader";
|
|
4
|
+
import { toManifestList } from "./manifest-export";
|
|
5
|
+
import { parseManifests } from "./manifest-parser";
|
|
6
|
+
import { validateManifest } from "./manifest-validator";
|
|
7
|
+
import type { ResourceClient } from "./resource-client";
|
|
8
|
+
import type {
|
|
9
|
+
KindResolver,
|
|
10
|
+
ManifestOperationInput,
|
|
11
|
+
OperationResult,
|
|
12
|
+
ResourceError,
|
|
13
|
+
ResourceOperation,
|
|
14
|
+
ResourceOperationItem,
|
|
15
|
+
ResourceOperationReport,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
export interface RunResourceOperationOptions {
|
|
19
|
+
operation: ResourceOperation;
|
|
20
|
+
kindResolver: KindResolver;
|
|
21
|
+
client?: ResourceClient;
|
|
22
|
+
inputs?: ManifestOperationInput[];
|
|
23
|
+
kind?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
all?: boolean;
|
|
26
|
+
namespaceOverride?: string;
|
|
27
|
+
defaultNamespace?: string;
|
|
28
|
+
dryRun?: "client";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MANIFEST_OPERATIONS = new Set<ResourceOperation>(["apply", "create", "update", "delete", "diff", "validate"]);
|
|
32
|
+
|
|
33
|
+
export async function readManifestInputs(filenames: string[], recursive: boolean): Promise<ManifestOperationInput[]> {
|
|
34
|
+
const files = await readManifestFiles(filenames, recursive);
|
|
35
|
+
const inputs: ManifestOperationInput[] = [];
|
|
36
|
+
let index = 0;
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
for (const manifest of parseManifests(file.objects, file.sourcePath)) {
|
|
39
|
+
inputs.push({ index, sourcePath: file.sourcePath, manifest });
|
|
40
|
+
index++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return inputs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runResourceOperation(options: RunResourceOperationOptions): Promise<ResourceOperationReport> {
|
|
47
|
+
if (options.operation === "get" && options.inputs !== undefined) return runManifestGet(options);
|
|
48
|
+
if (MANIFEST_OPERATIONS.has(options.operation)) return runManifestOperation(options);
|
|
49
|
+
if (!options.client) return reportForError(options.operation, "API credentials are required for this operation.");
|
|
50
|
+
if (options.operation === "get") return runGet(options);
|
|
51
|
+
return runExport(options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function runManifestGet(options: RunResourceOperationOptions): Promise<ResourceOperationReport> {
|
|
55
|
+
const inputs = options.inputs ?? [];
|
|
56
|
+
if (inputs.length === 0) return reportForError("get", "No resource manifests were provided.");
|
|
57
|
+
|
|
58
|
+
const validations = inputs.map(input => {
|
|
59
|
+
const namespace = options.namespaceOverride ?? input.manifest.metadata.namespace ?? options.defaultNamespace;
|
|
60
|
+
return validateManifest(input.manifest, options.kindResolver, namespace, { operation: "identity" });
|
|
61
|
+
});
|
|
62
|
+
if (validations.some(validation => !validation.result.valid || !validation.resolved)) {
|
|
63
|
+
return buildReport(
|
|
64
|
+
"get",
|
|
65
|
+
inputs.map((input, index) => {
|
|
66
|
+
const validation = validations[index].result;
|
|
67
|
+
if (validation.valid && validations[index].resolved) {
|
|
68
|
+
return itemFromInput(input, options, {
|
|
69
|
+
status: "skipped",
|
|
70
|
+
error: validationError("Batch validation failed; no operations were executed."),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return itemFromInput(input, options, {
|
|
74
|
+
status: "error",
|
|
75
|
+
validation,
|
|
76
|
+
error: validationError(validation.errors.map(error => `${error.path}: ${error.message}`).join("; ")),
|
|
77
|
+
});
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (!options.client) return reportForError("get", "API credentials are required for this operation.");
|
|
82
|
+
|
|
83
|
+
const results: ResourceOperationItem[] = [];
|
|
84
|
+
for (let index = 0; index < inputs.length; index++) {
|
|
85
|
+
const input = inputs[index];
|
|
86
|
+
try {
|
|
87
|
+
const result = await options.client.get(
|
|
88
|
+
validations[index].resolved!,
|
|
89
|
+
input.manifest.metadata.name,
|
|
90
|
+
options.namespaceOverride ?? input.manifest.metadata.namespace ?? options.defaultNamespace,
|
|
91
|
+
);
|
|
92
|
+
results.push(
|
|
93
|
+
result.error
|
|
94
|
+
? itemFromInput(input, options, { status: "error", error: result.error })
|
|
95
|
+
: itemFromInput(input, options, { status: "found", resource: result.resource }),
|
|
96
|
+
);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
results.push(itemFromInput(input, options, { status: "error", error: unexpectedError(error) }));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return buildReport("get", results);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runManifestOperation(options: RunResourceOperationOptions): Promise<ResourceOperationReport> {
|
|
105
|
+
const inputs = options.inputs ?? [];
|
|
106
|
+
if (inputs.length === 0) return reportForError(options.operation, "No resource manifests were provided.");
|
|
107
|
+
|
|
108
|
+
const validationMode =
|
|
109
|
+
options.operation === "delete" ? "identity" : options.operation === "update" ? "update" : "create";
|
|
110
|
+
const validations = inputs.map(input => {
|
|
111
|
+
const namespace = options.namespaceOverride ?? input.manifest.metadata.namespace ?? options.defaultNamespace;
|
|
112
|
+
return validateManifest(input.manifest, options.kindResolver, namespace, { operation: validationMode });
|
|
113
|
+
});
|
|
114
|
+
const invalid = validations.some(validation => !validation.result.valid || !validation.resolved);
|
|
115
|
+
if (invalid) {
|
|
116
|
+
const results = inputs.map((input, index): ResourceOperationItem => {
|
|
117
|
+
const validation = validations[index].result;
|
|
118
|
+
if (validation.valid && validations[index].resolved) {
|
|
119
|
+
return itemFromInput(input, options, {
|
|
120
|
+
status: "skipped",
|
|
121
|
+
error: validationError("Batch validation failed; no operations were executed."),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return itemFromInput(input, options, {
|
|
125
|
+
status: "error",
|
|
126
|
+
validation,
|
|
127
|
+
error: validationError(validation.errors.map(error => `${error.path}: ${error.message}`).join("; ")),
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
return buildReport(options.operation, results);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (options.operation === "validate") {
|
|
134
|
+
return buildReport(
|
|
135
|
+
options.operation,
|
|
136
|
+
inputs.map(input => itemFromInput(input, options, { status: "valid" })),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (!options.client) return reportForError(options.operation, "API credentials are required for this operation.");
|
|
140
|
+
|
|
141
|
+
const results: ResourceOperationItem[] = [];
|
|
142
|
+
for (let index = 0; index < inputs.length; index++) {
|
|
143
|
+
const input = inputs[index];
|
|
144
|
+
const resolved = validations[index].resolved!;
|
|
145
|
+
try {
|
|
146
|
+
if (options.operation === "diff") {
|
|
147
|
+
const diff = await options.client.diff(input.manifest, resolved, options.namespaceOverride);
|
|
148
|
+
if (diff.error) {
|
|
149
|
+
results.push(itemFromInput(input, options, { status: "error", error: diff.error }));
|
|
150
|
+
} else {
|
|
151
|
+
results.push(
|
|
152
|
+
itemFromInput(input, options, {
|
|
153
|
+
status: diff.isNew ? "new" : diff.diff?.hasDifferences ? "different" : "identical",
|
|
154
|
+
diff: diff.diff,
|
|
155
|
+
isNew: diff.isNew,
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let operationResult: OperationResult;
|
|
163
|
+
if (options.operation === "apply") {
|
|
164
|
+
operationResult = await options.client.apply(
|
|
165
|
+
input.manifest,
|
|
166
|
+
resolved,
|
|
167
|
+
options.namespaceOverride,
|
|
168
|
+
options.dryRun,
|
|
169
|
+
);
|
|
170
|
+
} else if (options.operation === "create") {
|
|
171
|
+
operationResult = await options.client.create(
|
|
172
|
+
input.manifest,
|
|
173
|
+
resolved,
|
|
174
|
+
options.namespaceOverride,
|
|
175
|
+
options.dryRun,
|
|
176
|
+
);
|
|
177
|
+
} else if (options.operation === "update") {
|
|
178
|
+
operationResult = await options.client.update(
|
|
179
|
+
input.manifest,
|
|
180
|
+
resolved,
|
|
181
|
+
options.namespaceOverride,
|
|
182
|
+
options.dryRun,
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
operationResult = await options.client.delete(
|
|
186
|
+
input.manifest.kind,
|
|
187
|
+
input.manifest.metadata.name,
|
|
188
|
+
resolved,
|
|
189
|
+
options.namespaceOverride ?? input.manifest.metadata.namespace,
|
|
190
|
+
options.dryRun,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
results.push(operationResultToItem(input, options, operationResult));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
results.push(itemFromInput(input, options, { status: "error", error: unexpectedError(error) }));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return buildReport(options.operation, results);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function runGet(options: RunResourceOperationOptions): Promise<ResourceOperationReport> {
|
|
202
|
+
if (!options.kind) return reportForError("get", "A resource kind is required.");
|
|
203
|
+
try {
|
|
204
|
+
const resolved = options.kindResolver.resolveKind(options.kind);
|
|
205
|
+
const result = await options.client!.get(
|
|
206
|
+
resolved,
|
|
207
|
+
options.name,
|
|
208
|
+
options.namespaceOverride ?? options.defaultNamespace,
|
|
209
|
+
);
|
|
210
|
+
if (result.error)
|
|
211
|
+
return buildReport("get", [
|
|
212
|
+
{ index: 0, kind: options.kind, name: options.name, status: "error", error: result.error },
|
|
213
|
+
]);
|
|
214
|
+
return buildReport("get", [
|
|
215
|
+
{
|
|
216
|
+
index: 0,
|
|
217
|
+
kind: options.kind,
|
|
218
|
+
name: options.name,
|
|
219
|
+
status: options.name ? "found" : "listed",
|
|
220
|
+
resource: result.resource,
|
|
221
|
+
items: result.items,
|
|
222
|
+
},
|
|
223
|
+
]);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return reportForError("get", (error as Error).message);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function runExport(options: RunResourceOperationOptions): Promise<ResourceOperationReport> {
|
|
230
|
+
try {
|
|
231
|
+
if (options.all) {
|
|
232
|
+
const result = await options.client!.exportAll(
|
|
233
|
+
options.kindResolver,
|
|
234
|
+
options.namespaceOverride ?? options.defaultNamespace,
|
|
235
|
+
);
|
|
236
|
+
const items: ResourceOperationItem[] = result.manifests.map((manifest, index) => ({
|
|
237
|
+
index,
|
|
238
|
+
kind: manifest.kind,
|
|
239
|
+
name: String(manifest.metadata.name ?? ""),
|
|
240
|
+
status: "exported",
|
|
241
|
+
manifest,
|
|
242
|
+
}));
|
|
243
|
+
for (const entry of result.errors) {
|
|
244
|
+
items.push({ index: items.length, kind: entry.kind, status: "error", error: entry.error });
|
|
245
|
+
}
|
|
246
|
+
return buildReport("export", items);
|
|
247
|
+
}
|
|
248
|
+
if (!options.kind) return reportForError("export", "A resource kind or --all is required.");
|
|
249
|
+
const resolved = options.kindResolver.resolveKind(options.kind);
|
|
250
|
+
if (options.name) {
|
|
251
|
+
const result = await options.client!.exportOne(
|
|
252
|
+
options.kind,
|
|
253
|
+
resolved,
|
|
254
|
+
options.name,
|
|
255
|
+
options.namespaceOverride ?? options.defaultNamespace,
|
|
256
|
+
);
|
|
257
|
+
if (result.error)
|
|
258
|
+
return buildReport("export", [
|
|
259
|
+
{ index: 0, kind: options.kind, name: options.name, status: "error", error: result.error },
|
|
260
|
+
]);
|
|
261
|
+
return buildReport("export", [
|
|
262
|
+
{ index: 0, kind: options.kind, name: options.name, status: "exported", manifest: result.manifest },
|
|
263
|
+
]);
|
|
264
|
+
}
|
|
265
|
+
const result = await options.client!.get(
|
|
266
|
+
resolved,
|
|
267
|
+
undefined,
|
|
268
|
+
options.namespaceOverride ?? options.defaultNamespace,
|
|
269
|
+
);
|
|
270
|
+
if (result.error)
|
|
271
|
+
return buildReport("export", [{ index: 0, kind: options.kind, status: "error", error: result.error }]);
|
|
272
|
+
const manifests = toManifestList({ items: result.items ?? [] }, options.kind);
|
|
273
|
+
return buildReport(
|
|
274
|
+
"export",
|
|
275
|
+
manifests.map((manifest, index) => ({
|
|
276
|
+
index,
|
|
277
|
+
kind: options.kind,
|
|
278
|
+
name: String(manifest.metadata.name ?? ""),
|
|
279
|
+
status: "exported",
|
|
280
|
+
manifest,
|
|
281
|
+
})),
|
|
282
|
+
);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return reportForError("export", (error as Error).message);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function operationResultToItem(
|
|
289
|
+
input: ManifestOperationInput,
|
|
290
|
+
options: RunResourceOperationOptions,
|
|
291
|
+
result: OperationResult,
|
|
292
|
+
): ResourceOperationItem {
|
|
293
|
+
if (result.status === "error") return itemFromInput(input, options, { status: "error", error: result.error });
|
|
294
|
+
if (result.status === "deleted")
|
|
295
|
+
return itemFromInput(input, options, { status: result.status, durationMs: result.durationMs });
|
|
296
|
+
if (result.status === "dry-run")
|
|
297
|
+
return itemFromInput(input, options, { status: result.status, action: result.action, diff: result.diff });
|
|
298
|
+
if (result.status === "updated")
|
|
299
|
+
return itemFromInput(input, options, {
|
|
300
|
+
status: result.status,
|
|
301
|
+
durationMs: result.durationMs,
|
|
302
|
+
resource: result.resource,
|
|
303
|
+
diff: result.diff,
|
|
304
|
+
});
|
|
305
|
+
if (result.status === "created")
|
|
306
|
+
return itemFromInput(input, options, {
|
|
307
|
+
status: result.status,
|
|
308
|
+
durationMs: result.durationMs,
|
|
309
|
+
resource: result.resource,
|
|
310
|
+
});
|
|
311
|
+
return itemFromInput(input, options, { status: result.status, resource: result.resource });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function itemFromInput(
|
|
315
|
+
input: ManifestOperationInput,
|
|
316
|
+
options: RunResourceOperationOptions,
|
|
317
|
+
extra: Partial<ResourceOperationItem> & Pick<ResourceOperationItem, "status">,
|
|
318
|
+
): ResourceOperationItem {
|
|
319
|
+
return {
|
|
320
|
+
index: input.index,
|
|
321
|
+
sourcePath: input.sourcePath,
|
|
322
|
+
kind: input.manifest.kind,
|
|
323
|
+
name: input.manifest.metadata.name,
|
|
324
|
+
namespace: options.namespaceOverride ?? input.manifest.metadata.namespace ?? options.defaultNamespace,
|
|
325
|
+
...extra,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function validationError(message: string): ResourceError {
|
|
330
|
+
return { kind: "validation", message };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function unexpectedError(error: unknown): ResourceError {
|
|
334
|
+
return { kind: "api", message: error instanceof Error ? error.message : String(error) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function reportForError(operation: ResourceOperation, message: string): ResourceOperationReport {
|
|
338
|
+
return buildReport(operation, [{ index: 0, status: "error", error: validationError(message) }]);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function buildReport(operation: ResourceOperation, results: ResourceOperationItem[]): ResourceOperationReport {
|
|
342
|
+
const failed = results.filter(result => result.status === "error" || result.status === "skipped").length;
|
|
343
|
+
const counts = {
|
|
344
|
+
total: results.length,
|
|
345
|
+
succeeded: results.length - failed,
|
|
346
|
+
failed,
|
|
347
|
+
} as ResourceOperationReport["counts"];
|
|
348
|
+
for (const result of results) counts[result.status] = (counts[result.status] ?? 0) + 1;
|
|
349
|
+
return { schemaVersion: 1, operation, success: failed === 0, counts, results };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function formatResourceOperationReport(
|
|
353
|
+
report: ResourceOperationReport,
|
|
354
|
+
format: "json" | "yaml" | "table" | "wide" = "table",
|
|
355
|
+
): string {
|
|
356
|
+
if (format === "json") return JSON.stringify(report, null, 2);
|
|
357
|
+
if (format === "yaml") return yamlStringify(report);
|
|
358
|
+
const lines = report.results.map(result => {
|
|
359
|
+
const identity =
|
|
360
|
+
result.kind && result.name
|
|
361
|
+
? `${result.kind}/${result.name}`
|
|
362
|
+
: (result.kind ?? result.sourcePath ?? "resource");
|
|
363
|
+
if (result.error) return `${identity} error: ${result.error.message}`;
|
|
364
|
+
if (result.diff?.hasDifferences) {
|
|
365
|
+
return `${identity} ${result.status}\n${formatDiff(result.diff, result.kind ?? "resource", result.name ?? "unknown")}`;
|
|
366
|
+
}
|
|
367
|
+
return `${identity} ${result.status}`;
|
|
368
|
+
});
|
|
369
|
+
lines.push(`${report.counts.succeeded} succeeded, ${report.counts.failed} failed, ${report.counts.total} total`);
|
|
370
|
+
return lines.join("\n");
|
|
371
|
+
}
|
package/src/output-formatter.ts
CHANGED
|
@@ -24,6 +24,9 @@ export function formatOperationResult(
|
|
|
24
24
|
if (result.action === "create") {
|
|
25
25
|
return `${manifest.kind}/${manifest.metadata.name} would be created (dry-run)`;
|
|
26
26
|
}
|
|
27
|
+
if (result.action === "delete") {
|
|
28
|
+
return `${manifest.kind}/${manifest.metadata.name} would be deleted (dry-run)`;
|
|
29
|
+
}
|
|
27
30
|
if (result.diff) {
|
|
28
31
|
return `${manifest.kind}/${manifest.metadata.name} would be updated (dry-run)\n${formatDiff(result.diff, manifest.kind, manifest.metadata.name)}`;
|
|
29
32
|
}
|
package/src/resource-client.ts
CHANGED
|
@@ -117,7 +117,7 @@ export class ResourceClient {
|
|
|
117
117
|
manifest: ResourceManifest,
|
|
118
118
|
resolved: ResolvedKind,
|
|
119
119
|
namespaceOverride?: string,
|
|
120
|
-
dryRun?: "client"
|
|
120
|
+
dryRun?: "client",
|
|
121
121
|
): Promise<OperationResult> {
|
|
122
122
|
const namespace = this.#resolveNamespace(manifest, namespaceOverride);
|
|
123
123
|
const name = manifest.metadata.name;
|
|
@@ -147,7 +147,7 @@ export class ResourceClient {
|
|
|
147
147
|
manifest: ResourceManifest,
|
|
148
148
|
resolved: ResolvedKind,
|
|
149
149
|
namespaceOverride?: string,
|
|
150
|
-
dryRun?: "client"
|
|
150
|
+
dryRun?: "client",
|
|
151
151
|
): Promise<OperationResult> {
|
|
152
152
|
if (dryRun) return { status: "dry-run", action: "create" };
|
|
153
153
|
|
|
@@ -156,12 +156,35 @@ export class ResourceClient {
|
|
|
156
156
|
return this.#createResource(manifest, resolved, namespace, startMs);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
async update(
|
|
160
|
+
manifest: ResourceManifest,
|
|
161
|
+
resolved: ResolvedKind,
|
|
162
|
+
namespaceOverride?: string,
|
|
163
|
+
dryRun?: "client",
|
|
164
|
+
): Promise<OperationResult> {
|
|
165
|
+
const namespace = this.#resolveNamespace(manifest, namespaceOverride);
|
|
166
|
+
const name = manifest.metadata.name;
|
|
167
|
+
const getUrl = this.#buildUrl(resolved.paths.get, namespace, name);
|
|
168
|
+
const startMs = performance.now();
|
|
169
|
+
const existing = await this.#fetchResource(getUrl);
|
|
170
|
+
|
|
171
|
+
if (existing.error) return { status: "error", error: existing.error };
|
|
172
|
+
|
|
173
|
+
const diff = this.#computeManifestDiff(existing.body!, manifest);
|
|
174
|
+
if (!diff.hasDifferences) return { status: "unchanged", resource: existing.body! };
|
|
175
|
+
if (dryRun) return { status: "dry-run", action: "update", diff };
|
|
176
|
+
return this.#updateResource(manifest, resolved, namespace, diff, startMs);
|
|
177
|
+
}
|
|
178
|
+
|
|
159
179
|
async delete(
|
|
160
180
|
kind: string,
|
|
161
181
|
name: string,
|
|
162
182
|
resolved: ResolvedKind,
|
|
163
183
|
namespaceOverride?: string,
|
|
184
|
+
dryRun?: "client",
|
|
164
185
|
): Promise<OperationResult> {
|
|
186
|
+
if (dryRun) return { status: "dry-run", action: "delete" };
|
|
187
|
+
|
|
165
188
|
const namespace = namespaceOverride ?? this.#defaultNamespace;
|
|
166
189
|
const url = this.#buildUrl(resolved.paths.delete, namespace, name);
|
|
167
190
|
|
|
@@ -306,12 +329,7 @@ export class ResourceClient {
|
|
|
306
329
|
const result = await this.#fetch(url, "POST", body);
|
|
307
330
|
const durationMs = Math.round(performance.now() - startMs);
|
|
308
331
|
|
|
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
|
-
}
|
|
332
|
+
if (result.error) return { status: "error", error: result.error };
|
|
315
333
|
|
|
316
334
|
return { status: "created", resource: result.body ?? {}, durationMs };
|
|
317
335
|
}
|
package/src/types.ts
CHANGED
|
@@ -58,9 +58,8 @@ export interface ParsedResourceArgs {
|
|
|
58
58
|
filenames: string[];
|
|
59
59
|
namespace?: string;
|
|
60
60
|
outputFormat: "json" | "yaml" | "table" | "wide";
|
|
61
|
-
dryRun?: "client"
|
|
61
|
+
dryRun?: "client";
|
|
62
62
|
recursive: boolean;
|
|
63
|
-
force: boolean;
|
|
64
63
|
kind?: string;
|
|
65
64
|
name?: string;
|
|
66
65
|
}
|
|
@@ -141,7 +140,7 @@ export type OperationResult =
|
|
|
141
140
|
| { status: "unchanged"; resource: Record<string, unknown> }
|
|
142
141
|
| { status: "deleted"; name: string; kind: string; durationMs: number }
|
|
143
142
|
| { status: "error"; error: ResourceError }
|
|
144
|
-
| { status: "dry-run"; action: "create" | "update"; diff?: ResourceDiff };
|
|
143
|
+
| { status: "dry-run"; action: "create" | "update" | "delete"; diff?: ResourceDiff };
|
|
145
144
|
|
|
146
145
|
export interface HttpTransportRequest {
|
|
147
146
|
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
@@ -163,11 +162,53 @@ export interface ResourceClientOptions {
|
|
|
163
162
|
apiUrl: string;
|
|
164
163
|
apiToken: string;
|
|
165
164
|
namespace: string;
|
|
166
|
-
dryRun?: "client"
|
|
165
|
+
dryRun?: "client";
|
|
167
166
|
resolvePayloadVars?: (json: string) => string;
|
|
168
167
|
transport?: HttpTransport;
|
|
169
168
|
}
|
|
170
169
|
|
|
170
|
+
export type ResourceOperation = "apply" | "create" | "update" | "get" | "delete" | "diff" | "export" | "validate";
|
|
171
|
+
|
|
172
|
+
export interface ManifestOperationInput {
|
|
173
|
+
index: number;
|
|
174
|
+
sourcePath: string;
|
|
175
|
+
manifest: ResourceManifest;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface ResourceOperationItem {
|
|
179
|
+
index: number;
|
|
180
|
+
sourcePath?: string;
|
|
181
|
+
kind?: string;
|
|
182
|
+
name?: string;
|
|
183
|
+
namespace?: string;
|
|
184
|
+
status: string;
|
|
185
|
+
action?: "create" | "update" | "delete";
|
|
186
|
+
durationMs?: number;
|
|
187
|
+
resource?: Record<string, unknown>;
|
|
188
|
+
items?: Record<string, unknown>[];
|
|
189
|
+
manifest?: { kind: string; metadata: Record<string, unknown>; spec: Record<string, unknown> };
|
|
190
|
+
manifests?: Array<{ kind: string; metadata: Record<string, unknown>; spec: Record<string, unknown> }>;
|
|
191
|
+
diff?: ResourceDiff;
|
|
192
|
+
isNew?: boolean;
|
|
193
|
+
validation?: ManifestValidationResult;
|
|
194
|
+
error?: ResourceError;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface ResourceOperationCounts {
|
|
198
|
+
total: number;
|
|
199
|
+
succeeded: number;
|
|
200
|
+
failed: number;
|
|
201
|
+
[status: string]: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ResourceOperationReport {
|
|
205
|
+
schemaVersion: 1;
|
|
206
|
+
operation: ResourceOperation;
|
|
207
|
+
success: boolean;
|
|
208
|
+
counts: ResourceOperationCounts;
|
|
209
|
+
results: ResourceOperationItem[];
|
|
210
|
+
}
|
|
211
|
+
|
|
171
212
|
export interface KindResolver {
|
|
172
213
|
resolveKind(kind: string): ResolvedKind;
|
|
173
214
|
getAllKnownKinds(): string[];
|