typekro 0.41.1 → 0.43.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.
Files changed (99) hide show
  1. package/dist/alchemy/deployers.d.ts.map +1 -1
  2. package/dist/alchemy/deployers.js +2 -1
  3. package/dist/alchemy/deployers.js.map +1 -1
  4. package/dist/alchemy/index.d.ts +5 -3
  5. package/dist/alchemy/index.d.ts.map +1 -1
  6. package/dist/alchemy/index.js +3 -1
  7. package/dist/alchemy/index.js.map +1 -1
  8. package/dist/alchemy/kubernetes-effect-gate.d.ts +24 -0
  9. package/dist/alchemy/kubernetes-effect-gate.d.ts.map +1 -0
  10. package/dist/alchemy/kubernetes-effect-gate.js +112 -0
  11. package/dist/alchemy/kubernetes-effect-gate.js.map +1 -0
  12. package/dist/alchemy/resource-registration.d.ts +29 -3
  13. package/dist/alchemy/resource-registration.d.ts.map +1 -1
  14. package/dist/alchemy/resource-registration.js +278 -92
  15. package/dist/alchemy/resource-registration.js.map +1 -1
  16. package/dist/alchemy/types.d.ts +13 -0
  17. package/dist/alchemy/types.d.ts.map +1 -1
  18. package/dist/core/dependencies/resolver.js +2 -2
  19. package/dist/core/dependencies/resolver.js.map +1 -1
  20. package/dist/core/deployment/direct-factory.d.ts +1 -0
  21. package/dist/core/deployment/direct-factory.d.ts.map +1 -1
  22. package/dist/core/deployment/direct-factory.js +22 -5
  23. package/dist/core/deployment/direct-factory.js.map +1 -1
  24. package/dist/core/deployment/engine.d.ts +2 -143
  25. package/dist/core/deployment/engine.d.ts.map +1 -1
  26. package/dist/core/deployment/engine.js +23 -20
  27. package/dist/core/deployment/engine.js.map +1 -1
  28. package/dist/core/deployment/kro-namespace-teardown.d.ts +2 -0
  29. package/dist/core/deployment/kro-namespace-teardown.d.ts.map +1 -1
  30. package/dist/core/deployment/kro-namespace-teardown.js +3 -0
  31. package/dist/core/deployment/kro-namespace-teardown.js.map +1 -1
  32. package/dist/core/deployment/resource-applier.d.ts +2 -2
  33. package/dist/core/deployment/resource-applier.d.ts.map +1 -1
  34. package/dist/core/deployment/resource-applier.js +3 -16
  35. package/dist/core/deployment/resource-applier.js.map +1 -1
  36. package/dist/core/deployment/rollback-manager.d.ts +2 -1
  37. package/dist/core/deployment/rollback-manager.d.ts.map +1 -1
  38. package/dist/core/deployment/rollback-manager.js +54 -5
  39. package/dist/core/deployment/rollback-manager.js.map +1 -1
  40. package/dist/core/kubernetes/bun-api-client.d.ts.map +1 -1
  41. package/dist/core/kubernetes/bun-api-client.js +22 -2
  42. package/dist/core/kubernetes/bun-api-client.js.map +1 -1
  43. package/dist/core/metadata/resource-metadata.d.ts +3 -0
  44. package/dist/core/metadata/resource-metadata.d.ts.map +1 -1
  45. package/dist/core/metadata/resource-metadata.js.map +1 -1
  46. package/dist/core/planning/artifacts.d.ts +2 -0
  47. package/dist/core/planning/artifacts.d.ts.map +1 -1
  48. package/dist/core/planning/direct-runtime-adapter.d.ts +1 -0
  49. package/dist/core/planning/direct-runtime-adapter.d.ts.map +1 -1
  50. package/dist/core/planning/direct-runtime-adapter.js +16 -19
  51. package/dist/core/planning/direct-runtime-adapter.js.map +1 -1
  52. package/dist/core/planning/execution-record.d.ts +2 -0
  53. package/dist/core/planning/execution-record.d.ts.map +1 -1
  54. package/dist/core/planning/execution-record.js +71 -15
  55. package/dist/core/planning/execution-record.js.map +1 -1
  56. package/dist/core/planning/planner.d.ts.map +1 -1
  57. package/dist/core/planning/planner.js +7 -3
  58. package/dist/core/planning/planner.js.map +1 -1
  59. package/dist/core/references/cel-evaluator.js +2 -2
  60. package/dist/core/references/cel-evaluator.js.map +1 -1
  61. package/dist/core/references/external-refs.d.ts.map +1 -1
  62. package/dist/core/references/external-refs.js +5 -0
  63. package/dist/core/references/external-refs.js.map +1 -1
  64. package/dist/core/references/resolver.d.ts.map +1 -1
  65. package/dist/core/references/resolver.js +0 -2
  66. package/dist/core/references/resolver.js.map +1 -1
  67. package/dist/core/types/deployment.d.ts +30 -0
  68. package/dist/core/types/deployment.d.ts.map +1 -1
  69. package/dist/factories/clickstack/types.d.ts +20 -5
  70. package/dist/factories/clickstack/types.d.ts.map +1 -1
  71. package/dist/factories/clickstack/types.js.map +1 -1
  72. package/dist/factories/clickstack/utils/storage.d.ts +3 -1
  73. package/dist/factories/clickstack/utils/storage.d.ts.map +1 -1
  74. package/dist/factories/clickstack/utils/storage.js +279 -5
  75. package/dist/factories/clickstack/utils/storage.js.map +1 -1
  76. package/dist/factories/harbor/compositions/harbor-installation.d.ts.map +1 -1
  77. package/dist/factories/harbor/compositions/harbor-installation.js +29 -12
  78. package/dist/factories/harbor/compositions/harbor-installation.js.map +1 -1
  79. package/dist/factories/harbor/compositions/harbor-rook-s3-credentials.d.ts +27 -0
  80. package/dist/factories/harbor/compositions/harbor-rook-s3-credentials.d.ts.map +1 -0
  81. package/dist/factories/harbor/compositions/harbor-rook-s3-credentials.js +56 -0
  82. package/dist/factories/harbor/compositions/harbor-rook-s3-credentials.js.map +1 -0
  83. package/dist/factories/harbor/compositions/index.d.ts +1 -0
  84. package/dist/factories/harbor/compositions/index.d.ts.map +1 -1
  85. package/dist/factories/harbor/compositions/index.js +1 -0
  86. package/dist/factories/harbor/compositions/index.js.map +1 -1
  87. package/dist/factories/harbor/provider/rook-s3-binding.d.ts +4 -0
  88. package/dist/factories/harbor/provider/rook-s3-binding.d.ts.map +1 -1
  89. package/dist/factories/harbor/provider/rook-s3-binding.js +16 -6
  90. package/dist/factories/harbor/provider/rook-s3-binding.js.map +1 -1
  91. package/dist/factories/helm/readiness-evaluators.d.ts.map +1 -1
  92. package/dist/factories/helm/readiness-evaluators.js +14 -8
  93. package/dist/factories/helm/readiness-evaluators.js.map +1 -1
  94. package/dist/factories/kubernetes/workloads/job.d.ts +1 -1
  95. package/dist/factories/kubernetes/workloads/job.d.ts.map +1 -1
  96. package/dist/factories/kubernetes/workloads/job.js +14 -2
  97. package/dist/factories/kubernetes/workloads/job.js.map +1 -1
  98. package/package.json +8 -5
  99. package/dist/.tsbuildinfo +0 -1
