@f5-sales-demo/pi-resource-management 23.9.5 → 23.10.3
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/catalog-kind-resolver.ts +74 -0
- package/src/defaults-metadata.generated.ts +7 -13
- package/src/immutable-selection.ts +189 -0
- package/src/index.ts +3 -1
- package/src/resource-client.ts +31 -22
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": "23.
|
|
4
|
+
"version": "23.10.3",
|
|
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",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createKindResolver } from "./kind-resolver";
|
|
2
|
+
import { type MapConstraints, validateMapConstraints } from "./map-constraints";
|
|
3
|
+
import type { ApiSpecIndex, ApiSpecValidationResourceEntry, KindResolver, ValidationError } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface ValidationCatalogCategory {
|
|
6
|
+
readonly operations: readonly {
|
|
7
|
+
readonly method: string;
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly fieldMetadata?: Readonly<Record<string, { readonly constraints?: Record<string, unknown> }>>;
|
|
10
|
+
}[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createCatalogKindResolver(
|
|
14
|
+
index: ApiSpecIndex,
|
|
15
|
+
validation: Readonly<Record<string, ApiSpecValidationResourceEntry>>,
|
|
16
|
+
catalog: Readonly<Record<string, ValidationCatalogCategory>>,
|
|
17
|
+
): KindResolver {
|
|
18
|
+
const baseResolver = createKindResolver(index, validation);
|
|
19
|
+
const normalizePath = (path: string) =>
|
|
20
|
+
path.replace(/\{metadata\.namespace\}/g, "{namespace}").replace(/\{metadata\.name\}/g, "{name}");
|
|
21
|
+
|
|
22
|
+
function valuesAtPath(object: unknown, path: string): unknown[] {
|
|
23
|
+
if (!path) return [object];
|
|
24
|
+
const [part, ...remaining] = path.split(".");
|
|
25
|
+
const name = part!.replace(/(?:\[\]|\{\})$/, "");
|
|
26
|
+
if (!object || typeof object !== "object") return [];
|
|
27
|
+
const child = (object as Record<string, unknown>)[name];
|
|
28
|
+
if (child === undefined || child === null) return [];
|
|
29
|
+
const rest = remaining.join(".");
|
|
30
|
+
if (part!.endsWith("[]")) return Array.isArray(child) ? child.flatMap(item => valuesAtPath(item, rest)) : [];
|
|
31
|
+
if (part!.endsWith("{}"))
|
|
32
|
+
return typeof child === "object" ? Object.values(child).flatMap(item => valuesAtPath(item, rest)) : [];
|
|
33
|
+
return valuesAtPath(child, rest);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
...baseResolver,
|
|
38
|
+
resolveKind(kind: string) {
|
|
39
|
+
const resolved = baseResolver.resolveKind(kind);
|
|
40
|
+
return {
|
|
41
|
+
...resolved,
|
|
42
|
+
validateFields(object: Record<string, unknown>, operation: "create" | "update"): ValidationError[] {
|
|
43
|
+
const method = operation === "update" ? "put" : "post";
|
|
44
|
+
const errors: ValidationError[] = [];
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
for (const category of Object.values(catalog)) {
|
|
47
|
+
for (const entry of category.operations) {
|
|
48
|
+
if (
|
|
49
|
+
entry.method.toLowerCase() !== method ||
|
|
50
|
+
normalizePath(entry.path) !== resolved.paths[operation]
|
|
51
|
+
)
|
|
52
|
+
continue;
|
|
53
|
+
for (const [field, metadata] of Object.entries(entry.fieldMetadata ?? {})) {
|
|
54
|
+
if (metadata.constraints?.constraintType !== "map") continue;
|
|
55
|
+
const fingerprint = `${field}:${JSON.stringify(metadata.constraints)}`;
|
|
56
|
+
if (seen.has(fingerprint)) continue;
|
|
57
|
+
seen.add(fingerprint);
|
|
58
|
+
for (const value of valuesAtPath(object, field)) {
|
|
59
|
+
for (const message of validateMapConstraints(
|
|
60
|
+
value,
|
|
61
|
+
metadata.constraints as unknown as MapConstraints,
|
|
62
|
+
)) {
|
|
63
|
+
errors.push({ path: field, message, code: "INVALID_MAP" });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return errors;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/generate-defaults.ts — DO NOT EDIT BY HAND.
|
|
2
2
|
// Per-kind defaults knowledge for minimum-settings export. Regenerate with: bun run generate-defaults
|
|
3
|
-
// Source: https://github.com/f5-sales-demo/api-specs-enriched/releases/download/v12.1
|
|
3
|
+
// Source: https://github.com/f5-sales-demo/api-specs-enriched/releases/download/v12.5.1/minimal-export-defaults.json (api-specs-enriched 12.5.1)
|
|
4
4
|
import type { KindDefaultsMetadata } from "./defaults-metadata";
|
|
5
5
|
|
|
6
6
|
export const DEFAULTS_METADATA: Record<string, KindDefaultsMetadata> = {
|
|
@@ -1577,10 +1577,8 @@ export const DEFAULTS_METADATA: Record<string, KindDefaultsMetadata> = {
|
|
|
1577
1577
|
},
|
|
1578
1578
|
},
|
|
1579
1579
|
malicious_user_mitigation: {
|
|
1580
|
-
serverDefaultFields: [
|
|
1581
|
-
fieldDefaults: {
|
|
1582
|
-
"spec.mitigation_type": null,
|
|
1583
|
-
},
|
|
1580
|
+
serverDefaultFields: [],
|
|
1581
|
+
fieldDefaults: {},
|
|
1584
1582
|
minimumConfigFields: ["spec.mitigation_type.rules"],
|
|
1585
1583
|
fieldConflicts: {},
|
|
1586
1584
|
},
|
|
@@ -2059,10 +2057,8 @@ export const DEFAULTS_METADATA: Record<string, KindDefaultsMetadata> = {
|
|
|
2059
2057
|
fieldConflicts: {},
|
|
2060
2058
|
},
|
|
2061
2059
|
service_policy: {
|
|
2062
|
-
serverDefaultFields: [
|
|
2063
|
-
fieldDefaults: {
|
|
2064
|
-
"spec.port_matcher": null,
|
|
2065
|
-
},
|
|
2060
|
+
serverDefaultFields: [],
|
|
2061
|
+
fieldDefaults: {},
|
|
2066
2062
|
minimumConfigFields: [
|
|
2067
2063
|
"spec.action",
|
|
2068
2064
|
"spec.api_group_matcher.match",
|
|
@@ -2131,10 +2127,8 @@ export const DEFAULTS_METADATA: Record<string, KindDefaultsMetadata> = {
|
|
|
2131
2127
|
},
|
|
2132
2128
|
},
|
|
2133
2129
|
service_policy_views: {
|
|
2134
|
-
serverDefaultFields: [
|
|
2135
|
-
fieldDefaults: {
|
|
2136
|
-
"spec.port_matcher": null,
|
|
2137
|
-
},
|
|
2130
|
+
serverDefaultFields: [],
|
|
2131
|
+
fieldDefaults: {},
|
|
2138
2132
|
minimumConfigFields: [
|
|
2139
2133
|
"spec.action",
|
|
2140
2134
|
"spec.api_group_matcher.match",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
export interface OpenAPISpec {
|
|
2
|
+
readonly info: { readonly title: string; readonly version: string; readonly [key: string]: unknown };
|
|
3
|
+
readonly paths: Record<
|
|
4
|
+
string,
|
|
5
|
+
Record<string, { readonly requestBody?: Record<string, unknown>; readonly [key: string]: unknown }>
|
|
6
|
+
>;
|
|
7
|
+
readonly components?: {
|
|
8
|
+
readonly schemas?: Record<string, Record<string, unknown>>;
|
|
9
|
+
readonly [key: string]: unknown;
|
|
10
|
+
};
|
|
11
|
+
readonly [key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ObjectValue = Record<string, unknown>;
|
|
15
|
+
export interface ImmutableGroup {
|
|
16
|
+
path: string[];
|
|
17
|
+
name: string;
|
|
18
|
+
members: string[];
|
|
19
|
+
}
|
|
20
|
+
export function objectValue(value: unknown): ObjectValue {
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a JSON object");
|
|
22
|
+
return value as ObjectValue;
|
|
23
|
+
}
|
|
24
|
+
export function resolveSchema(schema: ObjectValue, spec: OpenAPISpec, seen = new Set<string>()): ObjectValue {
|
|
25
|
+
const ref = schema.$ref;
|
|
26
|
+
let result = { ...schema };
|
|
27
|
+
if (typeof ref === "string") {
|
|
28
|
+
if (!ref.startsWith("#/components/schemas/") || seen.has(ref)) return {};
|
|
29
|
+
const target = spec.components?.schemas?.[ref.slice("#/components/schemas/".length)];
|
|
30
|
+
if (!target) throw new Error(`Missing schema ${ref}`);
|
|
31
|
+
result = { ...resolveSchema(target, spec, new Set([...seen, ref])), ...schema };
|
|
32
|
+
}
|
|
33
|
+
for (const child of (schema.allOf as ObjectValue[] | undefined) ?? []) {
|
|
34
|
+
const resolved = resolveSchema(child, spec, seen);
|
|
35
|
+
result = {
|
|
36
|
+
...result,
|
|
37
|
+
...resolved,
|
|
38
|
+
properties: { ...(result.properties as ObjectValue), ...(resolved.properties as ObjectValue) },
|
|
39
|
+
required: [...new Set([...((result.required as string[]) ?? []), ...((resolved.required as string[]) ?? [])])],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
export function immutableGroups(
|
|
45
|
+
schema: ObjectValue,
|
|
46
|
+
spec: OpenAPISpec,
|
|
47
|
+
path: string[] = [],
|
|
48
|
+
seen = new Set<string>(),
|
|
49
|
+
): ImmutableGroup[] {
|
|
50
|
+
if (typeof schema.$ref === "string" && seen.has(schema.$ref)) return [];
|
|
51
|
+
const next = new Set(seen);
|
|
52
|
+
if (typeof schema.$ref === "string") next.add(schema.$ref);
|
|
53
|
+
const resolved = resolveSchema(schema, spec);
|
|
54
|
+
const groups: ImmutableGroup[] = [];
|
|
55
|
+
for (const [name, members] of Object.entries((resolved["x-f5xc-immutable-oneof-groups"] as ObjectValue) ?? {})) {
|
|
56
|
+
if (!Array.isArray(members) || members.length < 2 || !members.every(m => typeof m === "string"))
|
|
57
|
+
throw new Error(`Invalid immutable group ${name}`);
|
|
58
|
+
groups.push({ path, name, members });
|
|
59
|
+
}
|
|
60
|
+
for (const [name, child] of Object.entries((resolved.properties as ObjectValue) ?? {}))
|
|
61
|
+
groups.push(...immutableGroups(objectValue(child), spec, [...path, name], next));
|
|
62
|
+
return groups;
|
|
63
|
+
}
|
|
64
|
+
export function valueAt(value: ObjectValue, path: string[]): ObjectValue {
|
|
65
|
+
let result = value;
|
|
66
|
+
for (const key of path) result = objectValue(result[key]);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
export function selectedMember(value: ObjectValue, group: ImmutableGroup): string {
|
|
70
|
+
const owner = valueAt(value, group.path);
|
|
71
|
+
const selected = group.members.filter(member => owner[member] !== undefined && owner[member] !== null);
|
|
72
|
+
if (selected.length !== 1)
|
|
73
|
+
throw new Error(`${[...group.path, group.name].join(".")} requires exactly one of ${group.members.join(", ")}`);
|
|
74
|
+
objectValue(owner[selected[0]!]);
|
|
75
|
+
return selected[0]!;
|
|
76
|
+
}
|
|
77
|
+
export function formatImmutableGroups(groups: ImmutableGroup[]): string {
|
|
78
|
+
return groups
|
|
79
|
+
.map(
|
|
80
|
+
g =>
|
|
81
|
+
`Immutable selection ${[...g.path, g.name].join(".")}: ${g.members.join(" | ")}. Changing the selected member requires DELETE then POST; child settings remain mutable.`,
|
|
82
|
+
)
|
|
83
|
+
.join("\n");
|
|
84
|
+
}
|
|
85
|
+
export function createBody(value: unknown, schema: ObjectValue, spec: OpenAPISpec): unknown {
|
|
86
|
+
const s = resolveSchema(schema, spec);
|
|
87
|
+
if (value === null || value === undefined || s.readOnly === true || s["x-field-mutability"] === "read-only")
|
|
88
|
+
return undefined;
|
|
89
|
+
if (Array.isArray(value)) return value.map(v => createBody(v, objectValue(s.items ?? {}), spec));
|
|
90
|
+
if (typeof value !== "object") return value;
|
|
91
|
+
const props = s.properties as ObjectValue | undefined;
|
|
92
|
+
const out: ObjectValue = {};
|
|
93
|
+
const input = { ...(value as ObjectValue) };
|
|
94
|
+
if (
|
|
95
|
+
props?.response_body_encoded &&
|
|
96
|
+
typeof input.response_body === "string" &&
|
|
97
|
+
input.response_body &&
|
|
98
|
+
!input.response_body_encoded
|
|
99
|
+
)
|
|
100
|
+
input.response_body_encoded = `string:///${Buffer.from(input.response_body).toString("base64")}`;
|
|
101
|
+
for (const [key, child] of Object.entries(input)) {
|
|
102
|
+
if (props && !(key in props)) continue;
|
|
103
|
+
const childSchema = props?.[key] ?? (typeof s.additionalProperties === "object" ? s.additionalProperties : {});
|
|
104
|
+
const projected = createBody(child, objectValue(childSchema), spec);
|
|
105
|
+
if (projected !== undefined) out[key] = projected;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
export function validateBody(value: unknown, schema: ObjectValue, spec: OpenAPISpec, path = "body"): string[] {
|
|
110
|
+
const s = resolveSchema(schema, spec);
|
|
111
|
+
const errors: string[] = [];
|
|
112
|
+
if (value === undefined || value === null) return errors;
|
|
113
|
+
const type = Array.isArray(value) ? "array" : typeof value;
|
|
114
|
+
if (
|
|
115
|
+
typeof s.type === "string" &&
|
|
116
|
+
s.type !== type &&
|
|
117
|
+
!(s.type === "integer" && typeof value === "number" && Number.isInteger(value))
|
|
118
|
+
)
|
|
119
|
+
return [`${path}: expected ${s.type}`];
|
|
120
|
+
if (Array.isArray(s.enum) && !s.enum.includes(value)) errors.push(`${path}: invalid enum value`);
|
|
121
|
+
if (typeof value === "number") {
|
|
122
|
+
if (typeof s.minimum === "number" && value < s.minimum) errors.push(`${path}: below minimum`);
|
|
123
|
+
if (typeof s.maximum === "number" && value > s.maximum) errors.push(`${path}: above maximum`);
|
|
124
|
+
}
|
|
125
|
+
if (typeof value === "string") {
|
|
126
|
+
if (typeof s.minLength === "number" && value.length < s.minLength) errors.push(`${path}: too short`);
|
|
127
|
+
if (typeof s.maxLength === "number" && value.length > s.maxLength) errors.push(`${path}: too long`);
|
|
128
|
+
if (typeof s.pattern === "string" && !new RegExp(s.pattern).test(value)) errors.push(`${path}: pattern mismatch`);
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(value)) {
|
|
131
|
+
if (typeof s.minItems === "number" && value.length < s.minItems) errors.push(`${path}: too few items`);
|
|
132
|
+
if (typeof s.maxItems === "number" && value.length > s.maxItems) errors.push(`${path}: too many items`);
|
|
133
|
+
value.forEach((v, i) => {
|
|
134
|
+
errors.push(...validateBody(v, objectValue(s.items ?? {}), spec, `${path}[${i}]`));
|
|
135
|
+
});
|
|
136
|
+
} else if (typeof value === "object") {
|
|
137
|
+
const o = objectValue(value);
|
|
138
|
+
for (const key of (s.required as string[] | undefined) ?? [])
|
|
139
|
+
if (o[key] === undefined || o[key] === null) errors.push(`${path}.${key}: required`);
|
|
140
|
+
for (const [key, child] of Object.entries(o)) {
|
|
141
|
+
const p = (s.properties as ObjectValue)?.[key];
|
|
142
|
+
if (p) errors.push(...validateBody(child, objectValue(p), spec, `${path}.${key}`));
|
|
143
|
+
}
|
|
144
|
+
for (const [key, members] of Object.entries(s))
|
|
145
|
+
if (key.startsWith("x-ves-oneof-field-")) {
|
|
146
|
+
const choices = typeof members === "string" ? JSON.parse(members) : members;
|
|
147
|
+
if (Array.isArray(choices) && choices.filter(k => o[k] !== undefined && o[k] !== null).length > 1)
|
|
148
|
+
errors.push(`${path}.${key}: mutually exclusive members`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return errors;
|
|
152
|
+
}
|
|
153
|
+
export interface MutationContract {
|
|
154
|
+
spec: OpenAPISpec;
|
|
155
|
+
schema: ObjectValue;
|
|
156
|
+
createSchema: ObjectValue;
|
|
157
|
+
groups: ImmutableGroup[];
|
|
158
|
+
collection: string;
|
|
159
|
+
}
|
|
160
|
+
export function mutationContract(
|
|
161
|
+
data: Readonly<Record<string, unknown>>,
|
|
162
|
+
method: string,
|
|
163
|
+
path: string,
|
|
164
|
+
): MutationContract | undefined {
|
|
165
|
+
for (const raw of Object.values(data)) {
|
|
166
|
+
const spec = raw as OpenAPISpec;
|
|
167
|
+
for (const [template, operations] of Object.entries(spec.paths)) {
|
|
168
|
+
const parts = template.split("/");
|
|
169
|
+
const actual = path.split("/");
|
|
170
|
+
if (parts.length !== actual.length || !parts.every((p, i) => /^\{[^}]+\}$/.test(p) || p === actual[i]))
|
|
171
|
+
continue;
|
|
172
|
+
const op = operations[method.toLowerCase()];
|
|
173
|
+
if (!op?.requestBody) continue;
|
|
174
|
+
const schema = ((op.requestBody.content as ObjectValue)?.["application/json"] as ObjectValue)
|
|
175
|
+
?.schema as ObjectValue;
|
|
176
|
+
if (!schema) continue;
|
|
177
|
+
const groups = immutableGroups(schema, spec);
|
|
178
|
+
if (!groups.length) continue;
|
|
179
|
+
const collection = path.slice(0, path.lastIndexOf("/"));
|
|
180
|
+
const createTemplate = template.slice(0, template.lastIndexOf("/"));
|
|
181
|
+
const create = (
|
|
182
|
+
(spec.paths[createTemplate]?.post?.requestBody?.content as ObjectValue)?.["application/json"] as ObjectValue
|
|
183
|
+
)?.schema as ObjectValue;
|
|
184
|
+
if (!create) throw new Error("Immutable mutation has no create schema");
|
|
185
|
+
return { spec, schema, createSchema: create, groups, collection };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export { parseExportArgs, parseResourceArgs } from "./arg-parser";
|
|
2
|
+
export { createCatalogKindResolver, type ValidationCatalogCategory } from "./catalog-kind-resolver";
|
|
2
3
|
export type { KindDefaultsMetadata } from "./defaults-metadata";
|
|
3
4
|
export { buildFilterFromMetadata, buildMinimalExportFilter } from "./defaults-metadata";
|
|
4
5
|
export { DEFAULTS_METADATA } from "./defaults-metadata.generated";
|
|
5
6
|
export { computeResourceDiff, formatDiff } from "./diff-engine";
|
|
6
7
|
export { ManifestFileError, readManifestFiles } from "./file-reader";
|
|
8
|
+
export * from "./immutable-selection";
|
|
7
9
|
export { createKindResolver, KindResolutionError } from "./kind-resolver";
|
|
8
10
|
export type { ExportedManifest, ManifestOutputFormat, MinimalExportFilter } from "./manifest-export";
|
|
9
11
|
export { applyMinimalExportFilter, formatManifestOutput, toManifest, toManifestList } from "./manifest-export";
|
|
@@ -18,7 +20,7 @@ export {
|
|
|
18
20
|
formatResourceDetail,
|
|
19
21
|
formatResourceList,
|
|
20
22
|
} from "./output-formatter";
|
|
21
|
-
export { FetchTransport, ResourceClient } from "./resource-client";
|
|
23
|
+
export { computeManifestDiff, FetchTransport, ResourceClient } from "./resource-client";
|
|
22
24
|
export type {
|
|
23
25
|
ApiSpecDomainEntry,
|
|
24
26
|
ApiSpecDomainResource,
|
package/src/resource-client.ts
CHANGED
|
@@ -133,7 +133,7 @@ export class ResourceClient {
|
|
|
133
133
|
|
|
134
134
|
if (existing.error) return { status: "error", error: existing.error };
|
|
135
135
|
|
|
136
|
-
const diff =
|
|
136
|
+
const diff = computeManifestDiff(existing.body!, manifest);
|
|
137
137
|
|
|
138
138
|
if (!diff.hasDifferences) {
|
|
139
139
|
return { status: "unchanged", resource: existing.body! };
|
|
@@ -170,7 +170,7 @@ export class ResourceClient {
|
|
|
170
170
|
|
|
171
171
|
if (existing.error) return { status: "error", error: existing.error };
|
|
172
172
|
|
|
173
|
-
const diff =
|
|
173
|
+
const diff = computeManifestDiff(existing.body!, manifest);
|
|
174
174
|
if (!diff.hasDifferences) return { status: "unchanged", resource: existing.body! };
|
|
175
175
|
if (dryRun) return { status: "dry-run", action: "update", diff };
|
|
176
176
|
return this.#updateResource(manifest, resolved, namespace, diff, startMs);
|
|
@@ -281,29 +281,17 @@ export class ResourceClient {
|
|
|
281
281
|
}
|
|
282
282
|
if (existing.error) return { isNew: false, error: existing.error };
|
|
283
283
|
|
|
284
|
-
const diff =
|
|
284
|
+
const diff = computeManifestDiff(existing.body!, manifest);
|
|
285
285
|
return { diff, isNew: false };
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
const metaDiff = computeResourceDiff(
|
|
296
|
-
filterToManifestKeys(currentMeta, desiredMeta) as Record<string, unknown>,
|
|
297
|
-
desiredMeta,
|
|
298
|
-
);
|
|
299
|
-
|
|
300
|
-
return {
|
|
301
|
-
hasDifferences: specDiff.hasDifferences || metaDiff.hasDifferences,
|
|
302
|
-
added: [...specDiff.added, ...metaDiff.added],
|
|
303
|
-
removed: [...specDiff.removed, ...metaDiff.removed],
|
|
304
|
-
changed: [...specDiff.changed, ...metaDiff.changed],
|
|
305
|
-
unchangedCount: specDiff.unchangedCount + metaDiff.unchangedCount,
|
|
306
|
-
};
|
|
288
|
+
async inspectForMutation(
|
|
289
|
+
resolved: ResolvedKind,
|
|
290
|
+
name: string,
|
|
291
|
+
namespaceOverride?: string,
|
|
292
|
+
): Promise<{ status: number; body?: Record<string, unknown>; error?: ResourceError }> {
|
|
293
|
+
const namespace = namespaceOverride ?? this.#defaultNamespace;
|
|
294
|
+
return this.#fetchComparisonResource(this.#buildUrl(resolved.paths.get, namespace, name), resolved);
|
|
307
295
|
}
|
|
308
296
|
|
|
309
297
|
#resolveNamespace(manifest: ResourceManifest, override?: string): string {
|
|
@@ -473,3 +461,24 @@ function filterToManifestKeys(serverVal: unknown, manifestVal: unknown): unknown
|
|
|
473
461
|
}
|
|
474
462
|
return filtered;
|
|
475
463
|
}
|
|
464
|
+
|
|
465
|
+
export function computeManifestDiff(serverResource: Record<string, unknown>, manifest: ResourceManifest): ResourceDiff {
|
|
466
|
+
const currentSpec = (serverResource.spec ?? {}) as Record<string, unknown>;
|
|
467
|
+
const filteredSpec = filterToManifestKeys(currentSpec, manifest.spec) as Record<string, unknown>;
|
|
468
|
+
const specDiff = computeResourceDiff(filteredSpec, manifest.spec);
|
|
469
|
+
|
|
470
|
+
const currentMeta = (serverResource.metadata ?? {}) as Record<string, unknown>;
|
|
471
|
+
const desiredMeta = manifest.rawObject.metadata as Record<string, unknown>;
|
|
472
|
+
const metaDiff = computeResourceDiff(
|
|
473
|
+
filterToManifestKeys(currentMeta, desiredMeta) as Record<string, unknown>,
|
|
474
|
+
desiredMeta,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
hasDifferences: specDiff.hasDifferences || metaDiff.hasDifferences,
|
|
479
|
+
added: [...specDiff.added, ...metaDiff.added],
|
|
480
|
+
removed: [...specDiff.removed, ...metaDiff.removed],
|
|
481
|
+
changed: [...specDiff.changed, ...metaDiff.changed],
|
|
482
|
+
unchangedCount: specDiff.unchangedCount + metaDiff.unchangedCount,
|
|
483
|
+
};
|
|
484
|
+
}
|