typekro 0.25.0 → 0.27.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/dist/.tsbuildinfo +1 -1
- package/dist/alchemy/kro-delete.d.ts +8 -0
- package/dist/alchemy/kro-delete.d.ts.map +1 -1
- package/dist/alchemy/kro-delete.js +34 -35
- package/dist/alchemy/kro-delete.js.map +1 -1
- package/dist/alchemy/resource-registration.d.ts +60 -0
- package/dist/alchemy/resource-registration.d.ts.map +1 -1
- package/dist/alchemy/resource-registration.js +290 -11
- package/dist/alchemy/resource-registration.js.map +1 -1
- package/dist/alchemy/types.d.ts +44 -0
- package/dist/alchemy/types.d.ts.map +1 -1
- package/dist/core/deployment/direct-factory.d.ts +8 -1
- package/dist/core/deployment/direct-factory.d.ts.map +1 -1
- package/dist/core/deployment/direct-factory.js +80 -61
- package/dist/core/deployment/direct-factory.js.map +1 -1
- package/dist/core/deployment/engine.d.ts +2 -0
- package/dist/core/deployment/engine.d.ts.map +1 -1
- package/dist/core/deployment/engine.js +22 -1
- package/dist/core/deployment/engine.js.map +1 -1
- package/dist/core/deployment/k8s-helpers.d.ts +12 -0
- package/dist/core/deployment/k8s-helpers.d.ts.map +1 -1
- package/dist/core/deployment/k8s-helpers.js +26 -1
- package/dist/core/deployment/k8s-helpers.js.map +1 -1
- package/dist/core/deployment/kro-factory.d.ts +248 -1
- package/dist/core/deployment/kro-factory.d.ts.map +1 -1
- package/dist/core/deployment/kro-factory.js +1060 -180
- package/dist/core/deployment/kro-factory.js.map +1 -1
- package/dist/core/deployment/kro-instance-safety.d.ts +209 -0
- package/dist/core/deployment/kro-instance-safety.d.ts.map +1 -1
- package/dist/core/deployment/kro-instance-safety.js +506 -24
- package/dist/core/deployment/kro-instance-safety.js.map +1 -1
- package/dist/core/deployment/kro-namespace-teardown.d.ts +248 -0
- package/dist/core/deployment/kro-namespace-teardown.d.ts.map +1 -0
- package/dist/core/deployment/kro-namespace-teardown.js +459 -0
- package/dist/core/deployment/kro-namespace-teardown.js.map +1 -0
- package/dist/core/deployment/readiness-waiter.d.ts.map +1 -1
- package/dist/core/deployment/readiness-waiter.js +6 -2
- package/dist/core/deployment/readiness-waiter.js.map +1 -1
- package/dist/core/deployment/rollback-manager.d.ts +33 -1
- package/dist/core/deployment/rollback-manager.d.ts.map +1 -1
- package/dist/core/deployment/rollback-manager.js +85 -30
- package/dist/core/deployment/rollback-manager.js.map +1 -1
- package/dist/core/serialization/core.d.ts.map +1 -1
- package/dist/core/serialization/core.js +82 -8
- package/dist/core/serialization/core.js.map +1 -1
- package/dist/core/types/deployment.d.ts +20 -0
- package/dist/core/types/deployment.d.ts.map +1 -1
- package/dist/factories/index.d.ts +1 -0
- package/dist/factories/index.d.ts.map +1 -1
- package/dist/factories/index.js +4 -0
- package/dist/factories/index.js.map +1 -1
- package/dist/factories/nats/compositions/index.d.ts +2 -0
- package/dist/factories/nats/compositions/index.d.ts.map +1 -0
- package/dist/factories/nats/compositions/index.js +2 -0
- package/dist/factories/nats/compositions/index.js.map +1 -0
- package/dist/factories/nats/compositions/nats-bootstrap.d.ts +49 -0
- package/dist/factories/nats/compositions/nats-bootstrap.d.ts.map +1 -0
- package/dist/factories/nats/compositions/nats-bootstrap.js +135 -0
- package/dist/factories/nats/compositions/nats-bootstrap.js.map +1 -0
- package/dist/factories/nats/index.d.ts +5 -0
- package/dist/factories/nats/index.d.ts.map +1 -0
- package/dist/factories/nats/index.js +5 -0
- package/dist/factories/nats/index.js.map +1 -0
- package/dist/factories/nats/resources/helm.d.ts +28 -0
- package/dist/factories/nats/resources/helm.d.ts.map +1 -0
- package/dist/factories/nats/resources/helm.js +39 -0
- package/dist/factories/nats/resources/helm.js.map +1 -0
- package/dist/factories/nats/resources/index.d.ts +3 -0
- package/dist/factories/nats/resources/index.d.ts.map +1 -0
- package/dist/factories/nats/resources/index.js +3 -0
- package/dist/factories/nats/resources/index.js.map +1 -0
- package/dist/factories/nats/resources/jetstream.d.ts +13 -0
- package/dist/factories/nats/resources/jetstream.d.ts.map +1 -0
- package/dist/factories/nats/resources/jetstream.js +105 -0
- package/dist/factories/nats/resources/jetstream.js.map +1 -0
- package/dist/factories/nats/types.d.ts +91 -0
- package/dist/factories/nats/types.d.ts.map +1 -0
- package/dist/factories/nats/types.js +72 -0
- package/dist/factories/nats/types.js.map +1 -0
- package/dist/factories/rook/types.d.ts +1 -1
- package/dist/utils/string.d.ts +14 -0
- package/dist/utils/string.d.ts.map +1 -1
- package/dist/utils/string.js +22 -0
- package/dist/utils/string.js.map +1 -1
- package/package.json +5 -1
|
@@ -14,11 +14,13 @@ import { createCompositionContext, runWithCompositionContext } from '../composit
|
|
|
14
14
|
import { buildNestedCompositionAliasTargets } from '../composition/nested-status-cel.js';
|
|
15
15
|
import { DEFAULT_DEPLOYMENT_TIMEOUT, DEFAULT_KRO_INSTANCE_TIMEOUT, DEFAULT_RGD_TIMEOUT, } from '../config/defaults.js';
|
|
16
16
|
import { CEL_EXPRESSION_BRAND, KUBERNETES_REF_SCHEMA_MARKER_SOURCE } from '../constants/brands.js';
|
|
17
|
-
import { ConversionError, CRDInstanceError, ensureError, ResourceGraphFactoryError, TypeKroError, ValidationError, } from '../errors.js';
|
|
17
|
+
import { ConversionError, CRDInstanceError, DeploymentTimeoutError, ensureError, ResourceGraphFactoryError, TypeKroError, ValidationError, } from '../errors.js';
|
|
18
18
|
import { isStrictCelDiagnosticsEnabled } from '../expressions/analysis/strict-cel.js';
|
|
19
19
|
import { applyAnalysisToResources } from '../expressions/composition/composition-analyzer.js';
|
|
20
20
|
import { createBunCompatibleKubernetesObjectApi } from '../kubernetes/index.js';
|
|
21
21
|
import { getComponentLogger } from '../logging/index.js';
|
|
22
|
+
import { namespace as namespaceResource } from '../../factories/kubernetes/core/namespace.js';
|
|
23
|
+
import { shortStableHash, toCamelCase } from '../../utils/string.js';
|
|
22
24
|
// Dependency inversion: kroCustomResource, resourceGraphDefinition, and
|
|
23
25
|
// alchemy bridge are injected via FactoryOptions providers (Phase 3.5)
|
|
24
26
|
// instead of dynamic import() from higher layers.
|
|
@@ -35,12 +37,20 @@ import { KubernetesClientManager } from './client-provider-manager.js';
|
|
|
35
37
|
import { DirectDeploymentEngine } from './engine.js';
|
|
36
38
|
import { logHandleSnapshot } from './handle-tracing.js';
|
|
37
39
|
import { isNotFoundError } from './k8s-helpers.js';
|
|
38
|
-
import { assertKroInstanceNamespaceOwnershipSafe, assertSingletonOwnerNamespaceOwnershipSafe, } from './kro-instance-safety.js';
|
|
40
|
+
import { assertKroInstanceNamespaceOwnershipSafe, assertNoHoistWeakenedStatusFields, assertSingletonOwnerNamespaceOwnershipSafe, concreteOwnedNamespaceResources, findDanglingHoistedReference, resolveConcreteMetadataValue, resolveNamespaceName, rewriteHoistedNamespaceReferences, rewriteHoistedNamespaceRefsInValue, selectHoistedNamespaces, } from './kro-instance-safety.js';
|
|
41
|
+
import { decideNamespaceOwnershipCreateFirst, deleteNamespaceIfEmpty, HOISTED_NAMESPACES_ANNOTATION, listNamespacesOwnedByRgd, NAMESPACE_OWNER_ANNOTATION, parseHoistedNamespacesAnnotation, readHoistedNamespacesRecord, } from './kro-namespace-teardown.js';
|
|
42
|
+
import { createRollbackManager } from './rollback-manager.js';
|
|
39
43
|
import { waitForKroInstanceReady as waitForKroInstanceReadyShared } from './kro-readiness.js';
|
|
40
44
|
import { evaluateSchemaCelExpression } from './schema-cel-evaluator.js';
|
|
41
45
|
import { convertToKubernetesName, extractSerializableKubeConfigOptions, generateInstanceName, getSingletonInstanceName, pluralizeKind, validateSpec, } from './shared-utilities.js';
|
|
42
46
|
import { joinYamlDocuments, singletonOwnerInstanceYamls, singletonRgdYamls, } from './singleton-gitops.js';
|
|
43
47
|
import { assertNoDeployedSingletonSpecDrift, singletonSpecFingerprintAnnotationValue, } from './singleton-owner-drift.js';
|
|
48
|
+
/**
|
|
49
|
+
* Label stamped on every KRO instance CR this factory creates, keyed to the
|
|
50
|
+
* factory's RGD name. Used by the cluster-wide cleanup list in `deleteInstance`
|
|
51
|
+
* to decide whether other instances still share the RGD.
|
|
52
|
+
*/
|
|
53
|
+
const INSTANCE_RGD_LABEL = 'typekro.io/rgd';
|
|
44
54
|
/**
|
|
45
55
|
* Decide whether the RGD/CRD should be preserved after a `deleteInstance`
|
|
46
56
|
* call, i.e., whether other instances still depend on it.
|
|
@@ -187,6 +197,119 @@ export class KroResourceFactoryImpl {
|
|
|
187
197
|
getInstanceApiVersion() {
|
|
188
198
|
return `${this.getSchemaGroup()}/${this.getSchemaVersion()}`;
|
|
189
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Resolve the namespace the KRO instance CR lives in for this call.
|
|
202
|
+
*
|
|
203
|
+
* The default CR placement is the FACTORY namespace (`this.namespace`), exactly
|
|
204
|
+
* as v0.26.0 always did — the maintainer's explicit decision (finding #2). This
|
|
205
|
+
* fixes the silent-relocation regression: an upgrade never moves an existing CR
|
|
206
|
+
* versus the released v0.26.0, and NO per-call `spec.namespace` is consulted for
|
|
207
|
+
* CR placement. An explicit `instanceNamespace` factory option still wins as an
|
|
208
|
+
* override (the only way to place the CR elsewhere).
|
|
209
|
+
*
|
|
210
|
+
* Identity distinctness within a single alchemy stack comes from the instance
|
|
211
|
+
* NAME: the top-level alchemy id is the legacy namespace-agnostic kind+name (see
|
|
212
|
+
* {@link instanceAlchemyId}, finding #1). NOTE: a different k8s factory namespace
|
|
213
|
+
* does NOT by itself create a different alchemy scope — two factories with the same
|
|
214
|
+
* instance name but different namespaces expose the SAME alchemy id and, if
|
|
215
|
+
* materialized in the SAME alchemy stack, collide (last write wins). Isolating
|
|
216
|
+
* same-named instances (e.g. `analytics` in dev vs prod) is the CALLER's
|
|
217
|
+
* responsibility: put them in SEPARATE alchemy stacks/scopes. `spec.namespace` is
|
|
218
|
+
* never consulted for CR placement or identity.
|
|
219
|
+
*
|
|
220
|
+
* The `spec` parameter is accepted only for call-site symmetry with the other
|
|
221
|
+
* resolvers and is deliberately unused (CR placement must not vary per spec, or
|
|
222
|
+
* `getInstances`/`deleteInstance` — which have no spec — could target a different
|
|
223
|
+
* namespace than `deploy` and orphan the CR, finding #3).
|
|
224
|
+
*
|
|
225
|
+
* Finalizer-stranding safety comes from HOISTING the composition's own workload
|
|
226
|
+
* Namespace OUT of the RGD graph (see {@link hoistedNamespaceNames}), not from
|
|
227
|
+
* where the instance CR lives.
|
|
228
|
+
*/
|
|
229
|
+
resolveInstanceNamespace(_spec) {
|
|
230
|
+
const explicit = this.factoryOptions.instanceNamespace;
|
|
231
|
+
if (explicit !== undefined)
|
|
232
|
+
return explicit;
|
|
233
|
+
return this.namespace;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Every Namespace HOISTED out of the RGD graph, mapped to its RAW `metadata.name`
|
|
237
|
+
* value — the UNCONDITIONAL model (typekro NEVER emits a Namespace into RGD YAML).
|
|
238
|
+
* Selection is trivially `kind === 'Namespace'` (excluding `__externalRef`
|
|
239
|
+
* observed namespaces); see {@link selectHoistedNamespaces}.
|
|
240
|
+
*
|
|
241
|
+
* This is a STABLE STRUCTURAL property of the RGD, computed WITHOUT any concrete
|
|
242
|
+
* spec: the set of hoisted ids is IDENTICAL for every instance of this factory, so
|
|
243
|
+
* the shared RGD never changes shape per-instance (deploying instance 2 can't
|
|
244
|
+
* mutate the graph instance 1 depends on). The name VALUES feed the reference
|
|
245
|
+
* rewriter so a reference to a hoisted Namespace's `metadata.name` becomes that
|
|
246
|
+
* Namespace's own concrete name expression.
|
|
247
|
+
*/
|
|
248
|
+
hoistedNamespaceRefs(resources = this.resources) {
|
|
249
|
+
return selectHoistedNamespaces(resources);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The CONCRETE Namespace resources to emit as SIBLINGS (deps-first, outside the
|
|
253
|
+
* graph) for this spec, keyed by namespace NAME.
|
|
254
|
+
*
|
|
255
|
+
* The set of hoisted IDS is STRUCTURAL (spec-independent); resolving those ids to
|
|
256
|
+
* concrete resources needs the spec. Re-executing the composition against the
|
|
257
|
+
* concrete spec yields the FULL concrete Namespace metadata (name + all labels,
|
|
258
|
+
* incl. Pod Security, + all annotations), which the emission paths PRESERVE and
|
|
259
|
+
* merge retention onto (finding #5) — never a bare synthesized Namespace. A hoisted
|
|
260
|
+
* id that is inactive under THIS spec (e.g. `includeWhen: false`) or whose name
|
|
261
|
+
* can't be resolved is skipped, so emission stays per-spec and order-independent.
|
|
262
|
+
* The map value is the ORIGINAL concrete Namespace resource (keyed by resolved
|
|
263
|
+
* name).
|
|
264
|
+
*/
|
|
265
|
+
concreteHoistedNamespaces(spec) {
|
|
266
|
+
const result = new Map();
|
|
267
|
+
const hoistIds = this.hoistedNamespaceRefs();
|
|
268
|
+
if (hoistIds.size === 0 || spec === undefined)
|
|
269
|
+
return result;
|
|
270
|
+
const compositionFn = this.factoryOptions.compositionFn;
|
|
271
|
+
const concreteById = concreteOwnedNamespaceResources({
|
|
272
|
+
compositionName: this.name,
|
|
273
|
+
spec,
|
|
274
|
+
resources: this.resources,
|
|
275
|
+
...(compositionFn ? { compositionFn } : {}),
|
|
276
|
+
});
|
|
277
|
+
for (const id of hoistIds.keys()) {
|
|
278
|
+
const concrete = concreteById.get(id);
|
|
279
|
+
// Inactive under this spec (conditionally-excluded) → nothing to emit.
|
|
280
|
+
if (!concrete)
|
|
281
|
+
continue;
|
|
282
|
+
const name = resolveNamespaceName(concrete.metadata?.name, spec) ??
|
|
283
|
+
(typeof concrete.metadata?.name === 'string' ? concrete.metadata.name : undefined);
|
|
284
|
+
if (name === undefined)
|
|
285
|
+
continue;
|
|
286
|
+
result.set(name, concrete);
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The concrete NAMES of the namespaces hoisted out of the graph for this spec —
|
|
292
|
+
* used to tell the ownership guard which owned namespace is now safe (no longer a
|
|
293
|
+
* graph child), and as the singleton keys for sibling emission.
|
|
294
|
+
*/
|
|
295
|
+
hoistedNamespaceNames(spec) {
|
|
296
|
+
return new Set(this.concreteHoistedNamespaces(spec).keys());
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The resource map serialized into the RGD: `resources` minus EVERY hoisted
|
|
300
|
+
* Namespace, with every remaining reference to a hoisted Namespace's
|
|
301
|
+
* `metadata.name` rewritten to that Namespace's own concrete name expression
|
|
302
|
+
* (finding #3) so the emitted RGD carries no dangling reference to a removed
|
|
303
|
+
* resource. The hoist decision is STRUCTURAL (spec-independent), so the RGD shape
|
|
304
|
+
* is stable across every instance of this factory (finding #4).
|
|
305
|
+
*/
|
|
306
|
+
resourcesForRgd(resources) {
|
|
307
|
+
const hoistIds = this.hoistedNamespaceRefs(resources);
|
|
308
|
+
if (hoistIds.size === 0)
|
|
309
|
+
return resources;
|
|
310
|
+
const remaining = Object.fromEntries(Object.entries(resources).filter(([id]) => !hoistIds.has(id)));
|
|
311
|
+
return rewriteHoistedNamespaceReferences(remaining, hoistIds);
|
|
312
|
+
}
|
|
190
313
|
/**
|
|
191
314
|
* Idempotently create the factory's target namespace if it doesn't
|
|
192
315
|
* exist. KRO does not auto-create the CR's containing namespace, and
|
|
@@ -318,12 +441,34 @@ export class KroResourceFactoryImpl {
|
|
|
318
441
|
return undefined;
|
|
319
442
|
}
|
|
320
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* STRICT plural discovery (finding #3, fail-closed). Distinguishes a definitive
|
|
446
|
+
* "CRD absent" (a successful CRD list with no matching definition → fresh cluster)
|
|
447
|
+
* from a discovery FAILURE (RBAC/connectivity/list error) — the latter is NOT proof
|
|
448
|
+
* of absence, so it THROWS rather than being swallowed into `undefined` the way
|
|
449
|
+
* {@link lookupCRDPlural} does. Callers that must fail closed (the pre-hoist safety
|
|
450
|
+
* guard) use this; callers that may fall back to a heuristic keep using
|
|
451
|
+
* {@link lookupCRDPlural}.
|
|
452
|
+
*/
|
|
453
|
+
async discoverGeneratedCrdPlural() {
|
|
454
|
+
const k8sApi = this.createKubernetesObjectApi();
|
|
455
|
+
// A LIST failure propagates (NOT caught) so callers FAIL CLOSED.
|
|
456
|
+
const crds = (await k8sApi.list('apiextensions.k8s.io/v1', 'CustomResourceDefinition'));
|
|
457
|
+
const match = crds?.items?.find((crd) => crd.spec?.group === this.getSchemaGroup() &&
|
|
458
|
+
crd.spec?.names?.kind === this.schemaDefinition.kind);
|
|
459
|
+
const plural = match?.spec?.names?.plural;
|
|
460
|
+
return plural ? { present: true, plural } : { present: false };
|
|
461
|
+
}
|
|
321
462
|
async requireCRDPluralForCleanup() {
|
|
322
463
|
if (!this.discoveredPlural) {
|
|
323
464
|
this.discoveredPlural = await this.lookupCRDPlural();
|
|
324
465
|
}
|
|
325
466
|
if (!this.discoveredPlural) {
|
|
326
|
-
|
|
467
|
+
// Teardown-only guard: without the plural we cannot list instances to decide
|
|
468
|
+
// whether the RGD is shared, so the caller preserves the RGD (conservative). The
|
|
469
|
+
// generated CRD is intentionally RETAINED regardless (v4), so this no longer
|
|
470
|
+
// guards against orphaning it.
|
|
471
|
+
throw new CRDInstanceError(`Cannot determine CRD plural for ${this.schemaDefinition.kind}; preserving RGD to avoid deleting shared KRO state`, this.schemaDefinition.apiVersion, this.schemaDefinition.kind, '*', 'deletion');
|
|
327
472
|
}
|
|
328
473
|
return this.discoveredPlural;
|
|
329
474
|
}
|
|
@@ -427,6 +572,11 @@ export class KroResourceFactoryImpl {
|
|
|
427
572
|
singletonFactoryFor(definition) {
|
|
428
573
|
return definition.composition.factory('kro', {
|
|
429
574
|
namespace: definition.registryNamespace,
|
|
575
|
+
// Pin the singleton owner CR to its registry namespace (its externalRef
|
|
576
|
+
// consumers resolve against that namespace). This equals the workload
|
|
577
|
+
// namespace, so it is NOT decoupled — a singleton composition that owns
|
|
578
|
+
// the registry namespace is still rejected by the ownership guard.
|
|
579
|
+
instanceNamespace: definition.registryNamespace,
|
|
430
580
|
waitForReady: true,
|
|
431
581
|
...(this.factoryOptions.timeout !== undefined
|
|
432
582
|
? { timeout: this.factoryOptions.timeout }
|
|
@@ -588,18 +738,42 @@ export class KroResourceFactoryImpl {
|
|
|
588
738
|
* Deploy directly to Kubernetes using DirectDeploymentEngine
|
|
589
739
|
*/
|
|
590
740
|
async deployDirect(spec, instanceNameOverride, singletonSpecFingerprint) {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
|
|
741
|
+
const instanceNamespace = this.resolveInstanceNamespace(spec);
|
|
742
|
+
const instanceName = instanceNameOverride ?? generateInstanceName(spec, this.name);
|
|
743
|
+
const hoistedNamespaces = this.concreteHoistedNamespaces(spec);
|
|
744
|
+
// FINDING #2: fail closed on a PRE-HOIST upgrade BEFORE touching the RGD. If a
|
|
745
|
+
// namespace this factory now hoists is currently a KRO ApplySet member (a namespace
|
|
746
|
+
// a pre-hoist RGD owned as a graph child), rolling the new hoisted RGD over the old
|
|
747
|
+
// one drops the namespace from the ApplySet — and KRO's prune then deletes the live
|
|
748
|
+
// namespace out from under the workload. Detect it and THROW (detection only — no
|
|
749
|
+
// automatic migration). See the migration doc for the one-time manual step.
|
|
750
|
+
await this.assertNoPreHoistNamespaceConflict(hoistedNamespaces.keys());
|
|
751
|
+
// Ensure RGD is deployed. Every owned Namespace is hoisted OUT of the RGD graph
|
|
752
|
+
// and applied as a SIBLING below (deps-first), never a graph child — so KRO never
|
|
753
|
+
// owns a namespace and deleting the instance can never garbage-collect the
|
|
754
|
+
// namespace holding its own finalizer. The hoist is STRUCTURAL, so the shared RGD
|
|
755
|
+
// shape is the same for every instance (finding #4).
|
|
756
|
+
await this.ensureRGDDeployed(spec);
|
|
757
|
+
// Ensure the namespace that will hold the CR exists before posting it. KRO
|
|
758
|
+
// reconciles resources from the RGD into their own namespaces, but the CR
|
|
759
|
+
// instance itself must live in a namespace the user can write to. If the
|
|
760
|
+
// composition owns that namespace it was hoisted out of the graph above, so
|
|
761
|
+
// creating it here (as a sibling) means KRO never garbage-collects it. Without
|
|
762
|
+
// this, the first deploy after `kubectl delete ns` fails with a 404 on the CR
|
|
763
|
+
// POST.
|
|
764
|
+
//
|
|
765
|
+
// A hoisted Namespace is applied with its COMPLETE preserved configuration
|
|
766
|
+
// (all labels incl. Pod Security + spec) plus retention markers; a non-hoisted
|
|
767
|
+
// instance namespace just needs to exist.
|
|
768
|
+
if (!hoistedNamespaces.has(instanceNamespace)) {
|
|
769
|
+
await this.ensureTargetNamespace(instanceNamespace);
|
|
770
|
+
}
|
|
771
|
+
for (const [name, original] of hoistedNamespaces) {
|
|
772
|
+
await this.applyRetainedHoistedNamespace(name, original, spec);
|
|
773
|
+
}
|
|
599
774
|
// Create DirectDeploymentEngine with KRO mode for CEL string conversion
|
|
600
775
|
const deploymentEngine = new DirectDeploymentEngine(this.getKubeConfig(), undefined, undefined, DeploymentMode.KRO);
|
|
601
776
|
// Create custom resource instance
|
|
602
|
-
const instanceName = instanceNameOverride ?? generateInstanceName(spec, this.name);
|
|
603
777
|
const customResourceData = this.createCustomResourceInstance(instanceName, spec, singletonSpecFingerprint);
|
|
604
778
|
// Wrap with kroCustomResource factory to get Enhanced object with readiness evaluation
|
|
605
779
|
const kroCustomResource = this.kroCustomResourceProvider ??
|
|
@@ -619,7 +793,7 @@ export class KroResourceFactoryImpl {
|
|
|
619
793
|
metadata: {
|
|
620
794
|
...enhancedCustomResource.metadata,
|
|
621
795
|
name: instanceName,
|
|
622
|
-
namespace:
|
|
796
|
+
namespace: instanceNamespace,
|
|
623
797
|
},
|
|
624
798
|
spec: customResourceData.spec, // Use spec directly from customResourceData to ensure it's preserved
|
|
625
799
|
};
|
|
@@ -630,7 +804,7 @@ export class KroResourceFactoryImpl {
|
|
|
630
804
|
try {
|
|
631
805
|
await deploymentEngine.deployResource(deployableResource, {
|
|
632
806
|
mode: 'kro',
|
|
633
|
-
namespace:
|
|
807
|
+
namespace: instanceNamespace,
|
|
634
808
|
waitForReady: false, // We'll handle Kro-specific readiness ourselves
|
|
635
809
|
timeout: this.factoryOptions.timeout || DEFAULT_DEPLOYMENT_TIMEOUT,
|
|
636
810
|
});
|
|
@@ -640,7 +814,7 @@ export class KroResourceFactoryImpl {
|
|
|
640
814
|
});
|
|
641
815
|
// Handle Kro-specific readiness checking if requested
|
|
642
816
|
if (this.factoryOptions.waitForReady ?? true) {
|
|
643
|
-
await this.waitForKroInstanceReady(instanceName, this.factoryOptions.timeout || DEFAULT_KRO_INSTANCE_TIMEOUT); // 10 minutes
|
|
817
|
+
await this.waitForKroInstanceReady(instanceName, this.factoryOptions.timeout || DEFAULT_KRO_INSTANCE_TIMEOUT, instanceNamespace); // 10 minutes
|
|
644
818
|
}
|
|
645
819
|
this.logger.info('Instance ready, creating enhanced proxy', {
|
|
646
820
|
instanceName,
|
|
@@ -653,6 +827,264 @@ export class KroResourceFactoryImpl {
|
|
|
653
827
|
await deploymentEngine.dispose();
|
|
654
828
|
}
|
|
655
829
|
}
|
|
830
|
+
/**
|
|
831
|
+
* Apply a hoisted workload Namespace as a RETAINED resource OUTSIDE the KRO
|
|
832
|
+
* graph, with its COMPLETE preserved configuration — all labels (incl. Pod
|
|
833
|
+
* Security + schema-derived), all annotations, AND spec — plus retention markers
|
|
834
|
+
* (finding #8), using typekro's own field manager (not KRO's). Server-side apply
|
|
835
|
+
* is idempotent and does not conflict with KRO because KRO no longer considers
|
|
836
|
+
* the Namespace desired.
|
|
837
|
+
*
|
|
838
|
+
* If the full Namespace cannot be applied, this FAILS (finding #8) rather than
|
|
839
|
+
* silently degrading to a bare `ensureTargetNamespace` that would drop Pod
|
|
840
|
+
* Security + other declared configuration.
|
|
841
|
+
*/
|
|
842
|
+
async applyRetainedHoistedNamespace(name, original, spec) {
|
|
843
|
+
const merged = KroResourceFactoryImpl.mergedHoistedNamespaceMetadata(name, original, spec);
|
|
844
|
+
const k8sApi = createBunCompatibleKubernetesObjectApi(this.getKubeConfig());
|
|
845
|
+
const baseMetadata = {
|
|
846
|
+
name: merged.name,
|
|
847
|
+
labels: merged.labels,
|
|
848
|
+
...(merged.finalizers !== undefined ? { finalizers: merged.finalizers } : {}),
|
|
849
|
+
...(merged.ownerReferences !== undefined
|
|
850
|
+
? { ownerReferences: merged.ownerReferences }
|
|
851
|
+
: {}),
|
|
852
|
+
};
|
|
853
|
+
const buildManifest = (annotations) => ({
|
|
854
|
+
apiVersion: 'v1',
|
|
855
|
+
kind: 'Namespace',
|
|
856
|
+
metadata: { ...baseMetadata, annotations },
|
|
857
|
+
...(merged.spec !== undefined ? { spec: merged.spec } : {}),
|
|
858
|
+
});
|
|
859
|
+
const annotationsWithStamp = {
|
|
860
|
+
...merged.annotations,
|
|
861
|
+
[NAMESPACE_OWNER_ANNOTATION]: this.rgdName,
|
|
862
|
+
};
|
|
863
|
+
// CREATE-FIRST ownership (finding #3): attempt to CREATE the namespace WITH the
|
|
864
|
+
// ownership stamp and complete declared config. A 201 is atomic proof WE created it
|
|
865
|
+
// (owned). A 409 means it already exists — owned ONLY if a prior create by this RGD
|
|
866
|
+
// stamped it; otherwise adopted (never stamped, so teardown never deletes it). This
|
|
867
|
+
// replaces the raceable GET→(404)→patch-with-stamp: no window in which another actor
|
|
868
|
+
// creates the namespace between our read and our stamping patch.
|
|
869
|
+
let decision;
|
|
870
|
+
try {
|
|
871
|
+
decision = await decideNamespaceOwnershipCreateFirst(k8sApi, buildManifest(annotationsWithStamp), this.rgdName);
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
// A non-conflict CREATE failure (or a failed conflict-read) — do NOT fall back to a
|
|
875
|
+
// bare namespace (finding #8). Fail loudly so the operator sees the real problem.
|
|
876
|
+
throw new ResourceGraphFactoryError(`Failed to create the retained workload Namespace "${name}" with its complete declared ` +
|
|
877
|
+
`configuration (Pod Security labels, annotations, spec): ${ensureError(error).message}`, this.name, 'deployment', ensureError(error));
|
|
878
|
+
}
|
|
879
|
+
// 201 CREATE landed the full config + stamp atomically — nothing more to apply.
|
|
880
|
+
if (decision.created)
|
|
881
|
+
return;
|
|
882
|
+
// 409: the namespace already existed. Apply the retained config via SSA, keeping the
|
|
883
|
+
// ownership stamp ONLY if we already own it (adoption must NOT be stamped).
|
|
884
|
+
const annotations = decision.owned ? annotationsWithStamp : merged.annotations;
|
|
885
|
+
try {
|
|
886
|
+
await k8sApi.patch(buildManifest(annotations), undefined, undefined, 'typekro', // fieldManager — typekro's, NOT kro's
|
|
887
|
+
true, // force (own the retention fields)
|
|
888
|
+
'application/apply-patch+yaml' // server-side apply
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
catch (error) {
|
|
892
|
+
// Do NOT fall back to a bare namespace (finding #8): that would drop the
|
|
893
|
+
// declared Pod Security labels + other config. Fail loudly so the operator
|
|
894
|
+
// sees the real problem instead of a silently-degraded workload namespace.
|
|
895
|
+
throw new ResourceGraphFactoryError(`Failed to apply the retained workload Namespace "${name}" with its complete declared ` +
|
|
896
|
+
`configuration (Pod Security labels, annotations, spec): ${ensureError(error).message}`, this.name, 'deployment', ensureError(error));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* FINDING #2 — fail-closed PRE-HOIST detection (detection only; no migration).
|
|
901
|
+
*
|
|
902
|
+
* On deploy, BEFORE the RGD is (re)applied, check every namespace this factory would
|
|
903
|
+
* hoist. If a live namespace is currently a KRO ApplySet member — it carries
|
|
904
|
+
* `applyset.kubernetes.io/part-of` and/or any `kro.run/*` ownership label from a
|
|
905
|
+
* PRE-HOIST RGD (which owned the namespace as a graph child) — then rolling the new,
|
|
906
|
+
* hoisted RGD (which no longer lists the namespace) over the old one makes KRO's
|
|
907
|
+
* ApplySet prune enumerate and DELETE the live namespace, taking the workload with
|
|
908
|
+
* it. We THROW a clear, actionable error instead. This does NOT re-introduce the
|
|
909
|
+
* removed automatic migration/drain/strip machinery — it only refuses to proceed.
|
|
910
|
+
*
|
|
911
|
+
* typekro's own v2 sibling namespaces carry `typekro.io/kro-instance-namespace=true`
|
|
912
|
+
* but NEITHER an ApplySet `part-of` NOR any `kro.run/*` label (KRO never owns them),
|
|
913
|
+
* so steady-state re-deploys never trip this. A 404 (fresh) is not a pre-hoist signal.
|
|
914
|
+
*
|
|
915
|
+
* FINDING #7 (hardened): the check covers not only the namespaces resolved from the
|
|
916
|
+
* INCOMING spec but EVERY existing instance of this shared RGD (their hoisted
|
|
917
|
+
* namespaces too) — an upgrade prunes the ApplySet for ALL of them at once, so
|
|
918
|
+
* missing another instance's namespace would let KRO delete it. And reads FAIL
|
|
919
|
+
* CLOSED: a non-404 read error is NOT proof of safety, so it ABORTS the deploy
|
|
920
|
+
* rather than being skipped.
|
|
921
|
+
*/
|
|
922
|
+
async assertNoPreHoistNamespaceConflict(hoistedNamespaceNames) {
|
|
923
|
+
const names = new Set(hoistedNamespaceNames);
|
|
924
|
+
// Union in every EXISTING instance's hoisted namespaces (finding #7): they are all
|
|
925
|
+
// dropped from the ApplySet by the same RGD roll. Fails closed on a non-absent list
|
|
926
|
+
// error.
|
|
927
|
+
for (const extra of await this.existingInstancesHoistedNamespaceNames()) {
|
|
928
|
+
names.add(extra);
|
|
929
|
+
}
|
|
930
|
+
if (names.size === 0)
|
|
931
|
+
return;
|
|
932
|
+
const k8sApi = this.createKubernetesObjectApi();
|
|
933
|
+
for (const name of names) {
|
|
934
|
+
let labels = {};
|
|
935
|
+
try {
|
|
936
|
+
const live = (await k8sApi.read({
|
|
937
|
+
apiVersion: 'v1',
|
|
938
|
+
kind: 'Namespace',
|
|
939
|
+
metadata: { name },
|
|
940
|
+
}));
|
|
941
|
+
labels = live.metadata?.labels ?? {};
|
|
942
|
+
}
|
|
943
|
+
catch (error) {
|
|
944
|
+
const k8sErr = error;
|
|
945
|
+
const code = k8sErr.statusCode ?? k8sErr.code ?? k8sErr.body?.code;
|
|
946
|
+
if (code === 404)
|
|
947
|
+
continue; // fresh namespace — nothing to migrate
|
|
948
|
+
// FAIL CLOSED (finding #7): a non-404 read error means we cannot prove the
|
|
949
|
+
// namespace is safe to hoist over. Abort rather than risk KRO's prune deleting
|
|
950
|
+
// a namespace whose pre-hoist ownership we could not read.
|
|
951
|
+
throw new TypeKroError(`Pre-hoist safety check could not read Namespace "${name}" for composition ` +
|
|
952
|
+
`"${this.name}" (${ensureError(error).message}). Refusing to deploy: a hoist over an ` +
|
|
953
|
+
`unreadable namespace could let KRO's ApplySet prune delete it. Resolve the read ` +
|
|
954
|
+
`error (RBAC/connectivity) and retry.`, 'PRE_HOIST_NAMESPACE_CHECK_FAILED', { composition: this.name, namespace: name, mode: 'kro' });
|
|
955
|
+
}
|
|
956
|
+
const partOfKro = typeof labels['applyset.kubernetes.io/part-of'] === 'string' ||
|
|
957
|
+
Object.keys(labels).some((key) => key.startsWith('kro.run/'));
|
|
958
|
+
if (partOfKro) {
|
|
959
|
+
throw new TypeKroError(`Pre-hoist deployment detected for composition "${this.name}": the live Namespace ` +
|
|
960
|
+
`"${name}" is a KRO ApplySet member (carries applyset.kubernetes.io/part-of and/or ` +
|
|
961
|
+
`kro.run/* ownership labels from a pre-hoist RGD). typekro now HOISTS every owned ` +
|
|
962
|
+
`Namespace out of the RGD, so applying the new RGD in place would drop "${name}" from ` +
|
|
963
|
+
`KRO's ApplySet and KRO's prune would DELETE the live namespace and its workloads. ` +
|
|
964
|
+
`This upgrade is not auto-migrated. Either recreate (delete the instance, then ` +
|
|
965
|
+
`redeploy — the namespace is recreated as a sibling) or perform the one-time manual ` +
|
|
966
|
+
`label-strip while the controller is quiesced. See docs/advanced/migration.md ` +
|
|
967
|
+
`("Upgrading from a pre-hoist TypeKro release").`, 'PRE_HOIST_NAMESPACE_CONFLICT', { composition: this.name, namespace: name, labels: Object.keys(labels), mode: 'kro' });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* The hoisted-namespace names of EVERY existing instance of this shared RGD (finding
|
|
973
|
+
* #7), resolved EXACTLY and PER INSTANCE from that instance's OWN durable record
|
|
974
|
+
* (finding #1, v7) — never approximated, and never from an RGD-wide aggregate.
|
|
975
|
+
*
|
|
976
|
+
* Each instance's namespaces come from its OWN CR `typekro.io/hoisted-namespaces`
|
|
977
|
+
* annotation — the only PER-INSTANCE proof. An instance that lacks its own record (a
|
|
978
|
+
* genuinely-legacy deployment) FAILS CLOSED (throws): the RGD-wide set of Namespaces
|
|
979
|
+
* carrying `typekro.io/created-by-rgd == this.rgdName` is NOT per-instance proof — a
|
|
980
|
+
* DIFFERENT (modern) instance could have stamped it, so a non-empty RGD-wide set would
|
|
981
|
+
* otherwise MASK a legacy instance whose own — possibly different — namespace is invisible
|
|
982
|
+
* and would be pruned. We also never approximate from metadata.namespace / spec.namespace.
|
|
983
|
+
*
|
|
984
|
+
* The RGD-wide `created-by-rgd` set IS still unioned into the returned protected set (it
|
|
985
|
+
* is a superset that catches namespaces leaked by an interrupted teardown) — it just never
|
|
986
|
+
* SATISFIES the per-instance check. FRESH clusters (no CRD / no instances yet) yield an
|
|
987
|
+
* empty set; any list error FAILS CLOSED so the guard is never silently bypassed.
|
|
988
|
+
*/
|
|
989
|
+
async existingInstancesHoistedNamespaceNames() {
|
|
990
|
+
const result = new Set();
|
|
991
|
+
// Short-circuit: if this RGD structurally hoists NO namespace, the hoist can prune nothing —
|
|
992
|
+
// there is nothing to protect and no per-instance record to require. An ordinary composition
|
|
993
|
+
// with no Namespace resources must never trip the pre-hoist legacy guard.
|
|
994
|
+
if (this.hoistedNamespaceRefs().size === 0)
|
|
995
|
+
return result;
|
|
996
|
+
// Discover the generated CRD via a STRICT lookup that THROWS on a real list error
|
|
997
|
+
// (finding #3, fail-closed): a discovery/RBAC failure is NOT proof the CRD is
|
|
998
|
+
// absent. Only a successful list with no matching CRD is a definitive "fresh
|
|
999
|
+
// cluster" (no prior instances to protect).
|
|
1000
|
+
let discovery;
|
|
1001
|
+
try {
|
|
1002
|
+
discovery = await this.discoverGeneratedCrdPlural();
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
throw new TypeKroError(`Pre-hoist safety check could not discover the generated CRD for RGD "${this.rgdName}" ` +
|
|
1006
|
+
`(${ensureError(error).message}). Refusing to deploy: a discovery/RBAC error is not ` +
|
|
1007
|
+
`proof the CRD is absent, and proceeding could let the hoist prune an existing ` +
|
|
1008
|
+
`instance's namespace unchecked.`, 'PRE_HOIST_CRD_DISCOVERY_FAILED', { composition: this.name, rgdName: this.rgdName, mode: 'kro' });
|
|
1009
|
+
}
|
|
1010
|
+
if (!discovery.present)
|
|
1011
|
+
return result; // definitively fresh — no prior instances
|
|
1012
|
+
let items;
|
|
1013
|
+
try {
|
|
1014
|
+
const customApi = await this.createCustomObjectsApi();
|
|
1015
|
+
const listResponse = await customApi.listClusterCustomObject({
|
|
1016
|
+
group: this.getSchemaGroup(),
|
|
1017
|
+
version: this.getSchemaVersion(),
|
|
1018
|
+
plural: discovery.plural,
|
|
1019
|
+
});
|
|
1020
|
+
items =
|
|
1021
|
+
listResponse.items ?? [];
|
|
1022
|
+
}
|
|
1023
|
+
catch (error) {
|
|
1024
|
+
// Only a DEFINITIVE 404 (the CRD/instances vanished between discovery and list)
|
|
1025
|
+
// is treated as fresh; ANY other error FAILS CLOSED (finding #3) — a message
|
|
1026
|
+
// that merely mentions "CRD"/"not found" no longer counts (that was the swallow
|
|
1027
|
+
// bug), only a real NotFound status.
|
|
1028
|
+
const k8sErr = error;
|
|
1029
|
+
const status = k8sErr.statusCode ?? k8sErr.code ?? k8sErr.body?.code;
|
|
1030
|
+
if (status === 404)
|
|
1031
|
+
return result;
|
|
1032
|
+
throw new TypeKroError(`Pre-hoist safety check could not list existing instances of RGD "${this.rgdName}" ` +
|
|
1033
|
+
`(${ensureError(error).message}). Refusing to deploy: another instance's namespace could ` +
|
|
1034
|
+
`be pruned by the hoist without being checked.`, 'PRE_HOIST_INSTANCE_LIST_FAILED', { composition: this.name, rgdName: this.rgdName, mode: 'kro' });
|
|
1035
|
+
}
|
|
1036
|
+
if (items.length === 0)
|
|
1037
|
+
return result;
|
|
1038
|
+
// The RGD-wide record of every namespace this RGD created: namespaces carrying
|
|
1039
|
+
// `typekro.io/created-by-rgd == this.rgdName`. We union it into the PROTECTED set as a
|
|
1040
|
+
// superset — it catches namespaces leaked by an interrupted teardown that no live CR
|
|
1041
|
+
// records. It is NOT used to satisfy the per-instance check below (finding #1, v7): being
|
|
1042
|
+
// RGD-wide, it cannot prove a SPECIFIC instance's namespaces. A list error FAILS CLOSED —
|
|
1043
|
+
// an unreadable list is not proof no owned namespace exists.
|
|
1044
|
+
let ownedNamespaces;
|
|
1045
|
+
try {
|
|
1046
|
+
ownedNamespaces = new Set(await listNamespacesOwnedByRgd(this.getKubeConfig(), this.rgdName, {
|
|
1047
|
+
k8sApi: this.createKubernetesObjectApi(),
|
|
1048
|
+
logger: this.logger,
|
|
1049
|
+
}));
|
|
1050
|
+
}
|
|
1051
|
+
catch (error) {
|
|
1052
|
+
throw new TypeKroError(`Pre-hoist safety check could not list namespaces owned by RGD "${this.rgdName}" ` +
|
|
1053
|
+
`(${ensureError(error).message}). Refusing to deploy: without the durable ownership ` +
|
|
1054
|
+
`record we cannot prove the hoist won't prune an existing instance's namespace.`, 'PRE_HOIST_OWNED_NAMESPACE_LIST_FAILED', { composition: this.name, rgdName: this.rgdName, mode: 'kro' });
|
|
1055
|
+
}
|
|
1056
|
+
// Every owned namespace is at risk of the ApplySet prune — protect them all.
|
|
1057
|
+
for (const ns of ownedNamespaces)
|
|
1058
|
+
result.add(ns);
|
|
1059
|
+
for (const item of items) {
|
|
1060
|
+
// Resolve THIS instance's namespaces from its OWN exact PER-INSTANCE record — the CR
|
|
1061
|
+
// `typekro.io/hoisted-namespaces` annotation (finding #1, v7). It carries the instance's
|
|
1062
|
+
// EXACT hoisted names (round-tripping a name derived from an arbitrary spec field) and is
|
|
1063
|
+
// the ONLY per-instance proof.
|
|
1064
|
+
const record = readHoistedNamespacesRecord(item.metadata?.annotations?.[HOISTED_NAMESPACES_ANNOTATION]);
|
|
1065
|
+
if (record.status === 'present') {
|
|
1066
|
+
// A VALID record — including an explicit empty `[]` — is per-instance proof: this
|
|
1067
|
+
// instance hoists exactly these namespaces (possibly none). Resolved; do NOT fail closed.
|
|
1068
|
+
for (const ns of record.names)
|
|
1069
|
+
result.add(ns);
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
// MISSING or MALFORMED record → NO per-instance exact record. FAIL CLOSED (finding #1, v7). We do
|
|
1073
|
+
// NOT fall back to the RGD-wide `created-by-rgd` set: it is not per-instance proof — a
|
|
1074
|
+
// DIFFERENT (modern) instance could have stamped it, so a non-empty set would MASK this
|
|
1075
|
+
// legacy instance whose own — possibly different — owned namespace is then invisible and
|
|
1076
|
+
// pruned by the hoist. Nor do we approximate from metadata.namespace / spec.namespace.
|
|
1077
|
+
// Refusing the upgrade until the legacy instance is migrated/annotated is the intended,
|
|
1078
|
+
// safe transition cost — refusing beats silently pruning an unseen namespace.
|
|
1079
|
+
throw new TypeKroError(`Pre-hoist safety check found an existing instance of RGD "${this.rgdName}" with no ` +
|
|
1080
|
+
`per-instance namespace record — its CR carries no "${HOISTED_NAMESPACES_ANNOTATION}" ` +
|
|
1081
|
+
`annotation. The RGD-wide "${NAMESPACE_OWNER_ANNOTATION}=${this.rgdName}" set is NOT ` +
|
|
1082
|
+
`per-instance proof (another instance could have stamped it), so this instance's own ` +
|
|
1083
|
+
`owned namespace could be pruned by the hoist unseen. Refusing to deploy: migrate it ` +
|
|
1084
|
+
`(redeploy the instance with a current TypeKro so its namespaces are recorded) and retry.`, 'PRE_HOIST_LEGACY_INSTANCE_UNRESOLVABLE', { composition: this.name, rgdName: this.rgdName, mode: 'kro' });
|
|
1085
|
+
}
|
|
1086
|
+
return result;
|
|
1087
|
+
}
|
|
656
1088
|
/**
|
|
657
1089
|
* Get all deployed instances
|
|
658
1090
|
*/
|
|
@@ -671,11 +1103,15 @@ export class KroResourceFactoryImpl {
|
|
|
671
1103
|
this.discoveredPlural = await this.lookupCRDPlural();
|
|
672
1104
|
}
|
|
673
1105
|
const plural = this.discoveredPlural ?? pluralizeKind(this.schemaDefinition.kind);
|
|
674
|
-
//
|
|
1106
|
+
// The instance CR lives in the namespace {@link resolveInstanceNamespace}
|
|
1107
|
+
// resolves to — the SAME shared resolver `deploy()`/`toYaml()` use, so a
|
|
1108
|
+
// create in namespace X is always listed in namespace X (finding #2). No
|
|
1109
|
+
// per-call spec is available here, so it resolves the factory default
|
|
1110
|
+
// (`instanceNamespace` override ?? factory namespace).
|
|
675
1111
|
const listResponse = await customApi.listNamespacedCustomObject({
|
|
676
1112
|
group: this.getSchemaGroup(),
|
|
677
1113
|
version,
|
|
678
|
-
namespace: this.
|
|
1114
|
+
namespace: this.resolveInstanceNamespace(),
|
|
679
1115
|
plural,
|
|
680
1116
|
});
|
|
681
1117
|
const listResult = listResponse;
|
|
@@ -747,155 +1183,260 @@ export class KroResourceFactoryImpl {
|
|
|
747
1183
|
if (opts?.scopes?.length) {
|
|
748
1184
|
throw new TypeKroError('Scope-filtered deletion is not supported in KRO mode. KRO manages resource lifecycle via its own controller. Use direct mode for scope-filtered deletes.', 'UNSUPPORTED_OPTION', { scopes: opts.scopes, instanceName: name, mode: 'kro' });
|
|
749
1185
|
}
|
|
750
|
-
const
|
|
751
|
-
|
|
1186
|
+
const k8sApi = this.createKubernetesObjectApi();
|
|
1187
|
+
// ONE gating deletion mechanism for the whole teardown (finding #1): the engine's
|
|
1188
|
+
// rollback manager, whose `deleteResourceAndWait` deletes then polls to a REAL 404
|
|
1189
|
+
// and THROWS DeploymentTimeoutError on timeout — never a silent return that lets a
|
|
1190
|
+
// later step run behind a still-Terminating resource.
|
|
1191
|
+
const rollback = createRollbackManager(k8sApi);
|
|
1192
|
+
const timeout = this.factoryOptions.timeout ?? 300000;
|
|
752
1193
|
const apiVersion = this.getInstanceApiVersion();
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
|
|
757
|
-
|
|
1194
|
+
// The CR lives in the namespace {@link resolveInstanceNamespace} resolves to — the
|
|
1195
|
+
// SAME shared resolver `deploy()` created it with (finding #2), so we never look in
|
|
1196
|
+
// the wrong namespace and mistake a 404 there for "already gone".
|
|
1197
|
+
const instanceNamespace = this.resolveInstanceNamespace();
|
|
1198
|
+
// Capture the CR's durable RECORD of which namespaces THIS instance hoisted BEFORE
|
|
1199
|
+
// deleting it (finding #4). deleteInstance has no caller spec, so the live CR is the
|
|
1200
|
+
// only source of the declared set. PREFER the recorded `typekro.io/hoisted-namespaces`
|
|
1201
|
+
// annotation (stamped at deploy time) — it round-trips a name derived from an
|
|
1202
|
+
// arbitrary spec field (e.g. spec.targetNamespace) EXACTLY. Fall back to re-deriving
|
|
1203
|
+
// from the CR's spec for instances deployed before the annotation existed (the
|
|
1204
|
+
// imperative path CAN re-execute the composition). If the CR is already gone (404) or
|
|
1205
|
+
// unreadable, we cannot reconstruct it, so NO namespace becomes a delete candidate
|
|
1206
|
+
// (fail-safe: an owned-but-unprovable namespace is kept).
|
|
1207
|
+
let crSpec;
|
|
1208
|
+
let recordedHoistedNames = [];
|
|
758
1209
|
try {
|
|
759
|
-
|
|
760
|
-
// which does graph-based deletion of all child resources.
|
|
761
|
-
await k8sApi.delete({
|
|
1210
|
+
const live = (await k8sApi.read({
|
|
762
1211
|
apiVersion,
|
|
763
1212
|
kind: this.schemaDefinition.kind,
|
|
764
|
-
metadata: {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
while (Date.now() - startTime < timeout) {
|
|
775
|
-
try {
|
|
776
|
-
await k8sApi.read({
|
|
777
|
-
apiVersion,
|
|
778
|
-
kind: this.schemaDefinition.kind,
|
|
779
|
-
metadata: { name, namespace: this.namespace },
|
|
780
|
-
});
|
|
781
|
-
// Still exists — KRO is processing finalizer
|
|
782
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
783
|
-
}
|
|
784
|
-
catch (pollError) {
|
|
785
|
-
const pollK8sError = pollError;
|
|
786
|
-
const errorCode = pollK8sError.statusCode ?? pollK8sError.code ?? pollK8sError.body?.code;
|
|
787
|
-
if (errorCode === 404) {
|
|
788
|
-
instanceDeleted = true;
|
|
789
|
-
break;
|
|
790
|
-
}
|
|
791
|
-
// Non-404 error (permissions, server error) — log and retry
|
|
792
|
-
this.logger.debug('Deletion poll error (retrying)', {
|
|
793
|
-
name,
|
|
794
|
-
errorCode,
|
|
795
|
-
});
|
|
796
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
if (!instanceDeleted) {
|
|
800
|
-
// KRO is still processing the finalizer. Treat the stuck instance as
|
|
801
|
-
// unsafe to clean up, so the RGD/CRD are preserved.
|
|
802
|
-
// Deleting the RGD while KRO is mid-finalizer would orphan cleanup.
|
|
803
|
-
deletionTimedOut = true;
|
|
804
|
-
this.logger.warn('Instance deletion still in progress after timeout', {
|
|
805
|
-
name,
|
|
806
|
-
timeout,
|
|
807
|
-
elapsed: Date.now() - startTime,
|
|
808
|
-
hint: 'KRO finalizer processing continues in the background. The RGD will be preserved.',
|
|
809
|
-
});
|
|
1213
|
+
metadata: { name, namespace: instanceNamespace },
|
|
1214
|
+
}));
|
|
1215
|
+
crSpec = live.spec;
|
|
1216
|
+
recordedHoistedNames = parseHoistedNamespacesAnnotation(live.metadata?.annotations?.[HOISTED_NAMESPACES_ANNOTATION]);
|
|
1217
|
+
}
|
|
1218
|
+
catch (readError) {
|
|
1219
|
+
const k8sErr = readError;
|
|
1220
|
+
const code = k8sErr.statusCode ?? k8sErr.code ?? k8sErr.body?.code;
|
|
1221
|
+
if (code !== 404) {
|
|
1222
|
+
this.logger.debug('Could not read instance CR before delete (declared-namespace record unavailable)', { name, error: ensureError(readError).message });
|
|
810
1223
|
}
|
|
811
1224
|
}
|
|
1225
|
+
const declaredHoistedNamespaceNames = recordedHoistedNames.length > 0
|
|
1226
|
+
? recordedHoistedNames
|
|
1227
|
+
: crSpec
|
|
1228
|
+
? [...this.concreteHoistedNamespaces(crSpec).keys()]
|
|
1229
|
+
: [];
|
|
1230
|
+
// 1. Delete the CR and GATE on its 404 — KRO cleared `kro.run/finalizer` after
|
|
1231
|
+
// graph-deleting every child (findings #1 + #2). On timeout the gate THROWS, so we
|
|
1232
|
+
// NEVER proceed to delete the RGD while KRO is mid-finalizer (which would orphan
|
|
1233
|
+
// cleanup). A pre-existing 404 on delete is treated as already-deleted.
|
|
1234
|
+
let instanceDeleted = false;
|
|
1235
|
+
try {
|
|
1236
|
+
await rollback.deleteResourceAndWait({ apiVersion, kind: this.schemaDefinition.kind, name, namespace: instanceNamespace }, { timeout });
|
|
1237
|
+
instanceDeleted = true;
|
|
1238
|
+
}
|
|
812
1239
|
catch (error) {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1240
|
+
if (error instanceof DeploymentTimeoutError) {
|
|
1241
|
+
// KRO is still processing the finalizer. The RGD/CRD/namespace are LEFT ALONE
|
|
1242
|
+
// (we throw before reaching them) so KRO can finish cleanup in the background.
|
|
1243
|
+
throw new CRDInstanceError(`KRO instance ${name} deletion did not complete within ${timeout}ms`, this.schemaDefinition.apiVersion, this.schemaDefinition.kind, name, 'deletion', ensureError(error));
|
|
817
1244
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
|
|
1245
|
+
throw new CRDInstanceError(`Failed to delete instance ${name}: ${ensureError(error).message}`, this.schemaDefinition.apiVersion, this.schemaDefinition.kind, name, 'deletion', ensureError(error));
|
|
1246
|
+
}
|
|
1247
|
+
// 2. LIST REMAINING INSTANCES FIRST — BEFORE deleting any namespace (finding #2, v7).
|
|
1248
|
+
// This instance's per-instance namespace cleanup below must EXCLUDE any namespace a
|
|
1249
|
+
// REMAINING instance still records: because ownership is RGD-wide, deleting this instance
|
|
1250
|
+
// could otherwise remove an empty/default-only namespace that another remaining instance
|
|
1251
|
+
// shares (its workloads may momentarily leave it empty), and the later RGD-preserve check
|
|
1252
|
+
// cannot RESTORE a deleted namespace. So we list once, up front, and use the result for
|
|
1253
|
+
// BOTH the shared-namespace exclusion AND the RGD-share decision. A list FAILURE is
|
|
1254
|
+
// fail-closed on BOTH: we can compute NEITHER the exclusion nor the share decision, so we
|
|
1255
|
+
// delete NO namespace and preserve the RGD/CRD (a retry completes teardown once the list
|
|
1256
|
+
// is readable).
|
|
1257
|
+
let remainingItems;
|
|
830
1258
|
try {
|
|
831
|
-
|
|
832
|
-
hasRemainingInstances = shouldPreserveRgd(instances, name, instanceDeleted, this.namespace);
|
|
1259
|
+
remainingItems = await this.listInstancesForCleanup();
|
|
833
1260
|
}
|
|
834
1261
|
catch (listError) {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1262
|
+
this.logger.warn('Cannot list instances — preserving ALL namespaces + the RGD/CRD (fail closed; retry to complete)', { rgdName: this.rgdName, error: ensureError(listError).message });
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
// The decision to keep the RGD is a pure function of (listed instances, target name,
|
|
1266
|
+
// instanceDeleted flag) — see {@link shouldPreserveRgd} for the rules.
|
|
1267
|
+
const hasRemainingInstances = shouldPreserveRgd(remainingItems, name, instanceDeleted, instanceNamespace);
|
|
1268
|
+
// The remaining instances (this one filtered out) — the SAME filter shouldPreserveRgd
|
|
1269
|
+
// applies — whose recorded namespaces this instance's cleanup must PRESERVE.
|
|
1270
|
+
const remainingInstances = instanceDeleted
|
|
1271
|
+
? remainingItems.filter((i) => {
|
|
1272
|
+
if (i.metadata?.name !== name)
|
|
1273
|
+
return true;
|
|
1274
|
+
if (!instanceNamespace)
|
|
1275
|
+
return false;
|
|
1276
|
+
return i.metadata?.namespace !== instanceNamespace;
|
|
1277
|
+
})
|
|
1278
|
+
: remainingItems;
|
|
1279
|
+
// 3. Delete THIS instance's OWN hoisted namespace(s) — EXCLUDING any a remaining instance
|
|
1280
|
+
// still records (finding #2, v7). Deletable = this instance's declared namespaces MINUS the
|
|
1281
|
+
// UNION of every remaining instance's recorded namespaces. This preserves v5's "each
|
|
1282
|
+
// instance cleans its own namespace even when non-last" (a NON-last instance's exclusive
|
|
1283
|
+
// namespace is still cleaned here, never leaked) while it also EXCLUDES a namespace a
|
|
1284
|
+
// remaining instance shares. Cleanup stays ownership-PRIMARY (`ownedByRgd`: a namespace only
|
|
1285
|
+
// this RGD's create stamped) + emptiness-secondary (a namespace another stack still occupies
|
|
1286
|
+
// is RETAINED) via deleteNamespaceIfEmpty. The names come from the CR's recorded
|
|
1287
|
+
// `typekro.io/hoisted-namespaces` annotation (finding #4), so an arbitrary-field name
|
|
1288
|
+
// round-trips cross-process. Namespace-before-RGD/CRD is safe: the RGD + generated CRD stay
|
|
1289
|
+
// HEALTHY/Active during ns termination, so the namespace controller confirms emptiness
|
|
1290
|
+
// INSTANTLY (never terminating against a *Terminating* CRD, upstream kro #1171). typekro
|
|
1291
|
+
// never puts a Namespace in the RGD, so KRO's finalizer never touched these — we own their
|
|
1292
|
+
// teardown; the delete is gated on a real 404 via the SAME primitive (throws on timeout).
|
|
1293
|
+
//
|
|
1294
|
+
// FAIL CLOSED: if ANY remaining instance's record cannot be resolved exactly (no
|
|
1295
|
+
// `hoisted-namespaces` annotation on an RGD that DOES hoist), the exclusion is uncomputable
|
|
1296
|
+
// → preserve ALL of this instance's namespaces (delete NONE), since any of them could be one
|
|
1297
|
+
// the unresolved instance still needs.
|
|
1298
|
+
if (declaredHoistedNamespaceNames.length > 0) {
|
|
1299
|
+
const preserved = new Set();
|
|
1300
|
+
let allRemainingResolvable = true;
|
|
1301
|
+
for (const item of remainingInstances) {
|
|
1302
|
+
const record = readHoistedNamespacesRecord(item.metadata?.annotations?.[HOISTED_NAMESPACES_ANNOTATION]);
|
|
1303
|
+
if (record.status !== 'present') {
|
|
1304
|
+
// MISSING or MALFORMED — this remaining instance's namespaces are uncomputable.
|
|
1305
|
+
allRemainingResolvable = false;
|
|
1306
|
+
continue;
|
|
867
1307
|
}
|
|
1308
|
+
// PRESENT (including a valid empty []) is RESOLVED: contribute its namespaces (possibly
|
|
1309
|
+
// none). A valid [] no longer forces the fail-closed preserve-everything path (P2).
|
|
1310
|
+
for (const ns of record.names)
|
|
1311
|
+
preserved.add(ns);
|
|
868
1312
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
// heuristic fallback so already-plural kinds clean up correctly.
|
|
873
|
-
const crdName = `${crdPlural}.${this.getSchemaGroup()}`;
|
|
874
|
-
try {
|
|
875
|
-
await k8sApi.delete({
|
|
876
|
-
apiVersion: 'apiextensions.k8s.io/v1',
|
|
877
|
-
kind: 'CustomResourceDefinition',
|
|
878
|
-
metadata: { name: crdName },
|
|
879
|
-
});
|
|
880
|
-
this.logger.debug('CRD deleted', { crdName });
|
|
1313
|
+
if (!allRemainingResolvable) {
|
|
1314
|
+
this.logger.warn("A remaining instance has no resolvable namespace record — preserving ALL of this instance's " +
|
|
1315
|
+
'namespaces (fail closed) to avoid deleting one a remaining instance shares', { rgdName: this.rgdName, instance: name });
|
|
881
1316
|
}
|
|
882
|
-
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1317
|
+
else {
|
|
1318
|
+
for (const ns of declaredHoistedNamespaceNames) {
|
|
1319
|
+
if (preserved.has(ns)) {
|
|
1320
|
+
this.logger.debug('Namespace recorded by a remaining instance — preserving', {
|
|
1321
|
+
namespace: ns,
|
|
1322
|
+
rgdName: this.rgdName,
|
|
1323
|
+
});
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
await deleteNamespaceIfEmpty(this.getKubeConfig(), ns, {
|
|
1327
|
+
logger: this.logger,
|
|
1328
|
+
// Reuse the SAME (mockable) object API so discovery/delete is unit-testable.
|
|
1329
|
+
k8sApi,
|
|
1330
|
+
ownedByRgd: this.rgdName,
|
|
1331
|
+
timeoutMs: timeout,
|
|
1332
|
+
context: { rgdName: this.rgdName },
|
|
1333
|
+
});
|
|
888
1334
|
}
|
|
889
1335
|
}
|
|
890
1336
|
}
|
|
891
|
-
|
|
1337
|
+
// Only delete the RGD and CRD if no other instances remain. Multiple instances can share
|
|
1338
|
+
// one RGD — deleting it would break the others.
|
|
1339
|
+
if (hasRemainingInstances) {
|
|
892
1340
|
this.logger.debug('Skipping RGD/CRD deletion — other instances still exist', {
|
|
893
1341
|
rgdName: this.rgdName,
|
|
894
1342
|
});
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
// 2b. RETRY-SAFE owned-namespace sweep (finding #2). The per-instance cleanup above
|
|
1346
|
+
// reads the names off the CR's `typekro.io/hoisted-namespaces` annotation — but that
|
|
1347
|
+
// record dies WITH the CR, so a retry AFTER an earlier attempt already deleted the CR
|
|
1348
|
+
// (crash / partial teardown) would find the CR 404, read NO names, and skip cleanup.
|
|
1349
|
+
// Since this is the LAST instance, find EVERY namespace this RGD created via the
|
|
1350
|
+
// DURABLE `typekro.io/created-by-rgd` namespace annotation (survives the CR) and clean
|
|
1351
|
+
// each — catching any namespace leaked by an interrupted teardown. The names above may
|
|
1352
|
+
// overlap; `deleteNamespaceIfEmpty` is idempotent (a 404 is a no-op). A LIST failure
|
|
1353
|
+
// means we cannot confirm cleanup, so PRESERVE the RGD/CRD and return (a later retry
|
|
1354
|
+
// completes it) — deleting the definitions now could orphan a leaked namespace.
|
|
1355
|
+
let ownedNamespaces;
|
|
1356
|
+
try {
|
|
1357
|
+
ownedNamespaces = await listNamespacesOwnedByRgd(this.getKubeConfig(), this.rgdName, {
|
|
1358
|
+
k8sApi,
|
|
1359
|
+
logger: this.logger,
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
catch (listError) {
|
|
1363
|
+
this.logger.warn('Cannot list owned namespaces to confirm cleanup — preserving RGD/CRD (retry to complete)', { rgdName: this.rgdName, error: ensureError(listError).message });
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
for (const ns of ownedNamespaces) {
|
|
1367
|
+
await deleteNamespaceIfEmpty(this.getKubeConfig(), ns, {
|
|
1368
|
+
logger: this.logger,
|
|
1369
|
+
k8sApi,
|
|
1370
|
+
ownedByRgd: this.rgdName,
|
|
1371
|
+
timeoutMs: timeout,
|
|
1372
|
+
context: { rgdName: this.rgdName },
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
// 2c. CONFIRM owned-namespace cleanup BEFORE removing the definitions (finding #2): do
|
|
1376
|
+
// NOT delete the RGD/CRD while any owned namespace still exists or cannot be confirmed
|
|
1377
|
+
// gone. `deleteNamespaceIfEmpty` above RETAINS (does not delete) a namespace another
|
|
1378
|
+
// stack still occupies or one it could not read — those legitimately remain here, so we
|
|
1379
|
+
// keep the RGD/CRD rather than orphan a still-owned namespace from its definition. Only
|
|
1380
|
+
// once NO created-by-rgd namespace remains do we proceed to tear the definitions down.
|
|
1381
|
+
let remainingOwned;
|
|
1382
|
+
try {
|
|
1383
|
+
remainingOwned = await listNamespacesOwnedByRgd(this.getKubeConfig(), this.rgdName, {
|
|
1384
|
+
k8sApi,
|
|
1385
|
+
logger: this.logger,
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
catch (listError) {
|
|
1389
|
+
this.logger.warn('Cannot confirm owned-namespace cleanup — preserving RGD/CRD (retry to complete)', { rgdName: this.rgdName, error: ensureError(listError).message });
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (remainingOwned.length > 0) {
|
|
1393
|
+
this.logger.info('Owned namespace(s) still present after cleanup — preserving RGD/CRD until they are gone', { rgdName: this.rgdName, remaining: remainingOwned });
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
// 3. Delete the RGD and GATE on its 404 (findings #1 + #2). The RGD carries a KRO
|
|
1397
|
+
// finalizer; while KRO processes it, KRO's per-RGD dynamic controller is still
|
|
1398
|
+
// WATCHING the generated CRD's resources. Waiting for the RGD 404 first lets KRO
|
|
1399
|
+
// tear its controller down cleanly. The gate THROWS on timeout (no silent proceed).
|
|
1400
|
+
await rollback.deleteResourceAndWait({ apiVersion: 'kro.run/v1alpha1', kind: 'ResourceGraphDefinition', name: this.rgdName }, { timeout });
|
|
1401
|
+
this.logger.debug('RGD deleted and fully gone', { rgdName: this.rgdName });
|
|
1402
|
+
// 4. Delete the generated CRD LAST — after the CR (404), RGD (404), and the owned
|
|
1403
|
+
// namespace are all gone (reverse-topo: CRDs die last). This step is BEST-EFFORT and
|
|
1404
|
+
// NON-FATAL: KRO's default config has allowCRDDeletion=false so KRO never deletes it,
|
|
1405
|
+
// and the apiextensions `customresourcecleanup` finalizer can stall for minutes even
|
|
1406
|
+
// with zero instances (upstream kro #1171). By now the namespace is already cleanly
|
|
1407
|
+
// gone, so a slow CRD teardown blocks NOTHING — we initiate the delete and wait
|
|
1408
|
+
// generously, but a timeout/failure is LOGGED, never thrown: it must not undo an
|
|
1409
|
+
// otherwise-successful teardown. A dangling (Terminating or leftover) CRD is
|
|
1410
|
+
// harmless; an operator may GC it out-of-band. See docs/advanced/migration.md.
|
|
1411
|
+
await this.deleteGeneratedCrdBestEffort(rollback, timeout);
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* BEST-EFFORT deletion of this factory's generated CRD, the LAST teardown step (after
|
|
1415
|
+
* the owned namespace is gone). Never throws: a slow/stuck apiextensions cleanup
|
|
1416
|
+
* finalizer (upstream kro #1171) must not fail an otherwise-complete teardown, and by
|
|
1417
|
+
* this point nothing depends on the CRD being gone. Logs the outcome.
|
|
1418
|
+
*/
|
|
1419
|
+
async deleteGeneratedCrdBestEffort(rollback, timeout) {
|
|
1420
|
+
// Prefer the already-discovered plural; else the non-throwing lookup (this is
|
|
1421
|
+
// best-effort): if we cannot determine the CRD name, log and skip rather than fail
|
|
1422
|
+
// the completed teardown.
|
|
1423
|
+
const crdPlural = this.discoveredPlural ?? (await this.lookupCRDPlural());
|
|
1424
|
+
if (!crdPlural) {
|
|
1425
|
+
this.logger.debug('Skipping best-effort CRD delete — plural undiscoverable (teardown already complete)', {
|
|
1426
|
+
rgdName: this.rgdName,
|
|
1427
|
+
});
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
const crdName = `${crdPlural}.${this.getSchemaGroup()}`;
|
|
1431
|
+
try {
|
|
1432
|
+
await rollback.deleteResourceAndWait({ apiVersion: 'apiextensions.k8s.io/v1', kind: 'CustomResourceDefinition', name: crdName }, { timeout });
|
|
1433
|
+
this.logger.debug('Generated CRD deleted and fully gone (last teardown step)', { crdName });
|
|
1434
|
+
}
|
|
1435
|
+
catch (error) {
|
|
1436
|
+
// NON-FATAL: the namespace is already gone; a lingering/Terminating CRD is
|
|
1437
|
+
// harmless (kro #1171). Log and return success.
|
|
1438
|
+
this.logger.warn('Best-effort generated-CRD delete did not complete — leaving it (harmless; GC out-of-band)', { crdName, error: ensureError(error).message });
|
|
895
1439
|
}
|
|
896
|
-
// Namespaces are resources in the composition's dependency graph.
|
|
897
|
-
// KRO's finalizer processing handles deleting all child resources
|
|
898
|
-
// (including Namespaces) via its applyset — no manual cleanup needed.
|
|
899
1440
|
}
|
|
900
1441
|
/**
|
|
901
1442
|
* Emit this factory's KRO deployment as declarative alchemy **v2** resources — the v2
|
|
@@ -924,10 +1465,32 @@ export class KroResourceFactoryImpl {
|
|
|
924
1465
|
name: this.name,
|
|
925
1466
|
});
|
|
926
1467
|
this.assertInstanceNamespaceOwnershipSafe(spec);
|
|
1468
|
+
const instanceNamespace = this.resolveInstanceNamespace(spec);
|
|
927
1469
|
const kubeConfigOptions = this.extractKubeConfigOptionsForAlchemy();
|
|
928
|
-
const kroDeletion = this.createAlchemyKroDeletionOptions();
|
|
1470
|
+
const kroDeletion = this.createAlchemyKroDeletionOptions(instanceNamespace);
|
|
929
1471
|
const prerequisiteDeclarations = this.prerequisiteAlchemyDeclarations(kubeConfigOptions);
|
|
930
1472
|
const prerequisiteIds = prerequisiteDeclarations.map((decl) => decl.id);
|
|
1473
|
+
// Every Namespace the composition owns is HOISTED out of the RGD graph and
|
|
1474
|
+
// emitted here instead — before the RGD + instance and OUTSIDE the graph, so the
|
|
1475
|
+
// CR always has a namespace to land in and KRO never garbage-collects it (which
|
|
1476
|
+
// would strand the instance's finalizer). Teardown is EMPTY-GATED (findings #3 +
|
|
1477
|
+
// #4): alchemy's reverse-topo teardown runs the namespace's delete AFTER the RGD +
|
|
1478
|
+
// instance (both `dependsOn` it), and the instance's delete waits for its
|
|
1479
|
+
// `kro.run/finalizer` to clear (KRO graph-deletes all children) — so by then
|
|
1480
|
+
// everything THIS instance owned has drained (finding #1's ordering, via the
|
|
1481
|
+
// dependency graph + instance-drain wait). The namespace is then deleted ONLY if
|
|
1482
|
+
// empty and RETAINED if another stack/user still has resources there. This is the
|
|
1483
|
+
// same for the instance's OWN namespace and a SHARED one — deduped by name, so
|
|
1484
|
+
// multiple stacks share ONE declaration and none deletes it while another still
|
|
1485
|
+
// occupies it (no cross-stack refcount needed).
|
|
1486
|
+
const instanceNamespaceDeclarations = [];
|
|
1487
|
+
const instanceNamespaceIds = [];
|
|
1488
|
+
for (const [hoistedNs, original] of this.concreteHoistedNamespaces(spec)) {
|
|
1489
|
+
const decl = this.hoistedNamespaceAlchemyDeclaration(hoistedNs, this.buildHoistedNamespaceResource(hoistedNs, original, spec), kubeConfigOptions, prerequisiteIds);
|
|
1490
|
+
instanceNamespaceDeclarations.push(decl);
|
|
1491
|
+
instanceNamespaceIds.push(decl.id);
|
|
1492
|
+
}
|
|
1493
|
+
const leadingIds = [...prerequisiteIds, ...instanceNamespaceIds];
|
|
931
1494
|
// 0. Singleton owners. The imperative `deploy()` ensures shared singleton owners (each its own
|
|
932
1495
|
// RGD + instance, in its registry namespace) via `ensureSingletonOwners` BEFORE the main
|
|
933
1496
|
// instance; emit them as declarations too so the declarative path has the same boundaries.
|
|
@@ -967,14 +1530,14 @@ export class KroResourceFactoryImpl {
|
|
|
967
1530
|
// RGD would then carry an object where a string belongs — KRO rejects the whole graph
|
|
968
1531
|
// (`GraphAccepted=False: expected string type ..., got object`) and it never reconciles. Matching
|
|
969
1532
|
// the load schema to the dump makes the round-trip lossless (the scalar stays the string it was).
|
|
970
|
-
const rgdManifest = yaml.load(this.buildRgdYaml(), { schema: yaml.JSON_SCHEMA });
|
|
1533
|
+
const rgdManifest = yaml.load(this.buildRgdYaml(spec), { schema: yaml.JSON_SCHEMA });
|
|
971
1534
|
const rgdFactory = this.rgdProvider ??
|
|
972
1535
|
(await import('../../factories/kro/resource-graph-definition.js')).resourceGraphDefinition;
|
|
973
1536
|
const rgdEnhanced = rgdFactory(rgdManifest);
|
|
974
1537
|
const rgdId = createAlchemyResourceId(rgdEnhanced, this.namespace);
|
|
975
1538
|
const rgdDeclaration = {
|
|
976
1539
|
id: rgdId,
|
|
977
|
-
dependsOn:
|
|
1540
|
+
dependsOn: leadingIds,
|
|
978
1541
|
props: {
|
|
979
1542
|
resource: rgdEnhanced,
|
|
980
1543
|
namespace: this.namespace,
|
|
@@ -997,13 +1560,13 @@ export class KroResourceFactoryImpl {
|
|
|
997
1560
|
// Cast: a plain KubernetesResource is fine — the alchemy path only reads kind/metadata.
|
|
998
1561
|
const crdAsEnhanced = crdInstanceManifest;
|
|
999
1562
|
const instanceDeclaration = {
|
|
1000
|
-
id:
|
|
1563
|
+
id: this.instanceAlchemyId(crdAsEnhanced),
|
|
1001
1564
|
// Wait for the RGD's CRD AND for any singleton owners (mirrors `ensureSingletonOwners` running
|
|
1002
1565
|
// before the main deploy), so the instance applies only once its dependencies exist.
|
|
1003
|
-
dependsOn: [rgdId, ...singletonInstanceIds],
|
|
1566
|
+
dependsOn: [rgdId, ...singletonInstanceIds, ...instanceNamespaceIds],
|
|
1004
1567
|
props: {
|
|
1005
1568
|
resource: crdAsEnhanced,
|
|
1006
|
-
namespace:
|
|
1569
|
+
namespace: instanceNamespace,
|
|
1007
1570
|
deploymentStrategy: 'kro',
|
|
1008
1571
|
kubeConfigOptions,
|
|
1009
1572
|
kroDeletion,
|
|
@@ -1019,22 +1582,43 @@ export class KroResourceFactoryImpl {
|
|
|
1019
1582
|
};
|
|
1020
1583
|
return [
|
|
1021
1584
|
...prerequisiteDeclarations,
|
|
1585
|
+
...instanceNamespaceDeclarations,
|
|
1022
1586
|
...singletonDeclarations,
|
|
1023
1587
|
rgdDeclaration,
|
|
1024
1588
|
instanceDeclaration,
|
|
1025
1589
|
];
|
|
1026
1590
|
}
|
|
1591
|
+
/**
|
|
1592
|
+
* The alchemy resource id for a KRO instance CR — the LEGACY, namespace-agnostic
|
|
1593
|
+
* kind+name id (finding #1).
|
|
1594
|
+
*
|
|
1595
|
+
* This deliberately does NOT fold the namespace into the id. An earlier revision
|
|
1596
|
+
* appended a namespace hash to distinguish same-named instances placed in
|
|
1597
|
+
* different namespaces, but that changed every existing instance's id versus the
|
|
1598
|
+
* released v0.26.0, so alchemy would see remove+replace and could tear down
|
|
1599
|
+
* (delete) the live CR on upgrade. Reverting to the legacy id keeps existing
|
|
1600
|
+
* alchemy state identity stable.
|
|
1601
|
+
*
|
|
1602
|
+
* Consequence to be honest about: within ONE alchemy stack, two instances collide
|
|
1603
|
+
* iff they share this kind+name id — regardless of their k8s namespace. A
|
|
1604
|
+
* different factory namespace does NOT automatically yield a different alchemy
|
|
1605
|
+
* scope. To keep same-named instances (e.g. `analytics` in dev vs prod) separate,
|
|
1606
|
+
* the CALLER must materialize them in SEPARATE alchemy stacks/scopes.
|
|
1607
|
+
*/
|
|
1608
|
+
instanceAlchemyId(resource) {
|
|
1609
|
+
return createAlchemyResourceId(resource);
|
|
1610
|
+
}
|
|
1027
1611
|
/** Serialize the factory's cluster connection so an alchemy resource can reconnect after rehydration. */
|
|
1028
1612
|
extractKubeConfigOptionsForAlchemy() {
|
|
1029
1613
|
return extractSerializableKubeConfigOptions(this.getKubeConfig(), this.factoryOptions.skipTLSVerify === true ? true : undefined);
|
|
1030
1614
|
}
|
|
1031
1615
|
/** Build the finalizer-safe, shared-RGD-aware deletion metadata for this factory's instances. */
|
|
1032
|
-
createAlchemyKroDeletionOptions() {
|
|
1616
|
+
createAlchemyKroDeletionOptions(instanceNamespace = this.namespace) {
|
|
1033
1617
|
return {
|
|
1034
1618
|
apiVersion: this.schemaDefinition.apiVersion,
|
|
1035
1619
|
kind: this.schemaDefinition.kind,
|
|
1036
1620
|
...(this.schemaDefinition.group && { group: this.schemaDefinition.group }),
|
|
1037
|
-
namespace:
|
|
1621
|
+
namespace: instanceNamespace,
|
|
1038
1622
|
rgdName: this.rgdName,
|
|
1039
1623
|
...(this.discoveredPlural && { plural: this.discoveredPlural }),
|
|
1040
1624
|
timeout: this.factoryOptions.timeout ?? 300000,
|
|
@@ -1129,8 +1713,16 @@ export class KroResourceFactoryImpl {
|
|
|
1129
1713
|
// Shared singleton owner instances are created by `deploy()`; for the
|
|
1130
1714
|
// GitOps path we emit them alongside the consuming instance, deps-first,
|
|
1131
1715
|
// so the consuming RGD's externalRef resolves. No-op without singletons.
|
|
1716
|
+
// Any owned workload Namespace hoisted out of the RGD graph leads, so the CR
|
|
1717
|
+
// always has a namespace to apply into and KRO never garbage-collects it.
|
|
1132
1718
|
const ownerYamls = singletonOwnerInstanceYamls(this.discoverSingletonDefinitions(spec));
|
|
1133
|
-
|
|
1719
|
+
const leadingInstanceYamls = [
|
|
1720
|
+
...[...this.concreteHoistedNamespaces(spec)].map(([ns, original]) => this.hoistedNamespaceYaml(ns, original, spec)),
|
|
1721
|
+
...ownerYamls,
|
|
1722
|
+
];
|
|
1723
|
+
return leadingInstanceYamls.length === 0
|
|
1724
|
+
? instanceYaml
|
|
1725
|
+
: joinYamlDocuments(leadingInstanceYamls, instanceYaml);
|
|
1134
1726
|
}
|
|
1135
1727
|
const rgdYaml = this.buildRgdYaml();
|
|
1136
1728
|
const prerequisiteYamls = this.prerequisiteResourceYamls();
|
|
@@ -1148,13 +1740,39 @@ export class KroResourceFactoryImpl {
|
|
|
1148
1740
|
* step — it's applied inline during ref-to-CEL conversion via
|
|
1149
1741
|
* `SerializationContext.omitFields`, which `serializeResourceGraphToYaml`
|
|
1150
1742
|
* populates from `kroSchema.__omitFields` automatically.
|
|
1743
|
+
*
|
|
1744
|
+
* The hoist decision is SPEC-INDEPENDENT (finding #4): the RGD is shared by every
|
|
1745
|
+
* instance of this factory, so its shape must be a stable structural property and
|
|
1746
|
+
* must NOT vary with a per-call spec. `spec` is accepted only for signature
|
|
1747
|
+
* symmetry with the callers and is deliberately NOT used to decide hoisting — see
|
|
1748
|
+
* {@link hoistedNamespaceRefs} / {@link resourcesForRgd}.
|
|
1151
1749
|
*/
|
|
1152
|
-
buildRgdYaml() {
|
|
1750
|
+
buildRgdYaml(_spec) {
|
|
1153
1751
|
if (this.factoryOptions.compositionAnalysis && !this.compositionAnalysisApplied) {
|
|
1154
1752
|
this.compositionAnalysisApplied = true;
|
|
1155
1753
|
applyAnalysisToResources(this.resources, this.factoryOptions.compositionAnalysis);
|
|
1156
1754
|
}
|
|
1157
|
-
|
|
1755
|
+
// Hoist EVERY owned Namespace OUT of the RGD graph (STRUCTURALLY, so the shared
|
|
1756
|
+
// RGD shape is identical for every instance) and rewrite any dangling reference
|
|
1757
|
+
// to it. typekro NEVER emits a Namespace into RGD YAML; each is applied as a
|
|
1758
|
+
// sibling (toYaml/toAlchemyResources/imperative deploy), so deleting the instance
|
|
1759
|
+
// can never garbage-collect the namespace holding its own finalizer.
|
|
1760
|
+
const hoistIds = this.hoistedNamespaceRefs(this.resources);
|
|
1761
|
+
const graphResources = this.resourcesForRgd(this.resources);
|
|
1762
|
+
// Rewrite references to a hoisted Namespace in the STATUS mappings too
|
|
1763
|
+
// (finding #6): a status CEL like `ns-${string(ownedNamespace.metadata.name)}`
|
|
1764
|
+
// would otherwise reference a resource removed from the graph and KRO would
|
|
1765
|
+
// reject the RGD. Each becomes the referenced Namespace's own name expression.
|
|
1766
|
+
const statusMappings = rewriteHoistedNamespaceRefsInValue(this.statusMappings, hoistIds);
|
|
1767
|
+
const nestedCel = rewriteHoistedNamespaceRefsInValue(this.getNestedStatusCel(), hoistIds);
|
|
1768
|
+
// #6 — REJECT (throw) HONESTLY, never silently drop: a status field whose only
|
|
1769
|
+
// reference was a hoisted Namespace becomes a schema-only expression
|
|
1770
|
+
// (`schema.spec.namespace`), which KRO status CEL cannot evaluate, so it cannot be
|
|
1771
|
+
// represented in the KRO status schema. Rather than ship a status API weaker than
|
|
1772
|
+
// the declared one (the old warn-and-drop), fail loudly naming the field(s).
|
|
1773
|
+
// Resource-derived sibling fields are unaffected.
|
|
1774
|
+
assertNoHoistWeakenedStatusFields(this.statusMappings, hoistIds, this.name);
|
|
1775
|
+
const aspectResources = applyAspects(graphResources, {
|
|
1158
1776
|
mode: 'kro',
|
|
1159
1777
|
aspects: this.factoryOptions.aspects ?? [],
|
|
1160
1778
|
});
|
|
@@ -1164,10 +1782,9 @@ export class KroResourceFactoryImpl {
|
|
|
1164
1782
|
if (isStrictCelDiagnosticsEnabled(this.factoryOptions)) {
|
|
1165
1783
|
this.assertStatusCelReferencesKnownResources(aspectResources);
|
|
1166
1784
|
}
|
|
1167
|
-
const kroSchema = generateKroSchemaFromArktype(this.name, this.schemaDefinition, aspectResources,
|
|
1785
|
+
const kroSchema = generateKroSchemaFromArktype(this.name, this.schemaDefinition, aspectResources, statusMappings, nestedCel);
|
|
1168
1786
|
// Attach nested status CEL mappings as non-enumerable property so
|
|
1169
1787
|
// serializeResourceGraphToYaml can inline virtual composition IDs.
|
|
1170
|
-
const nestedCel = this.getNestedStatusCel();
|
|
1171
1788
|
if (nestedCel && Object.keys(nestedCel).length > 0) {
|
|
1172
1789
|
Object.defineProperty(kroSchema, '__nestedStatusCel', {
|
|
1173
1790
|
value: nestedCel,
|
|
@@ -1181,13 +1798,15 @@ export class KroResourceFactoryImpl {
|
|
|
1181
1798
|
// identifiers: [schema]") and are hydrated client-side instead, like any
|
|
1182
1799
|
// other static status field.
|
|
1183
1800
|
const resourceIdList = Object.keys(aspectResources);
|
|
1184
|
-
const nestedCel = this.getNestedStatusCel();
|
|
1185
1801
|
const nestedCelForClassification = nestedCel && Object.keys(nestedCel).length > 0 ? nestedCel : undefined;
|
|
1186
1802
|
for (const override of statusOverrides) {
|
|
1187
|
-
|
|
1803
|
+
// Rewrite any reference to a hoisted Namespace in the override expression
|
|
1804
|
+
// too (finding #6), so a status override never dangles at the removed id.
|
|
1805
|
+
const celExpression = rewriteHoistedNamespaceRefsInValue(override.celExpression, hoistIds);
|
|
1806
|
+
if (isStaticExpression(celExpression, nestedCelForClassification, resourceIdList))
|
|
1188
1807
|
continue;
|
|
1189
1808
|
kroSchema.status ??= {};
|
|
1190
|
-
const yamlSafe =
|
|
1809
|
+
const yamlSafe = celExpression.replace(/"([^"\\]*)"/g, "'$1'");
|
|
1191
1810
|
kroSchema.status[override.propertyPath] = yamlSafe;
|
|
1192
1811
|
}
|
|
1193
1812
|
}
|
|
@@ -1202,7 +1821,27 @@ export class KroResourceFactoryImpl {
|
|
|
1202
1821
|
applyTernaryConditionalsToResources(aspectResources, kroSchema.__ternaryConditionals, kroSchema.__nestedStatusCel);
|
|
1203
1822
|
}
|
|
1204
1823
|
}
|
|
1205
|
-
|
|
1824
|
+
const rgdYaml = serializeResourceGraphToYaml(this.rgdName, aspectResources, { namespace: this.namespace }, kroSchema);
|
|
1825
|
+
// VALIDATE (finding #6): after hoisting, NO reference to any removed resource id
|
|
1826
|
+
// may remain anywhere in the emitted RGD. If one slipped through (a form the
|
|
1827
|
+
// rewrite didn't structurally cover), fail LOUDLY here rather than shipping an
|
|
1828
|
+
// RGD KRO will reject at runtime with a dangling `${...}` reference.
|
|
1829
|
+
this.assertNoDanglingHoistedReferences(rgdYaml, new Set(hoistIds.keys()));
|
|
1830
|
+
return rgdYaml;
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Assert the emitted RGD carries NO leftover reference to a hoisted (removed)
|
|
1834
|
+
* resource id — neither a CEL interpolation `${<id>.…}` nor a raw
|
|
1835
|
+
* `__KUBERNETES_REF_<id>_…` marker (finding #6). A negative lookbehind avoids
|
|
1836
|
+
* matching an id that is only a suffix of a longer identifier.
|
|
1837
|
+
*/
|
|
1838
|
+
assertNoDanglingHoistedReferences(rgdYaml, hoistIds) {
|
|
1839
|
+
const dangling = findDanglingHoistedReference(rgdYaml, hoistIds);
|
|
1840
|
+
if (dangling !== undefined) {
|
|
1841
|
+
throw new ResourceGraphFactoryError(`Hoisting the owned Namespace left a dangling reference to removed resource "${dangling}" in the ` +
|
|
1842
|
+
'emitted RGD. This would make KRO reject the graph. Report this as a typekro bug (the ' +
|
|
1843
|
+
'reference form was not structurally rewritten).', this.name, 'deployment');
|
|
1844
|
+
}
|
|
1206
1845
|
}
|
|
1207
1846
|
/**
|
|
1208
1847
|
* Strict CEL diagnostics: verify that every dynamic status CEL expression
|
|
@@ -1289,14 +1928,17 @@ export class KroResourceFactoryImpl {
|
|
|
1289
1928
|
}
|
|
1290
1929
|
}
|
|
1291
1930
|
/**
|
|
1292
|
-
* Ensure the ResourceGraphDefinition is deployed using DirectDeploymentEngine
|
|
1931
|
+
* Ensure the ResourceGraphDefinition is deployed using DirectDeploymentEngine.
|
|
1932
|
+
*
|
|
1933
|
+
* The `spec` (when the caller has one) lets the RGD hoist the composition's own
|
|
1934
|
+
* workload Namespace out of the graph with its schema-driven name resolved.
|
|
1293
1935
|
*/
|
|
1294
|
-
async ensureRGDDeployed() {
|
|
1936
|
+
async ensureRGDDeployed(spec) {
|
|
1295
1937
|
// Create DirectDeploymentEngine instance with KRO mode for CEL string generation
|
|
1296
1938
|
const deploymentEngine = new DirectDeploymentEngine(this.getKubeConfig(), undefined, undefined, DeploymentMode.KRO);
|
|
1297
1939
|
// Build the RGD YAML — shared with toYaml() so both call sites emit
|
|
1298
1940
|
// identical post-processed output and share the single-apply guard.
|
|
1299
|
-
const rgdYaml = this.buildRgdYaml();
|
|
1941
|
+
const rgdYaml = this.buildRgdYaml(spec);
|
|
1300
1942
|
// Parse the YAML to get the RGD object. Use js-yaml with JSON_SCHEMA to MATCH the dump schema in
|
|
1301
1943
|
// `serializeResourceGraphToYaml` (same fix as the alchemy path above — this is the imperative/direct
|
|
1302
1944
|
// KRO deploy path). `k8s.loadAllYaml` uses js-yaml's DEFAULT (timestamp-aware) schema, which coerces an
|
|
@@ -1786,35 +2428,273 @@ export class KroResourceFactoryImpl {
|
|
|
1786
2428
|
*/
|
|
1787
2429
|
createCustomResourceInstance(instanceName, spec, singletonSpecFingerprint) {
|
|
1788
2430
|
const apiVersion = this.getInstanceApiVersion();
|
|
2431
|
+
// DURABLE RECORD (finding #4): stamp the CONCRETE hoisted-namespace names on the CR so
|
|
2432
|
+
// teardown + the pre-hoist guard read them back EXACTLY, without re-deriving from
|
|
2433
|
+
// metadata.namespace/spec.namespace. This is what lets a name derived from an ARBITRARY
|
|
2434
|
+
// spec field (e.g. spec.targetNamespace) round-trip. ALWAYS record the array — INCLUDING an
|
|
2435
|
+
// empty [] — so the pre-hoist guard can distinguish "this instance hoists zero namespaces"
|
|
2436
|
+
// (a valid, safe record) from a genuinely-legacy CR that predates the annotation (missing →
|
|
2437
|
+
// fail closed). Omitting [] made an ordinary namespace-less composition's 2nd deploy throw.
|
|
2438
|
+
const hoistedNamespaceNames = [...this.concreteHoistedNamespaces(spec).keys()];
|
|
2439
|
+
const annotations = {
|
|
2440
|
+
...(singletonSpecFingerprint
|
|
2441
|
+
? { 'typekro.io/singleton-spec-fingerprint': singletonSpecFingerprint }
|
|
2442
|
+
: {}),
|
|
2443
|
+
[HOISTED_NAMESPACES_ANNOTATION]: JSON.stringify(hoistedNamespaceNames),
|
|
2444
|
+
};
|
|
1789
2445
|
return {
|
|
1790
2446
|
apiVersion,
|
|
1791
2447
|
kind: this.schemaDefinition.kind,
|
|
1792
2448
|
metadata: {
|
|
1793
2449
|
name: instanceName,
|
|
1794
|
-
namespace: this.
|
|
2450
|
+
namespace: this.resolveInstanceNamespace(spec),
|
|
1795
2451
|
labels: {
|
|
1796
2452
|
'typekro.io/factory': this.name,
|
|
1797
2453
|
'typekro.io/mode': this.mode,
|
|
1798
|
-
|
|
2454
|
+
[INSTANCE_RGD_LABEL]: this.rgdName,
|
|
1799
2455
|
},
|
|
1800
|
-
...(
|
|
2456
|
+
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
|
2457
|
+
},
|
|
2458
|
+
spec,
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
/**
|
|
2462
|
+
* Labels/annotations marking a hoisted workload Namespace as SHARED, RETAINED
|
|
2463
|
+
* infrastructure that must survive any single consumer's teardown/prune.
|
|
2464
|
+
*
|
|
2465
|
+
* Retention is declared for BOTH major GitOps reconcilers, not just Flux, so a
|
|
2466
|
+
* prune (or an Application deletion) by whichever tool manages a consuming app
|
|
2467
|
+
* can never delete this shared namespace (and with it every OTHER stack's
|
|
2468
|
+
* resources living inside it):
|
|
2469
|
+
* - `kustomize.toolkit.fluxcd.io/prune: disabled` — Flux Kustomize.
|
|
2470
|
+
* - `argocd.argoproj.io/sync-options: Prune=false,Delete=false` — Argo CD.
|
|
2471
|
+
* `Delete=false` is what survives an Argo Application DELETION, not merely a
|
|
2472
|
+
* sync-prune (`Prune=false` alone does NOT).
|
|
2473
|
+
* The alchemy path pairs these with a `retain` prop (see
|
|
2474
|
+
* {@link hoistedNamespaceAlchemyDeclaration}) that skips delete. For any other
|
|
2475
|
+
* GitOps tool, pre-create the workload namespace out-of-band.
|
|
2476
|
+
*/
|
|
2477
|
+
static INSTANCE_NAMESPACE_METADATA = {
|
|
2478
|
+
labels: {
|
|
2479
|
+
'app.kubernetes.io/managed-by': 'typekro',
|
|
2480
|
+
'typekro.io/kro-instance-namespace': 'true',
|
|
2481
|
+
},
|
|
2482
|
+
annotations: {
|
|
2483
|
+
'kustomize.toolkit.fluxcd.io/prune': 'disabled',
|
|
2484
|
+
'argocd.argoproj.io/sync-options': 'Prune=false,Delete=false',
|
|
2485
|
+
},
|
|
2486
|
+
};
|
|
2487
|
+
/**
|
|
2488
|
+
* The collision-free singleton id for a hoisted workload Namespace, deduped by
|
|
2489
|
+
* the namespace NAME (finding #7). {@link toCamelCase} alone destroys separators
|
|
2490
|
+
* (`foo-bar` and `foo--bar` both camel-case to `fooBar`), so two DISTINCT
|
|
2491
|
+
* namespaces would collapse to one id — silently deduping unrelated namespaces
|
|
2492
|
+
* into a single retained resource. Appending a stable hash of the RAW name keeps
|
|
2493
|
+
* distinct names distinct while staying deterministic (same name → same id, so
|
|
2494
|
+
* the intended cross-factory dedup still holds).
|
|
2495
|
+
*/
|
|
2496
|
+
static hoistedNamespaceId(workloadNamespace) {
|
|
2497
|
+
return `${toCamelCase(`kro-instance-namespace-${workloadNamespace}`)}${shortStableHash(workloadNamespace)}`;
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Resolve a single Namespace metadata VALUE (a label/annotation value) against
|
|
2501
|
+
* the concrete spec to its final string form (finding #8). Handles:
|
|
2502
|
+
* - plain strings (kept verbatim),
|
|
2503
|
+
* - numbers / booleans (stringified — a "non-string" declared value),
|
|
2504
|
+
* - schema-derived CEL expressions / KubernetesRefs (resolved to the concrete
|
|
2505
|
+
* value; re-execution already collapsed the schema ref, but this evaluates
|
|
2506
|
+
* it against the spec for robustness).
|
|
2507
|
+
* Returns `undefined` for a value that cannot be resolved to a string, so the
|
|
2508
|
+
* caller drops only genuinely-unresolvable entries rather than the whole class of
|
|
2509
|
+
* non-string / schema-derived metadata (the old string-only filter dropped them).
|
|
2510
|
+
*/
|
|
2511
|
+
static resolveMetadataStringValue(value, spec) {
|
|
2512
|
+
// Metadata VALUES are unrestricted strings (finding #5): `Team_A`, free text with
|
|
2513
|
+
// spaces, etc. — resolve any CONCRETE value, without the DNS-label shape
|
|
2514
|
+
// restriction that `resolveNamespaceName` correctly applies to namespace NAMES.
|
|
2515
|
+
return resolveConcreteMetadataValue(value, spec);
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* Merge the SHARED/RETAINED marker labels + annotations onto an ORIGINAL,
|
|
2519
|
+
* fully-concrete Namespace resource, PRESERVING the COMPLETE declared config
|
|
2520
|
+
* (findings #6 + #8): ALL labels — including Pod Security (`pod-security.kubernetes.io/*`)
|
|
2521
|
+
* AND non-string / schema-derived labels resolved against the spec — ALL
|
|
2522
|
+
* annotations, the remaining declarative ObjectMeta (`finalizers`,
|
|
2523
|
+
* `ownerReferences`), AND the Namespace `spec` (e.g. `spec.finalizers`). The
|
|
2524
|
+
* retention markers are added on top.
|
|
2525
|
+
*/
|
|
2526
|
+
static mergedHoistedNamespaceMetadata(workloadNamespace, original, spec) {
|
|
2527
|
+
const originalMeta = (original.metadata ?? {});
|
|
2528
|
+
const resolveMap = (value) => {
|
|
2529
|
+
const out = {};
|
|
2530
|
+
for (const [key, val] of Object.entries(value ?? {})) {
|
|
2531
|
+
const resolved = KroResourceFactoryImpl.resolveMetadataStringValue(val, spec);
|
|
2532
|
+
if (resolved !== undefined)
|
|
2533
|
+
out[key] = resolved;
|
|
2534
|
+
}
|
|
2535
|
+
return out;
|
|
2536
|
+
};
|
|
2537
|
+
// `original` is an Enhanced PROXY: reading an UNDECLARED field returns a
|
|
2538
|
+
// KubernetesRef marker (a truthy function-like proxy), NOT undefined. Passing
|
|
2539
|
+
// such a proxy through to the k8s client serializer makes it try to iterate a
|
|
2540
|
+
// `{}`-looking value for an array-typed attribute (e.g. `spec.finalizers`) and
|
|
2541
|
+
// throw "{} is not iterable". So extract only the CONCRETE, own-enumerable
|
|
2542
|
+
// declared values via a JSON round-trip (drops proxies, ref-markers, symbols,
|
|
2543
|
+
// functions) and omit anything empty.
|
|
2544
|
+
const concretize = (value) => {
|
|
2545
|
+
if (value === undefined || value === null)
|
|
2546
|
+
return undefined;
|
|
2547
|
+
try {
|
|
2548
|
+
const cloned = JSON.parse(JSON.stringify(value));
|
|
2549
|
+
return cloned;
|
|
2550
|
+
}
|
|
2551
|
+
catch {
|
|
2552
|
+
return undefined;
|
|
2553
|
+
}
|
|
2554
|
+
};
|
|
2555
|
+
const concreteSpecRaw = concretize(original.spec);
|
|
2556
|
+
// Only keep a spec that actually declares something (an empty proxy spec must
|
|
2557
|
+
// NOT be emitted — that is the "{} is not iterable" trap).
|
|
2558
|
+
const originalSpec = concreteSpecRaw !== undefined && Object.keys(concreteSpecRaw).length > 0
|
|
2559
|
+
? concreteSpecRaw
|
|
2560
|
+
: undefined;
|
|
2561
|
+
// Preserve the remaining declarative ObjectMeta the user may have set on the
|
|
2562
|
+
// owned Namespace (finding #6): metadata.finalizers + metadata.ownerReferences.
|
|
2563
|
+
// Server-set fields (uid/resourceVersion/managedFields/…) are intentionally not
|
|
2564
|
+
// carried — only declared configuration is retained. Array.isArray guards against
|
|
2565
|
+
// the proxy's ref-markers for undeclared fields (which are functions, not arrays).
|
|
2566
|
+
const finalizers = Array.isArray(originalMeta.finalizers)
|
|
2567
|
+
? (concretize(originalMeta.finalizers) ?? []).map((f) => String(f))
|
|
2568
|
+
: undefined;
|
|
2569
|
+
const ownerReferences = Array.isArray(originalMeta.ownerReferences)
|
|
2570
|
+
? concretize(originalMeta.ownerReferences)
|
|
2571
|
+
: undefined;
|
|
2572
|
+
return {
|
|
2573
|
+
name: workloadNamespace,
|
|
2574
|
+
labels: {
|
|
2575
|
+
...resolveMap(originalMeta.labels),
|
|
2576
|
+
...KroResourceFactoryImpl.INSTANCE_NAMESPACE_METADATA.labels,
|
|
2577
|
+
},
|
|
2578
|
+
annotations: {
|
|
2579
|
+
...resolveMap(originalMeta.annotations),
|
|
2580
|
+
...KroResourceFactoryImpl.INSTANCE_NAMESPACE_METADATA.annotations,
|
|
2581
|
+
},
|
|
2582
|
+
...(finalizers !== undefined && finalizers.length > 0 ? { finalizers } : {}),
|
|
2583
|
+
...(ownerReferences !== undefined && ownerReferences.length > 0 ? { ownerReferences } : {}),
|
|
2584
|
+
...(originalSpec !== undefined && originalSpec !== null ? { spec: originalSpec } : {}),
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* A workload Namespace this composition owns, HOISTED out of the RGD graph and
|
|
2589
|
+
* emitted as a retained resource instead. It is created OUTSIDE the KRO graph —
|
|
2590
|
+
* deps-first in GitOps/alchemy, and applied directly in the imperative path — so
|
|
2591
|
+
* KRO never garbage-collects it and the instance's finalizer can never be
|
|
2592
|
+
* stranded by its own namespace terminating.
|
|
2593
|
+
*
|
|
2594
|
+
* The COMPLETE original Namespace configuration is preserved (finding #5) — see
|
|
2595
|
+
* {@link mergedHoistedNamespaceMetadata}. It is a SHARED SINGLETON deduped by the
|
|
2596
|
+
* namespace NAME (collision-free id, see {@link hoistedNamespaceId}), so every
|
|
2597
|
+
* consumer/stack targeting the SAME workload namespace emits the SAME declaration
|
|
2598
|
+
* — alchemy dedupes them to one retained owner rather than N fighting copies.
|
|
2599
|
+
*/
|
|
2600
|
+
buildHoistedNamespaceResource(workloadNamespace, original, spec) {
|
|
2601
|
+
const merged = KroResourceFactoryImpl.mergedHoistedNamespaceMetadata(workloadNamespace, original, spec);
|
|
2602
|
+
return namespaceResource({
|
|
2603
|
+
metadata: {
|
|
2604
|
+
name: merged.name,
|
|
2605
|
+
labels: merged.labels,
|
|
2606
|
+
// OWNERSHIP RECORD (findings #4b + #2): the declarative alchemy path stamps this
|
|
2607
|
+
// namespace as owned by THIS composition's RGD (the annotation the empty-gated
|
|
2608
|
+
// teardown checks). This build step is cluster-free, so it cannot yet distinguish
|
|
2609
|
+
// create-vs-adopt — that is resolved at DEPLOY time by
|
|
2610
|
+
// `_preserveHoistedNamespaceAdoption` (resource-registration.ts), which reads the
|
|
2611
|
+
// live namespace and STRIPS this stamp when adopting a pre-existing namespace, so
|
|
2612
|
+
// teardown never deletes a namespace typekro merely adopted. Ownership is the
|
|
2613
|
+
// PRIMARY teardown guard; the empty-gate is the secondary one.
|
|
2614
|
+
annotations: { ...merged.annotations, [NAMESPACE_OWNER_ANNOTATION]: this.rgdName },
|
|
2615
|
+
...(merged.finalizers !== undefined ? { finalizers: merged.finalizers } : {}),
|
|
2616
|
+
...(merged.ownerReferences !== undefined
|
|
1801
2617
|
? {
|
|
1802
|
-
|
|
1803
|
-
'typekro.io/singleton-spec-fingerprint': singletonSpecFingerprint,
|
|
1804
|
-
},
|
|
2618
|
+
ownerReferences: merged.ownerReferences,
|
|
1805
2619
|
}
|
|
1806
2620
|
: {}),
|
|
1807
2621
|
},
|
|
1808
|
-
spec
|
|
2622
|
+
...(merged.spec !== undefined
|
|
2623
|
+
? { spec: merged.spec }
|
|
2624
|
+
: {}),
|
|
2625
|
+
id: KroResourceFactoryImpl.hoistedNamespaceId(workloadNamespace),
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
hoistedNamespaceAlchemyDeclaration(workloadNamespace, nsEnhanced, kubeConfigOptions, dependsOn) {
|
|
2629
|
+
const timeout = this.factoryOptions.timeout;
|
|
2630
|
+
const resourceId = nsEnhanced.id;
|
|
2631
|
+
return {
|
|
2632
|
+
// Collision-free (finding #7) and cluster-scoped (no namespace segment):
|
|
2633
|
+
// distinct workload namespaces → distinct ids; same name → same id (dedup).
|
|
2634
|
+
id: KroResourceFactoryImpl.hoistedNamespaceId(workloadNamespace),
|
|
2635
|
+
dependsOn,
|
|
2636
|
+
props: {
|
|
2637
|
+
resource: nsEnhanced,
|
|
2638
|
+
...(resourceId !== undefined ? { resourceId } : {}),
|
|
2639
|
+
namespace: workloadNamespace,
|
|
2640
|
+
deploymentStrategy: 'direct',
|
|
2641
|
+
// EMPTY-GATED teardown (findings #3 + #4), replacing the old retain-by-name
|
|
2642
|
+
// distinction: on delete, the namespace is removed ONLY if empty and RETAINED
|
|
2643
|
+
// if another stack/user still has resources inside it. Alchemy's reverse-topo
|
|
2644
|
+
// teardown runs this AFTER the RGD + instance (both `dependsOn` this
|
|
2645
|
+
// declaration) are gone — the load-bearing delete-after-RGD ordering (a
|
|
2646
|
+
// namespace deleted while the CR is still inside it re-creates the finalizer
|
|
2647
|
+
// deadlock).
|
|
2648
|
+
namespaceEmptyGate: true,
|
|
2649
|
+
// Ownership record for the empty-gated delete (finding #4): only a namespace
|
|
2650
|
+
// carrying this RGD's `created-by-rgd` annotation is a candidate.
|
|
2651
|
+
namespaceOwnerRgd: this.rgdName,
|
|
2652
|
+
// CRD coordinates so the alchemy pre-hoist check enumerates EVERY existing
|
|
2653
|
+
// instance of this shared RGD (finding #7), not just the incoming namespace.
|
|
2654
|
+
namespacePreHoistQuery: {
|
|
2655
|
+
group: this.getSchemaGroup(),
|
|
2656
|
+
version: this.getSchemaVersion(),
|
|
2657
|
+
kind: this.schemaDefinition.kind,
|
|
2658
|
+
},
|
|
2659
|
+
kubeConfigOptions,
|
|
2660
|
+
options: {
|
|
2661
|
+
waitForReady: false,
|
|
2662
|
+
...(timeout !== undefined && { timeout }),
|
|
2663
|
+
},
|
|
2664
|
+
},
|
|
1809
2665
|
};
|
|
1810
2666
|
}
|
|
2667
|
+
hoistedNamespaceYaml(workloadNamespace, original, spec) {
|
|
2668
|
+
const merged = KroResourceFactoryImpl.mergedHoistedNamespaceMetadata(workloadNamespace, original, spec);
|
|
2669
|
+
return yaml
|
|
2670
|
+
.dump({
|
|
2671
|
+
apiVersion: 'v1',
|
|
2672
|
+
kind: 'Namespace',
|
|
2673
|
+
metadata: {
|
|
2674
|
+
name: merged.name,
|
|
2675
|
+
labels: merged.labels,
|
|
2676
|
+
annotations: merged.annotations,
|
|
2677
|
+
...(merged.finalizers !== undefined ? { finalizers: merged.finalizers } : {}),
|
|
2678
|
+
...(merged.ownerReferences !== undefined
|
|
2679
|
+
? { ownerReferences: merged.ownerReferences }
|
|
2680
|
+
: {}),
|
|
2681
|
+
},
|
|
2682
|
+
...(merged.spec !== undefined ? { spec: merged.spec } : {}),
|
|
2683
|
+
}, { lineWidth: -1, noRefs: true, sortKeys: false })
|
|
2684
|
+
.trimEnd();
|
|
2685
|
+
}
|
|
1811
2686
|
assertInstanceNamespaceOwnershipSafe(spec) {
|
|
1812
2687
|
const compositionFn = this.factoryOptions.compositionFn;
|
|
1813
2688
|
assertKroInstanceNamespaceOwnershipSafe({
|
|
1814
2689
|
compositionName: this.name,
|
|
1815
|
-
instanceNamespace: this.
|
|
2690
|
+
instanceNamespace: this.resolveInstanceNamespace(spec),
|
|
1816
2691
|
spec,
|
|
1817
2692
|
resources: this.resources,
|
|
2693
|
+
// The owned workload namespace is hoisted out of the graph (created retained,
|
|
2694
|
+
// deps-first), so the guard treats it as safe. Everything not hoisted — an
|
|
2695
|
+
// unprovable owned name, or an EXPLICIT `instanceNamespace` pin back onto an
|
|
2696
|
+
// owned namespace — still throws.
|
|
2697
|
+
hoistedNamespaces: this.hoistedNamespaceNames(spec),
|
|
1818
2698
|
...(compositionFn ? { compositionFn } : {}),
|
|
1819
2699
|
});
|
|
1820
2700
|
}
|
|
@@ -1845,11 +2725,11 @@ export class KroResourceFactoryImpl {
|
|
|
1845
2725
|
status,
|
|
1846
2726
|
metadata: {
|
|
1847
2727
|
name: instanceName,
|
|
1848
|
-
namespace: this.
|
|
2728
|
+
namespace: this.resolveInstanceNamespace(spec),
|
|
1849
2729
|
labels: {
|
|
1850
2730
|
'typekro.io/factory': this.name,
|
|
1851
2731
|
'typekro.io/mode': this.mode,
|
|
1852
|
-
|
|
2732
|
+
[INSTANCE_RGD_LABEL]: this.rgdName,
|
|
1853
2733
|
},
|
|
1854
2734
|
annotations: {
|
|
1855
2735
|
'typekro.io/deployed-at': new Date().toISOString(),
|
|
@@ -1864,7 +2744,7 @@ export class KroResourceFactoryImpl {
|
|
|
1864
2744
|
// Hydrate dynamic status fields if enabled and there are dynamic fields
|
|
1865
2745
|
if (this.factoryOptions.hydrateStatus !== false && Object.keys(dynamicFields).length > 0) {
|
|
1866
2746
|
try {
|
|
1867
|
-
const hydratedDynamicFields = await this.hydrateDynamicStatusFields(instanceName, dynamicFields);
|
|
2747
|
+
const hydratedDynamicFields = await this.hydrateDynamicStatusFields(instanceName, dynamicFields, this.resolveInstanceNamespace(spec));
|
|
1868
2748
|
// Merge evaluated static fields with dynamic fields from KRO.
|
|
1869
2749
|
// Use evaluatedStaticFields (resolved markers) not raw staticFields.
|
|
1870
2750
|
const mergedStatus = {
|
|
@@ -2045,7 +2925,7 @@ export class KroResourceFactoryImpl {
|
|
|
2045
2925
|
* Wait for Kro instance to be ready with Kro-specific logic.
|
|
2046
2926
|
* Delegates to the shared `waitForKroInstanceReady` in `kro-readiness.ts`.
|
|
2047
2927
|
*/
|
|
2048
|
-
async waitForKroInstanceReady(instanceName, timeout) {
|
|
2928
|
+
async waitForKroInstanceReady(instanceName, timeout, instanceNamespace = this.resolveInstanceNamespace()) {
|
|
2049
2929
|
const apiVersion = this.getInstanceApiVersion();
|
|
2050
2930
|
const kubeConfig = this.getKubeConfig();
|
|
2051
2931
|
const k8sApi = createBunCompatibleKubernetesObjectApi(kubeConfig);
|
|
@@ -2054,7 +2934,7 @@ export class KroResourceFactoryImpl {
|
|
|
2054
2934
|
timeout,
|
|
2055
2935
|
k8sApi,
|
|
2056
2936
|
customObjectsApi: this.getCustomObjectsApi(),
|
|
2057
|
-
namespace:
|
|
2937
|
+
namespace: instanceNamespace,
|
|
2058
2938
|
apiVersion,
|
|
2059
2939
|
kind: this.schemaDefinition.kind,
|
|
2060
2940
|
rgdName: this.rgdName,
|
|
@@ -2064,7 +2944,7 @@ export class KroResourceFactoryImpl {
|
|
|
2064
2944
|
/**
|
|
2065
2945
|
* Hydrate dynamic status fields by evaluating CEL expressions against live Kro resource data
|
|
2066
2946
|
*/
|
|
2067
|
-
async hydrateDynamicStatusFields(instanceName, dynamicFields) {
|
|
2947
|
+
async hydrateDynamicStatusFields(instanceName, dynamicFields, instanceNamespace = this.resolveInstanceNamespace()) {
|
|
2068
2948
|
const dynamicLogger = this.logger.child({ instanceName });
|
|
2069
2949
|
// Get the live custom resource to extract dynamic status fields
|
|
2070
2950
|
const apiVersion = this.getInstanceApiVersion();
|
|
@@ -2075,7 +2955,7 @@ export class KroResourceFactoryImpl {
|
|
|
2075
2955
|
kind: this.schemaDefinition.kind,
|
|
2076
2956
|
metadata: {
|
|
2077
2957
|
name: instanceName,
|
|
2078
|
-
namespace:
|
|
2958
|
+
namespace: instanceNamespace,
|
|
2079
2959
|
},
|
|
2080
2960
|
});
|
|
2081
2961
|
// In the new API, methods return objects directly (no .body wrapper)
|