@@ -24,22 +24,23 @@ import { DEFAULT_DEPLOYMENT_TIMEOUT } from '../core/config/defaults.js';
24
24
  import { CEL_EXPRESSION_BRAND } from '../core/constants/brands.js';
25
25
  import { ResourceReplacementTimeoutError } from '../core/deployment/errors.js';
26
26
  import { isNotFoundError } from '../core/deployment/k8s-helpers.js';
27
- import { callDeadlineBudget, isRequestTimeoutError, retryOnceOnRequestTimeout, usesExecCredential, withCallDeadline, } from '../core/deployment/poll-timeout.js';
28
27
  import { migrateLegacyKroArtifactBindingCrd, repairRetainedKroGeneratedCrdOwnership, } from '../core/deployment/kro-artifact-binding-migration.js';
29
28
  import { decideNamespaceOwnershipCreateFirst, deleteNamespaceIfEmpty, HOISTED_NAMESPACES_ANNOTATION, listNamespacesOwnedByRgd, NAMESPACE_OWNER_ANNOTATION, readHoistedNamespacesRecord, } from '../core/deployment/kro-namespace-teardown.js';
29
+ import { callDeadlineBudget, isRequestTimeoutError, retryOnceOnRequestTimeout, usesExecCredential, withCallDeadline, } from '../core/deployment/poll-timeout.js';
30
30
  import { SINGLETON_SPEC_FINGERPRINT_ANNOTATION } from '../core/deployment/resource-tagging.js';
31
31
  import { materializeSerializableKubeConfigOptions } from '../core/deployment/shared-utilities.js';
32
32
  import { singletonDriftVerdict, } from '../core/deployment/singleton-owner-drift.js';
33
- import { ensureError } from '../core/errors.js';
33
+ import { ensureError, TypeKroError } from '../core/errors.js';
34
34
  import { createKubernetesClientProvider } from '../core/kubernetes/client-provider.js';
35
35
  import { createBunCompatibleCustomObjectsApi, createBunCompatibleKubernetesObjectApi, } from '../core/kubernetes/index.js';
36
36
  import { getComponentLogger } from '../core/logging/index.js';
37
- import { copyResourceMetadata, getReadinessEvaluator, getResourceScope, setMetadataField, setReadinessEvaluator, setResourceId, } from '../core/metadata/index.js';
37
+ import { copyResourceMetadata, getMetadataField, getReadinessEvaluator, getResourceScope, setMetadataField, setReadinessEvaluator, setResourceId, } from '../core/metadata/index.js';
38
38
  import { collectArtifactOutputUses, decodeDirectArtifactExecutionRecord, decodeKroArtifactBundle, materializeDirectArtifactManifest, materializeKroArtifactBundleOperation, planValueSensitiveBindingNames, } from '../core/planning/index.js';
39
39
  import { resolvePortableReadinessStrategy } from '../core/readiness/portable-strategies.js';
40
- import { DirectTypeKroDeployer, KroTypeKroDeployer, ResourceGraphDefinitionDeletionDeferredError, } from './deployers.js';
41
- import { deleteKroDefinition, deleteKroInstanceFinalizerSafe, decideKroRgdDeletion, } from './kro-delete.js';
42
40
  import { setOwnProperty } from '../shared/own-property.js';
