typekro 0.42.0 → 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.
- package/dist/alchemy/deployers.d.ts.map +1 -1
- package/dist/alchemy/deployers.js +2 -1
- package/dist/alchemy/deployers.js.map +1 -1
- package/dist/alchemy/index.d.ts +5 -3
- package/dist/alchemy/index.d.ts.map +1 -1
- package/dist/alchemy/index.js +3 -1
- package/dist/alchemy/index.js.map +1 -1
- package/dist/alchemy/kubernetes-effect-gate.d.ts +24 -0
- package/dist/alchemy/kubernetes-effect-gate.d.ts.map +1 -0
- package/dist/alchemy/kubernetes-effect-gate.js +112 -0
- package/dist/alchemy/kubernetes-effect-gate.js.map +1 -0
- package/dist/alchemy/resource-registration.d.ts +29 -3
- package/dist/alchemy/resource-registration.d.ts.map +1 -1
- package/dist/alchemy/resource-registration.js +278 -92
- package/dist/alchemy/resource-registration.js.map +1 -1
- package/dist/alchemy/types.d.ts +13 -0
- package/dist/alchemy/types.d.ts.map +1 -1
- package/dist/core/dependencies/resolver.js +2 -2
- package/dist/core/dependencies/resolver.js.map +1 -1
- package/dist/core/deployment/direct-factory.d.ts +1 -0
- package/dist/core/deployment/direct-factory.d.ts.map +1 -1
- package/dist/core/deployment/direct-factory.js +22 -5
- package/dist/core/deployment/direct-factory.js.map +1 -1
- package/dist/core/deployment/engine.d.ts +2 -143
- package/dist/core/deployment/engine.d.ts.map +1 -1
- package/dist/core/deployment/engine.js +23 -20
- package/dist/core/deployment/engine.js.map +1 -1
- package/dist/core/deployment/kro-namespace-teardown.d.ts +2 -0
- package/dist/core/deployment/kro-namespace-teardown.d.ts.map +1 -1
- package/dist/core/deployment/kro-namespace-teardown.js +3 -0
- package/dist/core/deployment/kro-namespace-teardown.js.map +1 -1
- package/dist/core/deployment/resource-applier.d.ts +2 -2
- package/dist/core/deployment/resource-applier.d.ts.map +1 -1
- package/dist/core/deployment/resource-applier.js +3 -16
- package/dist/core/deployment/resource-applier.js.map +1 -1
- package/dist/core/deployment/rollback-manager.d.ts +2 -1
- package/dist/core/deployment/rollback-manager.d.ts.map +1 -1
- package/dist/core/deployment/rollback-manager.js +54 -5
- package/dist/core/deployment/rollback-manager.js.map +1 -1
- package/dist/core/kubernetes/bun-api-client.d.ts.map +1 -1
- package/dist/core/kubernetes/bun-api-client.js +22 -2
- package/dist/core/kubernetes/bun-api-client.js.map +1 -1
- package/dist/core/metadata/resource-metadata.d.ts +3 -0
- package/dist/core/metadata/resource-metadata.d.ts.map +1 -1
- package/dist/core/metadata/resource-metadata.js.map +1 -1
- package/dist/core/planning/artifacts.d.ts +2 -0
- package/dist/core/planning/artifacts.d.ts.map +1 -1
- package/dist/core/planning/direct-runtime-adapter.d.ts +1 -0
- package/dist/core/planning/direct-runtime-adapter.d.ts.map +1 -1
- package/dist/core/planning/direct-runtime-adapter.js +16 -19
- package/dist/core/planning/direct-runtime-adapter.js.map +1 -1
- package/dist/core/planning/execution-record.d.ts +2 -0
- package/dist/core/planning/execution-record.d.ts.map +1 -1
- package/dist/core/planning/execution-record.js +71 -15
- package/dist/core/planning/execution-record.js.map +1 -1
- package/dist/core/planning/planner.d.ts.map +1 -1
- package/dist/core/planning/planner.js +7 -3
- package/dist/core/planning/planner.js.map +1 -1
- package/dist/core/references/cel-evaluator.js +2 -2
- package/dist/core/references/cel-evaluator.js.map +1 -1
- package/dist/core/references/external-refs.d.ts.map +1 -1
- package/dist/core/references/external-refs.js +5 -0
- package/dist/core/references/external-refs.js.map +1 -1
- package/dist/core/references/resolver.d.ts.map +1 -1
- package/dist/core/references/resolver.js +0 -2
- package/dist/core/references/resolver.js.map +1 -1
- package/dist/core/types/deployment.d.ts +30 -0
- package/dist/core/types/deployment.d.ts.map +1 -1
- package/dist/factories/harbor/compositions/harbor-installation.d.ts.map +1 -1
- package/dist/factories/harbor/compositions/harbor-installation.js +29 -12
- package/dist/factories/harbor/compositions/harbor-installation.js.map +1 -1
- package/dist/factories/harbor/compositions/harbor-rook-s3-credentials.d.ts +27 -0
- package/dist/factories/harbor/compositions/harbor-rook-s3-credentials.d.ts.map +1 -0
- package/dist/factories/harbor/compositions/harbor-rook-s3-credentials.js +56 -0
- package/dist/factories/harbor/compositions/harbor-rook-s3-credentials.js.map +1 -0
- package/dist/factories/harbor/compositions/index.d.ts +1 -0
- package/dist/factories/harbor/compositions/index.d.ts.map +1 -1
- package/dist/factories/harbor/compositions/index.js +1 -0
- package/dist/factories/harbor/compositions/index.js.map +1 -1
- package/dist/factories/harbor/provider/rook-s3-binding.d.ts +4 -0
- package/dist/factories/harbor/provider/rook-s3-binding.d.ts.map +1 -1
- package/dist/factories/harbor/provider/rook-s3-binding.js +16 -6
- package/dist/factories/harbor/provider/rook-s3-binding.js.map +1 -1
- package/dist/factories/helm/readiness-evaluators.d.ts.map +1 -1
- package/dist/factories/helm/readiness-evaluators.js +14 -8
- package/dist/factories/helm/readiness-evaluators.js.map +1 -1
- package/dist/factories/kubernetes/workloads/job.d.ts +1 -1
- package/dist/factories/kubernetes/workloads/job.d.ts.map +1 -1
- package/dist/factories/kubernetes/workloads/job.js +14 -2
- package/dist/factories/kubernetes/workloads/job.js.map +1 -1
- package/package.json +7 -5
- 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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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*
|
|
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
|
|
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
|
|
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
|
-
|
|
810
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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,
|