41
+ import { DirectTypeKroDeployer, KroTypeKroDeployer, ResourceGraphDefinitionDeletionDeferredError, } from './deployers.js';
42
+ import { decideKroRgdDeletion, deleteKroDefinition, deleteKroInstanceFinalizerSafe, } from './kro-delete.js';
43
+ import { guardKubernetesObjectApi, } from './kubernetes-effect-gate.js';
43
44
  /** The single alchemy v2 resource type for any TypeKro KRO resource (RGD or CR instance). */
44
45
  export const KRO_RESOURCE_TYPE = 'TypeKro.KroResource';
45
46
  /**
@@ -124,78 +125,138 @@ function shouldReplaceKroResourceIdentity(_olds, news, output) {
124
125
  }
125
126
  /** Test hook for identity-stable replacement decisions with unresolved sibling inputs. */
126
127
  export const shouldReplaceKroResourceIdentityForTest = shouldReplaceKroResourceIdentity;
127
- /**
128
- * The provider `Layer` that backs {@link KroResource}. Merge into the runtime's providers
129
- * (alongside the cloud providers) so reconcile/delete run. `reconcile` is the single
130
- * convergent create/update (apply the manifest, wait for readiness); `delete` performs the
131
- * finalizer-safe, shared-RGD-aware teardown.
132
- */
133
- export const kroProvider = ProviderMod.effect(KroResource,
134
- // Typed so the service literal is checked directly against `ProviderService` (methods bivariant) —
135
- // avoids the exactOptionalPropertyTypes friction of an inferred literal while keeping the effect-hosted
136
- // registration the conformance boundary requires.
137
- Effect.succeed({
138
- // `namespace` is identity-stable: a namespace change is a replacement, not an in-place update.
139
- stables: ['namespace'],
140
- // Account-wide enumeration (powers `alchemy nuke`). A generic KRO resource isn't discoverable
141
- // cluster-wide from props alone — TypeKro manages teardown through its own `delete` lifecycle —
142
- // so this reports nothing to nuke rather than guessing (required by Alchemy's ProviderService).
143
- list: () => Effect.succeed([]),
144
- diff: Effect.fn(function* ({ olds, news, output }) {
145
- if (shouldReplaceKroResourceIdentity(olds, news, output)) {
146
- return { action: 'replace' };
147
- }
148
- return yield* Effect.tryPromise({
149
- try: (abortSignal) => detectKroResourceIdentityDrift(olds, output, undefined, abortSignal),
150
- catch: ensureError,
151
- });
152
- }),
153
- reconcile: Effect.fn(function* ({ news, output }) {
154
- return yield* Effect.tryPromise({
155
- try: async (abortSignal) => {
156
- const persistedIdentity = persistedKroResourceIdentity(output);
157
- const desiredIdentity = desiredKroResourceIdentity(news, output);
158
- if (persistedIdentity &&
159
- desiredIdentity &&
160
- desiredIdentity !== UNRESOLVED_IDENTITY &&
161
- !sameKroResourceIdentity(persistedIdentity, desiredIdentity)) {
162
- const previous = propsFromOutput(output);
163
- if (!previous) {
164
- throw new Error(`Alchemy cannot replace prior Kubernetes identity ${persistedIdentity.apiVersion}/${persistedIdentity.kind} ` +
165
- `${persistedIdentity.metadata.namespace ? `${persistedIdentity.metadata.namespace}/` : ''}` +
166
- `${persistedIdentity.metadata.name}: persisted delete properties are unavailable`);
167
- }
168
- await deleteKroResource(previous, abortSignal);
128
+ function hooksForResource(hooks, id) {
129
+ const beforeEffect = hooks.beforeKubernetesEffect;
130
+ const guarded = hooks.guardsResource?.(id);
131
+ if (!beforeEffect && guarded !== true)
132
+ return hooks;
133
+ if (guarded === false && hooks.observesResource?.(id) !== true) {
134
+ const { beforeKubernetesEffect: _unused, ...remaining } = hooks;
135
+ return remaining;
136
+ }
137
+ return {
138
+ ...hooks,
139
+ beforeKubernetesEffect: async (props, mutation) => {
140
+ const decision = await beforeEffect?.call(hooks, props, mutation, { id });
141
+ if (guarded === true && !decision) {
142
+ throw new Error(`Guarded resource '${id}' received no Kubernetes effect authority.`);
143
+ }
144
+ return decision;
145
+ },
146
+ };
147
+ }
148
+ /** Bind operation-scoped Kubernetes effect gates without persisting callback closures in Alchemy state. */
149
+ export function kroProviderWithHooks(hooks = {}) {
150
+ return ProviderMod.effect(KroResource,
151
+ // Typed so the service literal is checked directly against `ProviderService` (methods bivariant) —
152
+ // avoids the exactOptionalPropertyTypes friction of an inferred literal while keeping the effect-hosted
153
+ // registration the conformance boundary requires.
154
+ Effect.succeed({
155
+ // `namespace` is identity-stable: a namespace change is a replacement, not an in-place update.
156
+ stables: ['namespace'],
157
+ // Account-wide enumeration (powers `alchemy nuke`). A generic KRO resource isn't discoverable
158
+ // cluster-wide from props alone — TypeKro manages teardown through its own `delete` lifecycle —
159
+ // so this reports nothing to nuke rather than guessing (required by Alchemy's ProviderService).
160
+ list: () => Effect.succeed([]),
161
+ diff: Effect.fn(function* ({ id, olds, news, output }) {
162
+ if (shouldReplaceKroResourceIdentity(olds, news, output)) {
163
+ // A gated replacement needs reconcile to deploy the new identity
164
+ // successfully before it deletes the persisted identity. Alchemy's
165
+ // replace action would run teardown before reconcile can do so.
166
+ // KRO mode does not use the direct object API gate and keeps
167
+ // Alchemy's normal create-before-delete replacement behavior.
168
+ const strategy = typeof news === 'object' && news !== null && 'deploymentStrategy' in news
169
+ ? Reflect.get(news, 'deploymentStrategy')
170
+ : undefined;
171
+ if (hooks.beforeKubernetesEffect &&
172
+ hooks.guardsResource?.(id) !== false &&
173
+ strategy !== 'kro') {
174
+ return { action: 'update' };
169
175
  }
170
- await waitForPersistedIdentityDeletion(news, output, abortSignal);
171
- return deployKroResource(news, abortSignal);
172
- },
173
- catch: ensureError,
174
- });
175
- }),
176
- delete: Effect.fn(function* ({ output, olds }) {
177
- // Prefer the live spec (`olds` — the last-applied props; Alchemy renamed the delete
178
- // input's spec field from `news` to `olds`); fall back to reconstructing minimal props from
179
- // persisted output (a delete after the spec is gone — e.g. resource removed from the stack).
180
- const props = olds ?? propsFromOutput(output);
181
- if (props) {
182
- yield* Effect.tryPromise({
183
- try: (abortSignal) => deleteKroResource(props, abortSignal),
176
+ return { action: 'replace' };
177
+ }
178
+ return yield* Effect.tryPromise({
179
+ try: (abortSignal) => detectKroResourceIdentityDrift(olds, output, undefined, abortSignal),
184
180
  catch: ensureError,
185
181
  });
186
- }
187
- else {
188
- // Neither a live spec nor a usable output (e.g. a create that failed before persisting a
189
- // complete output). Warn rather than silently no-op so a possible leaked cluster object is
190
- // visible — there's nothing reconstructable to tear down here.
191
- getComponentLogger('alchemy-deployment')
192
- .child({ alchemyType: KRO_RESOURCE_TYPE })
193
- .warn('Skipping delete: no live spec and no reconstructable output to tear down', {
194
- hasOutput: !!output,
182
+ }),
183
+ reconcile: Effect.fn(function* ({ id, news, output }) {
184
+ return yield* Effect.tryPromise({
185
+ try: async (abortSignal) => {
186
+ const resourceHooks = hooksForResource(hooks, id);
187
+ const persistedIdentity = persistedKroResourceIdentity(output);
188
+ const desiredIdentity = desiredKroResourceIdentity(news, output);
189
+ const mutationPrecondition = await resourceHooks.beforeReconcile?.(news);
190
+ if (resourceHooks.beforeKubernetesEffect && news.deploymentStrategy === 'direct') {
191
+ if (mutationPrecondition || news.mutationPrecondition) {
192
+ throw new Error('Effect-time Kubernetes admission cannot reuse a preflight mutation precondition.');
193
+ }
194
+ if (news.deployer) {
195
+ throw new Error('Effect-time Kubernetes admission cannot use an injected deployer.');
196
+ }
197
+ }
198
+ if (persistedIdentity &&
199
+ desiredIdentity &&
200
+ desiredIdentity !== UNRESOLVED_IDENTITY &&
201
+ !sameKroResourceIdentity(persistedIdentity, desiredIdentity)) {
202
+ const previous = propsFromOutput(output);
203
+ if (!previous) {
204
+ throw new Error(`Alchemy cannot replace prior Kubernetes identity ${persistedIdentity.apiVersion}/${persistedIdentity.kind} ` +
205
+ `${persistedIdentity.metadata.namespace ? `${persistedIdentity.metadata.namespace}/` : ''}` +
206
+ `${persistedIdentity.metadata.name}: persisted delete properties are unavailable`);
207
+ }
208
+ // A distinct guarded identity must exist successfully before the incumbent is
209
+ // destroyed. Each write still obtains its own fresh effect-time authority. If
210
+ // teardown fails, persisted output stays on the incumbent and retry converges
211
+ // against the already-created successor before retrying the old delete.
212
+ if (resourceHooks.beforeKubernetesEffect && news.deploymentStrategy === 'direct') {
213
+ const successor = await deployKroResource(news, abortSignal, {}, resourceHooks);
214
+ await resourceHooks.beforeDelete?.(previous);
215
+ await deleteKroResource(previous, abortSignal, resourceHooks);
216
+ await waitForPersistedIdentityDeletion(news, output, abortSignal);
217
+ return successor;
218
+ }
219
+ await resourceHooks.beforeDelete?.(previous);
220
+ await deleteKroResource(previous, abortSignal, resourceHooks);
221
+ }
222
+ await waitForPersistedIdentityDeletion(news, output, abortSignal);
223
+ return deployKroResource(mutationPrecondition ? { ...news, mutationPrecondition } : news, abortSignal, {}, resourceHooks);
224
+ },
225
+ catch: ensureError,
195
226
  });
196
- }
197
- }),
198
- }));
227
+ }),
228
+ delete: Effect.fn(function* ({ id, output, olds }) {
229
+ // Prefer the live spec (`olds` — the last-applied props; Alchemy renamed the delete
230
+ // input's spec field from `news` to `olds`); fall back to reconstructing minimal props from
231
+ // persisted output (a delete after the spec is gone — e.g. resource removed from the stack).
232
+ const props = yield* Effect.try({
233
+ try: () => propsForRetainedDelete(olds, output),
234
+ catch: ensureError,
235
+ });
236
+ if (props) {
237
+ yield* Effect.tryPromise({
238
+ try: async (abortSignal) => {
239
+ const resourceHooks = hooksForResource(hooks, id);
240
+ await resourceHooks.beforeDelete?.(props);
241
+ await deleteKroResource(props, abortSignal, resourceHooks);
242
+ },
243
+ catch: ensureError,
244
+ });
245
+ }
246
+ else {
247
+ // Neither a live spec nor a usable output (e.g. a create that failed before persisting a
248
+ // complete output). Warn rather than silently no-op so a possible leaked cluster object is
249
+ // visible — there's nothing reconstructable to tear down here.
250
+ getComponentLogger('alchemy-deployment')
251
+ .child({ alchemyType: KRO_RESOURCE_TYPE })
252
+ .warn('Skipping delete: no live spec and no reconstructable output to tear down', {
253
+ hasOutput: !!output,
254
+ });
255
+ }
256
+ }),
257
+ }));
258
+ }
259
+ export const kroProvider = kroProviderWithHooks();
199
260
  /**
200
261
  * Instantiate a set of {@link AlchemyResourceDeclaration}s (from a factory's `toAlchemyResources`)
201
262
  * as `KroResource`s inside an alchemy Stack, wiring each declaration's `dependsOn` into alchemy
@@ -267,9 +328,9 @@ export function materializeAlchemyResources(kroResource, declarations, options =
267
328
  // provider's delete hook. Register it natively so destroy/prune drops
268
329
  // only the state entry without invoking Kubernetes deletion. Keep the
269
330
  // provider-level retain guard for legacy state and direct provider use.
270
- handles[decl.id] = yield* (decl.props.retain === true
331
+ handles[decl.id] = yield* decl.props.retain === true
271
332
  ? RemovalPolicy.retain()(resource)
272
- : resource);
333
+ : resource;
273
334
  }
274
335
  return handles;
275
336
  });
@@ -322,7 +383,7 @@ function alchemyArtifactOutputs(requirements, uses, options) {
322
383
  * Reconcile: deploy a single KRO resource (RGD or CR instance) and return its persisted state.
323
384
  * Convergent — alchemy calls this for both create and update; the deployer is idempotent apply.
324
385
  */
325
- async function deployKroResource(props, abortSignal, dependencies = {}) {
386
+ async function deployKroResource(props, abortSignal, dependencies = {}, hooks = {}) {
326
387
  abortSignal?.throwIfAborted();
327
388
  const logger = getComponentLogger('alchemy-deployment').child({ alchemyType: KRO_RESOURCE_TYPE });
328
389
  // Fail-closed PRE-HOIST guard on the ALCHEMY path (finding #7): before applying a
@@ -338,11 +399,11 @@ async function deployKroResource(props, abortSignal, dependencies = {}) {
338
399
  abortSignal?.throwIfAborted();
339
400
  // finding #2 (adopted-namespace): the hoisted workload Namespace is stamped
340
401
  // owned-by-this-RGD at BUILD time (cluster-free). READ the live namespace here and
341
- // KEEP that stamp ONLY when typekro actually creates it (404) or already owns it;
402
+ // KEEP that stamp ONLY after an atomic create succeeds or it already owns it;
342
403
  // otherwise strip it so teardown never deletes a namespace typekro merely adopted.
343
- const effectiveProps = await _preserveHoistedNamespaceAdoption(props, logger, abortSignal);
404
+ const effectiveProps = await _preserveHoistedNamespaceAdoption(props, logger, abortSignal, hooks);
344
405
  abortSignal?.throwIfAborted();
345
- const { deployer, dispose } = await _resolveDeployer(effectiveProps, 'deployment', abortSignal);
406
+ const { deployer, dispose } = await _resolveDeployer(effectiveProps, 'deployment', abortSignal, hooks);
346
407
  try {
347
408
  // Direct mode: hand the deployer the live state of this resource's dependencies so the engine
348
409
  // resolves its cross-resource references + CEL expressions against them (the deps deployed
@@ -387,6 +448,7 @@ async function deployKroResource(props, abortSignal, dependencies = {}) {
387
448
  copyResourceMetadata(resourceForDeploy, wrapped);
388
449
  resourceForDeploy = wrapped;
389
450
  }
451
+ resourceForDeploy = applyKroResourceMutationPrecondition(resourceForDeploy, effectiveProps.mutationPrecondition);
390
452
  if (effectiveProps.deploymentStrategy === 'kro' &&
391
453
  resourceForDeploy.kind === 'ResourceGraphDefinition') {
392
454
  await (dependencies.migrateLegacyArtifactBindings ?? migrateLegacyKroArtifactBindingCrd)(dependencies.kubeConfigForMigration?.() ??
@@ -424,6 +486,39 @@ async function deployKroResource(props, abortSignal, dependencies = {}) {
424
486
  * through the Effect provider above.
425
487
  */
426
488
  export const deployKroResourceForTest = deployKroResource;
489
+ function applyKroResourceMutationPrecondition(resource, precondition) {
490
+ if (!precondition)
491
+ return resource;
492
+ const guarded = {
493
+ ...resource,
494
+ metadata: { ...(resource.metadata ?? { name: '' }) },
495
+ };
496
+ copyResourceMetadata(resource, guarded);
497
+ if (precondition.operation === 'create') {
498
+ setMetadataField(guarded, 'applyPolicy', {
499
+ strategy: 'create-only',
500
+ });
501
+ return guarded;
502
+ }
503
+ const policy = getMetadataField(guarded, 'applyPolicy');
504
+ if (policy?.strategy === 'replace' ||
505
+ policy?.existingResource === 'replace' ||
506
+ policy?.immutableFieldPolicy === 'recreate') {
507
+ throw new TypeKroError(`A preconditioned update cannot use replacement semantics for ${guarded.kind}/${guarded.metadata?.name}`, 'MUTATION_PRECONDITION_REPLACEMENT_UNSAFE');
508
+ }
509
+ const updated = {
510
+ ...guarded,
511
+ metadata: {
512
+ ...(guarded.metadata ?? { name: '' }),
513
+ uid: precondition.uid,
514
+ resourceVersion: precondition.resourceVersion,
515
+ },
516
+ };
517
+ copyResourceMetadata(guarded, updated);
518
+ return updated;
519
+ }
520
+ /** @internal Exact helper shared with focused operation-host tests. */
521
+ export const applyKroResourceMutationPreconditionForTest = applyKroResourceMutationPrecondition;
427
522
  function persistedKroResourceIdentity(output) {
428
523
  const prior = output?.deployedResource;
429
524
  if (!prior)
@@ -789,14 +884,46 @@ export const existingInstanceNamespacesAlchemyForTest = _existingInstanceNamespa
789
884
  * Returns props unchanged for non-hoisted-namespace resources or when the stamp should
790
885
  * stay; otherwise a shallow copy whose resource has the ownership annotation removed.
791
886
  */
792
- async function _preserveHoistedNamespaceAdoption(props, logger, abortSignal) {
887
+ async function _preserveHoistedNamespaceAdoption(props, logger, abortSignal, hooks = {}) {
793
888
  if (props.namespaceEmptyGate !== true || props.namespaceOwnerRgd === undefined)
794
889
  return props;
890
+ const namespaceOwnerRgd = props.namespaceOwnerRgd;
891
+ if (props.resource.kind !== 'Namespace') {
892
+ throw new Error('Hoisted namespace ownership requires a Namespace resource.');
893
+ }
894
+ // This explicit hoisted role is cluster-scoped even after legacy Alchemy input serialization
895
+ // loses factory WeakMap metadata. Preserve it through adoption copies and subsequent apply.
896
+ const namespaceResource = { ...props.resource, scope: 'cluster' };
897
+ copyResourceMetadata(props.resource, namespaceResource);
898
+ setMetadataField(namespaceResource, 'scope', 'cluster');
899
+ props = { ...props, resource: namespaceResource };
795
900
  const name = props.resource.metadata?.name;
796
901
  if (typeof name !== 'string' || name.length === 0)
797
902
  return props;
798
903
  const kc = _createClientProvider(props, 'ownership-check');
799
- const api = _boundClusterCalls(createBunCompatibleKubernetesObjectApi(kc, props.options?.httpTimeouts), props, 'ownership-check', abortSignal, kc);
904
+ const boundedApi = _boundClusterCalls(createBunCompatibleKubernetesObjectApi(kc, props.options?.httpTimeouts), props, 'ownership-check', abortSignal, kc);
905
+ const effectGate = hooks.beforeKubernetesEffect;
906
+ const guarded = effectGate && props.deploymentStrategy === 'direct';
907
+ const api = guarded
908
+ ? guardKubernetesObjectApi(boundedApi, (mutation) => effectGate(props, mutation))
909
+ : boundedApi;
910
+ // Existing namespaces require no ownership-create attempt: keep only an already-present
911
+ // matching stamp. Absence never grants ownership; the admitted atomic create below does.
912
+ // A race still receives the established helper's 409/read/adopt decision.
913
+ let existingNamespace;
914
+ if (guarded) {
915
+ try {
916
+ existingNamespace = await api.read({
917
+ apiVersion: 'v1',
918
+ kind: 'Namespace',
919
+ metadata: { name },
920
+ });
921
+ }
922
+ catch (error) {
923
+ if (!isNotFoundError(error))
924
+ throw error;
925
+ }
926
+ }
800
927
  // CREATE-FIRST ownership (finding #3), matching the imperative path: attempt to CREATE
801
928
  // the namespace WITH the build-time stamp. A 201 is atomic proof typekro created it
802
929
  // (owned). A 409 means it already exists — owned ONLY if a prior create by this RGD
@@ -806,17 +933,22 @@ async function _preserveHoistedNamespaceAdoption(props, logger, abortSignal) {
806
933
  // (idempotent), so a 201 here is not the final apply — it is the ownership PROBE.
807
934
  let ownsNamespace;
808
935
  try {
809
- const decision = await decideNamespaceOwnershipCreateFirst(api, props.resource, props.namespaceOwnerRgd);
810
- ownsNamespace = decision.owned;
936
+ ownsNamespace = existingNamespace
937
+ ? existingNamespace.metadata?.annotations?.[NAMESPACE_OWNER_ANNOTATION] === namespaceOwnerRgd
938
+ : (await decideNamespaceOwnershipCreateFirst(api, props.resource, namespaceOwnerRgd)).owned;
811
939
  }
812
940
  catch (error) {
941
+ // Guarded admission/API failures terminate this operation, never fall through to a
942
+ // differently shaped apply or consume another permit after a rejected preparation write.
943
+ if (guarded)
944
+ throw error;
813
945
  // A non-conflict CREATE failure (or a failed conflict-read) — do NOT claim ownership
814
946
  // (conservative: a namespace we cannot provably create/own must not be stampable, or
815
947
  // teardown might delete an adopted one). The deployer's SSA apply below surfaces the
816
948
  // real error if the cluster is genuinely broken.
817
949
  logger.debug('Create-first ownership probe failed; treating as adopted (alchemy)', {
818
950
  namespace: name,
819
- rgd: props.namespaceOwnerRgd,
951
+ rgd: namespaceOwnerRgd,
820
952
  error: ensureError(error).message,
821
953
  });
822
954
  ownsNamespace = false;
@@ -825,7 +957,7 @@ async function _preserveHoistedNamespaceAdoption(props, logger, abortSignal) {
825
957
  return props; // keep the build-time ownership stamp
826
958
  logger.debug('Preserving namespace adoption — stripping build-time ownership stamp (alchemy)', {
827
959
  namespace: name,
828
- rgd: props.namespaceOwnerRgd,
960
+ rgd: namespaceOwnerRgd,
829
961
  });
830
962
  return { ...props, resource: _stripNamespaceOwnerAnnotation(props.resource) };
831
963
  }
@@ -1093,7 +1225,7 @@ function _resourceFromDirectArtifactRecord(props, preserveSensitiveInputs = fals
1093
1225
  return [binding, preserveSensitiveInputs ? value : Redacted.value(value)];
1094
1226
  }));
1095
1227
  const artifactOutputs = artifactOutputsForOperation(record.artifact, props, preserveSensitiveInputs, `Direct artifact ${logicalId}`);
1096
- return materializeDirectArtifactManifest(record.artifact, {
1228
+ const resource = materializeDirectArtifactManifest(record.artifact, {
1097
1229
  instanceName: props.resourceId ?? logicalId,
1098
1230
  runtimeResources: { [logicalId]: props.resource },
1099
1231
  ...(Object.keys(sensitive).length > 0 ? { sensitive } : {}),
@@ -1101,6 +1233,17 @@ function _resourceFromDirectArtifactRecord(props, preserveSensitiveInputs = fals
1101
1233
  ...(readinessEvaluators ? { readinessEvaluators } : {}),
1102
1234
  resolveReadinessStrategy: resolvePortableReadinessStrategy,
1103
1235
  }, props.resourceId ?? logicalId);
1236
+ const externalReferences = (record.externalReferences ?? []).map((reference) => {
1237
+ const id = reference.sourceNodeId ?? reference.id;
1238
+ return {
1239
+ id,
1240
+ manifest: materializeDirectArtifactManifest(reference, { instanceName: props.resourceId ?? logicalId }, id),
1241
+ };
1242
+ });
1243
+ if (externalReferences.length > 0) {
1244
+ setMetadataField(resource, 'directExternalReferences', externalReferences);
1245
+ }
1246
+ return resource;
1104
1247
  }
1105
1248
  /** Internal test hook for canonical direct-artifact state rehydration. */
1106
1249
  export const resourceFromDirectArtifactRecordForTest = _resourceFromDirectArtifactRecord;
@@ -1178,6 +1321,35 @@ function propsFromOutput(output) {
1178
1321
  ...(output.namespaceOwnerRgd !== undefined && { namespaceOwnerRgd: output.namespaceOwnerRgd }),
1179
1322
  };
1180
1323
  }
1324
+ /** Recover a retained base-format input's scope only from its matching persisted output. */
1325
+ function propsForRetainedDelete(olds, output) {
1326
+ if (!olds)
1327
+ return propsFromOutput(output);
1328
+ const outputScope = output?.resource && getResourceScope(output.resource);
1329
+ if (!outputScope)
1330
+ return olds;
1331
+ const deployed = persistedKroResourceIdentity(output);
1332
+ const sameIdentity = (left, right) => left.apiVersion === right.apiVersion &&
1333
+ left.kind === right.kind &&
1334
+ left.metadata?.name === right.metadata?.name &&
1335
+ left.metadata?.namespace === right.metadata?.namespace;
1336
+ if (!deployed ||
1337
+ !sameIdentity(olds.resource, output.resource) ||
1338
+ !sameIdentity(output.resource, deployed)) {
1339
+ throw new Error('Retained Alchemy delete scope cannot be recovered from a conflicting persisted Kubernetes identity.');
1340
+ }
1341
+ const oldScope = getResourceScope(olds.resource);
1342
+ if (oldScope && oldScope !== outputScope) {
1343
+ throw new Error('Retained Alchemy delete scope conflicts with the persisted Kubernetes output.');
1344
+ }
1345
+ if (oldScope)
1346
+ return olds;
1347
+ const resource = { ...olds.resource, scope: outputScope };
1348
+ copyResourceMetadata(olds.resource, resource);
1349
+ return { ...olds, resource };
1350
+ }
1351
+ /** @internal Retained-state migration regression helper. */
1352
+ export const propsForRetainedDeleteForTest = propsForRetainedDelete;
1181
1353
  /**
1182
1354
  * Create KubernetesClientProvider using centralized configuration management
1183
1355
  * Eliminates complex multi-stage fallback logic and consolidates TLS handling
@@ -1208,7 +1380,7 @@ function _createClientProvider(props, phase) {
1208
1380
  /**
1209
1381
  * Create the appropriate deployer based on the deployment strategy
1210
1382
  */
1211
- async function _createDeployer(kc, props, abortSignal) {
1383
+ async function _createDeployer(kc, props, abortSignal, hooks = {}) {
1212
1384
  // Use dynamic import to avoid circular dependencies
1213
1385
  const { DirectDeploymentEngine } = await import('../core/deployment/engine.js');
1214
1386
  const { DeploymentMode } = await import('../core/references/index.js');
@@ -1221,7 +1393,12 @@ async function _createDeployer(kc, props, abortSignal) {
1221
1393
  // config is still passed so Bun applies it at the socket (which can also cancel the request),
1222
1394
  // and the deadline wrapper adds the runtime-neutral bound on top. Both use the same per-verb
1223
1395
  // budgets, so under Bun this changes nothing.
1224
- const engine = new DirectDeploymentEngine(kc, _boundClusterCalls(createBunCompatibleKubernetesObjectApi(kc, props.options?.httpTimeouts), props, 'deployment-engine', abortSignal, kc), undefined, DeploymentMode.DIRECT, props.options?.httpTimeouts);
1396
+ const boundedApi = _boundClusterCalls(createBunCompatibleKubernetesObjectApi(kc, props.options?.httpTimeouts), props, 'deployment-engine', abortSignal, kc);
1397
+ const effectGate = hooks.beforeKubernetesEffect;
1398
+ const api = effectGate && props.deploymentStrategy === 'direct'
1399
+ ? guardKubernetesObjectApi(boundedApi, (mutation) => effectGate(props, mutation))
1400
+ : boundedApi;
1401
+ const engine = new DirectDeploymentEngine(kc, api, undefined, DeploymentMode.DIRECT, props.options?.httpTimeouts);
1225
1402
  if (props.deploymentStrategy === 'direct') {
1226
1403
  return new DirectTypeKroDeployer(engine);
1227
1404
  }
@@ -1343,12 +1520,15 @@ function enrichKroDeletionOptions(props, options) {
1343
1520
  /** Internal test hook for legacy Alchemy KRO state rehydration. */
1344
1521
  export const inferKroDeletionOptionsForTest = inferKroDeletionOptions;
1345
1522
  export const enrichKroDeletionOptionsForTest = enrichKroDeletionOptions;
1346
- async function _resolveDeployer(props, phase, abortSignal) {
1523
+ async function _resolveDeployer(props, phase, abortSignal, hooks = {}) {
1347
1524
  if (props.deployer) {
1525
+ if (hooks.beforeKubernetesEffect && props.deploymentStrategy === 'direct') {
1526
+ throw new Error('Effect-time Kubernetes admission cannot use an injected deployer.');
1527
+ }
1348
1528
  return { deployer: props.deployer, dispose: async () => { } };
1349
1529
  }
1350
1530
  const kc = _createClientProvider(props, phase);
1351
- const deployer = await _createDeployer(kc, props, abortSignal);
1531
+ const deployer = await _createDeployer(kc, props, abortSignal, hooks);
1352
1532
  return {
1353
1533
  deployer,
1354
1534
  dispose: async () => {
@@ -1363,7 +1543,7 @@ async function _resolveDeployer(props, phase, abortSignal) {
1363
1543
  * stacks' instances reference), we log and let alchemy drop the state entry — the orphaned
1364
1544
  * RGD is cluster-scoped and dies with the cluster; it must not wedge the destroy.
1365
1545
  */
1366
- async function deleteKroResource(props, abortSignal) {
1546
+ async function deleteKroResource(props, abortSignal, hooks = {}) {
1367
1547
  abortSignal?.throwIfAborted();
1368
1548
  const logger = getComponentLogger('alchemy-deployment').child({ alchemyType: KRO_RESOURCE_TYPE });
1369
1549
  // Retained (shared) resources — e.g. the KRO instance control-plane Namespace —
@@ -1396,9 +1576,15 @@ async function deleteKroResource(props, abortSignal) {
1396
1576
  return;
1397
1577
  }
1398
1578
  const kubeConfig = _createClientProvider(props, 'delete');
1579
+ const effectGate = hooks.beforeKubernetesEffect;
1580
+ const k8sApi = effectGate && props.deploymentStrategy === 'direct'
1581
+ ? guardKubernetesObjectApi(createBunCompatibleKubernetesObjectApi(kubeConfig), (mutation) => effectGate(props, mutation))
1582
+ : undefined;
1399
1583
  abortSignal?.throwIfAborted();
1400
1584
  await deleteNamespaceIfEmpty(kubeConfig, namespaceName, {
1401
1585
  logger,
1586
+ ...(k8sApi ? { k8sApi } : {}),
1587
+ ...(k8sApi ? { forbidResidualPvcCleanup: true } : {}),
1402
1588
  // Ownership record (finding #4) + gated delete (finding #1): only delete a
1403
1589
  // namespace this composition's RGD created, and gate it to a real 404.
1404
1590
  ...(props.namespaceOwnerRgd !== undefined && { ownedByRgd: props.namespaceOwnerRgd }),
@@ -1415,7 +1601,7 @@ async function deleteKroResource(props, abortSignal) {
1415
1601
  abortSignal?.throwIfAborted();
1416
1602
  return;
1417
1603
  }
1418
- const { deployer, dispose } = await _resolveDeployer(props, 'delete', abortSignal);
1604
+ const { deployer, dispose } = await _resolveDeployer(props, 'delete', abortSignal, hooks);
1419
1605
  try {
1420
1606
  await deployer.delete(props.resource, {
1421
1607
  mode: props.deploymentStrategy,