@microagi/alchemy-gcp 0.6.0 → 0.7.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.
@@ -0,0 +1,301 @@
1
+ import { Credentials } from "@distilled.cloud/gcp";
2
+ import { Resource } from "alchemy";
3
+ import { Unowned } from "alchemy/AdoptPolicy";
4
+ import { isResolved, somePropsAreDifferent } from "alchemy/Diff";
5
+ import * as Provider from "alchemy/Provider";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Redacted from "effect/Redacted";
8
+ import type * as GCP from "../Providers.ts";
9
+ import { gcpInternalLabels, hasAlchemyLabels } from "../Tags.ts";
10
+ import {
11
+ applyObject,
12
+ deleteObject,
13
+ getObject,
14
+ KubernetesApiError,
15
+ type GkeConnection,
16
+ type KubeObject,
17
+ } from "./client.ts";
18
+
19
+ /**
20
+ * Apply an **arbitrary** Kubernetes object to a GKE cluster via server-side
21
+ * apply — the generic escape hatch for any Kind (built-ins and CRDs alike),
22
+ * complementing the typed {@link "./Secret.ts"} resource.
23
+ *
24
+ * Convergence is a single `application/apply-patch+yaml` PATCH with
25
+ * `fieldManager: alchemy`, `force: true`. SSA merges by field ownership: only
26
+ * the fields you declare are managed, fields owned by other managers/controllers
27
+ * are left intact, and dropping a previously-declared field prunes it. The REST
28
+ * path is resolved from the apiserver's discovery endpoint, so no per-Kind code
29
+ * is required. See {@link "./client.ts"} for the full create/replace-vs-SSA
30
+ * rationale and when to prefer each.
31
+ *
32
+ * Ownership for adoption is gated on alchemy-internal `metadata.labels` (same
33
+ * triple as labelled GCP resources), so an object created out of band reads
34
+ * back as {@link Unowned} until adopted.
35
+ *
36
+ * @section Applying a manifest
37
+ * @example A ConfigMap
38
+ * ```typescript
39
+ * yield* GCP.KubernetesManifest("AppConfig", {
40
+ * endpoint: cluster.endpoint,
41
+ * caCertificate: cluster.clusterCaCertificate,
42
+ * apiVersion: "v1",
43
+ * kind: "ConfigMap",
44
+ * name: "app-config",
45
+ * namespace: "admin",
46
+ * body: { data: { LOG_LEVEL: "info" } },
47
+ * });
48
+ * ```
49
+ * @example A namespaced CRD instance
50
+ * ```typescript
51
+ * yield* GCP.KubernetesManifest("Tunnel", {
52
+ * endpoint: cluster.endpoint,
53
+ * caCertificate: cluster.clusterCaCertificate,
54
+ * apiVersion: "networking.cfargotunnel.com/v1alpha1",
55
+ * kind: "TunnelBinding",
56
+ * name: "research-ui",
57
+ * namespace: "admin",
58
+ * body: { spec: { ... } },
59
+ * });
60
+ * ```
61
+ */
62
+ export type KubernetesManifestProps = {
63
+ /**
64
+ * GKE control-plane endpoint (IP or hostname, no scheme) — typically
65
+ * `cluster.endpoint`. A change points at a different cluster → replace.
66
+ */
67
+ endpoint: string;
68
+ /**
69
+ * Base64-encoded cluster CA certificate (PEM) — typically
70
+ * `cluster.clusterCaCertificate`. Mutable (can rotate on the same cluster).
71
+ */
72
+ caCertificate: string;
73
+ /** Object `apiVersion`, e.g. `"v1"`, `"apps/v1"`. Immutable — replace. */
74
+ apiVersion: string;
75
+ /** Object `kind`, e.g. `"ConfigMap"`, `"Deployment"`. Immutable — replace. */
76
+ kind: string;
77
+ /** `metadata.name`. Immutable — replace. */
78
+ name: string;
79
+ /**
80
+ * `metadata.namespace`. Required for namespaced Kinds, omit for cluster-scoped
81
+ * ones. Immutable — replace.
82
+ */
83
+ namespace?: string;
84
+ /** Extra `metadata.labels` (alchemy ownership labels merge on top). Mutable. */
85
+ labels?: Record<string, string>;
86
+ /**
87
+ * The rest of the object — everything except `apiVersion`/`kind`/`metadata`
88
+ * (e.g. `spec`, `data`, `rules`). Mutable; may carry resolved Inputs from
89
+ * other resources. SSA prunes any key dropped from a later apply.
90
+ */
91
+ body?: Record<string, unknown>;
92
+ };
93
+
94
+ export type KubernetesManifestAttributes = {
95
+ /** Object apiVersion. */
96
+ apiVersion: string;
97
+ /** Object kind. */
98
+ kind: string;
99
+ /** metadata.name. */
100
+ name: string;
101
+ /** metadata.namespace (undefined for cluster-scoped objects). */
102
+ namespace: string | undefined;
103
+ /** Server-assigned uid. */
104
+ uid: string | undefined;
105
+ /** Server-assigned resourceVersion at last apply. */
106
+ resourceVersion: string | undefined;
107
+ /** Control-plane endpoint, threaded through for delete/read. */
108
+ endpoint: string;
109
+ /** Cluster CA certificate, threaded through for delete/read. */
110
+ caCertificate: string;
111
+ };
112
+
113
+ export type KubernetesManifest = Resource<
114
+ "GCP.KubernetesManifest",
115
+ KubernetesManifestProps,
116
+ KubernetesManifestAttributes,
117
+ never,
118
+ GCP.Providers
119
+ >;
120
+ export const KubernetesManifest = Resource<KubernetesManifest>(
121
+ "GCP.KubernetesManifest",
122
+ );
123
+
124
+ const toAttributes = (
125
+ o: KubeObject | undefined,
126
+ parent: {
127
+ apiVersion: string;
128
+ kind: string;
129
+ name: string;
130
+ namespace: string | undefined;
131
+ endpoint: string;
132
+ caCertificate: string;
133
+ },
134
+ ): KubernetesManifestAttributes => ({
135
+ apiVersion: o?.apiVersion ?? parent.apiVersion,
136
+ kind: o?.kind ?? parent.kind,
137
+ name: o?.metadata?.name ?? parent.name,
138
+ namespace: o?.metadata?.namespace ?? parent.namespace,
139
+ uid: o?.metadata?.uid,
140
+ resourceVersion: o?.metadata?.resourceVersion,
141
+ endpoint: parent.endpoint,
142
+ caCertificate: parent.caCertificate,
143
+ });
144
+
145
+ /** Mint a fresh GKE connection (plain bearer token) from props + ADC creds. */
146
+ const connect = Effect.fn("k8sManifest.connect")(function* (props: {
147
+ endpoint: string;
148
+ caCertificate: string;
149
+ }) {
150
+ const { accessToken } = yield* yield* Credentials;
151
+ return {
152
+ endpoint: props.endpoint,
153
+ caCertificate: props.caCertificate,
154
+ token: Redacted.value(accessToken),
155
+ } satisfies GkeConnection;
156
+ });
157
+
158
+ /** GET the object, mapping a 404 to `undefined`. */
159
+ const observe = (
160
+ connection: GkeConnection,
161
+ apiVersion: string,
162
+ kind: string,
163
+ namespace: string | undefined,
164
+ name: string,
165
+ ) =>
166
+ getObject(connection, apiVersion, kind, namespace, name).pipe(
167
+ Effect.catchIf(
168
+ (e): e is KubernetesApiError =>
169
+ e instanceof KubernetesApiError && e.statusCode === 404,
170
+ () => Effect.succeed(undefined as KubeObject | undefined),
171
+ ),
172
+ );
173
+
174
+ export const KubernetesManifestProvider = () =>
175
+ Provider.effect(
176
+ KubernetesManifest,
177
+ Effect.gen(function* () {
178
+ return {
179
+ // Identity (apiVersion/kind/name/namespace) and the server-assigned uid
180
+ // are unchanged by an in-place apply. endpoint is a replace trigger so
181
+ // it never changes on update; caCertificate is excluded (can rotate).
182
+ stables: [
183
+ "apiVersion",
184
+ "kind",
185
+ "name",
186
+ "namespace",
187
+ "uid",
188
+ "endpoint",
189
+ ],
190
+ diff: Effect.fn(function* ({ news, olds = {} }) {
191
+ if (!isResolved(news)) return undefined;
192
+ const o = olds as KubernetesManifestProps;
193
+ // apiVersion/kind/name/namespace are the object's identity, and
194
+ // endpoint points at a specific cluster — any change orphans the old
195
+ // object, so create new + delete old.
196
+ if (
197
+ somePropsAreDifferent(o, news, [
198
+ "apiVersion",
199
+ "kind",
200
+ "name",
201
+ "namespace",
202
+ "endpoint",
203
+ ])
204
+ ) {
205
+ return { action: "replace" } as const;
206
+ }
207
+ return undefined;
208
+ }),
209
+ reconcile: Effect.fn(function* ({ id, news }) {
210
+ const labels = {
211
+ ...(news.labels ?? {}),
212
+ ...(yield* gcpInternalLabels(id)),
213
+ };
214
+ const connection = yield* connect(news);
215
+
216
+ // Build the full object: identity + merged labels + the user body.
217
+ // SSA is declarative — we send exactly what we manage (no
218
+ // resourceVersion), and force ownership of any field we name.
219
+ const object: KubeObject = {
220
+ ...news.body,
221
+ apiVersion: news.apiVersion,
222
+ kind: news.kind,
223
+ metadata: {
224
+ name: news.name,
225
+ ...(news.namespace ? { namespace: news.namespace } : {}),
226
+ labels,
227
+ },
228
+ };
229
+
230
+ const applied = yield* applyObject(connection, object);
231
+
232
+ // A 2xx apply normally echoes the object, but an empty-body success is
233
+ // possible — re-read so we always return real attributes (uid/
234
+ // resourceVersion). Use getObject (not observe): a missing object
235
+ // after a successful apply is a real failure, not "absent".
236
+ const final =
237
+ applied?.metadata != null
238
+ ? applied
239
+ : yield* getObject(
240
+ connection,
241
+ news.apiVersion,
242
+ news.kind,
243
+ news.namespace,
244
+ news.name,
245
+ );
246
+
247
+ return toAttributes(final, {
248
+ apiVersion: news.apiVersion,
249
+ kind: news.kind,
250
+ name: news.name,
251
+ namespace: news.namespace,
252
+ endpoint: news.endpoint,
253
+ caCertificate: news.caCertificate,
254
+ });
255
+ }),
256
+ delete: Effect.fn(function* ({ output }) {
257
+ const connection = yield* connect(output);
258
+ yield* deleteObject(
259
+ connection,
260
+ output.apiVersion,
261
+ output.kind,
262
+ output.namespace,
263
+ output.name,
264
+ );
265
+ }),
266
+ read: Effect.fn(function* ({ id, output, olds }) {
267
+ const endpoint = output?.endpoint ?? olds?.endpoint;
268
+ const caCertificate = output?.caCertificate ?? olds?.caCertificate;
269
+ const apiVersion = output?.apiVersion ?? olds?.apiVersion;
270
+ const kind = output?.kind ?? olds?.kind;
271
+ const name = output?.name ?? olds?.name;
272
+ const namespace = output?.namespace ?? olds?.namespace;
273
+ if (!endpoint || !caCertificate || !apiVersion || !kind || !name) {
274
+ return undefined;
275
+ }
276
+
277
+ const connection = yield* connect({ endpoint, caCertificate });
278
+ const observed = yield* observe(
279
+ connection,
280
+ apiVersion,
281
+ kind,
282
+ namespace,
283
+ name,
284
+ );
285
+ if (!observed) return undefined;
286
+
287
+ const attrs = toAttributes(observed, {
288
+ apiVersion,
289
+ kind,
290
+ name,
291
+ namespace,
292
+ endpoint,
293
+ caCertificate,
294
+ });
295
+ return (yield* hasAlchemyLabels(id, observed.metadata?.labels))
296
+ ? attrs
297
+ : Unowned(attrs);
298
+ }),
299
+ };
300
+ }),
301
+ );
@@ -1,4 +1,10 @@
1
1
  import { Credentials } from "@distilled.cloud/gcp";
2
+ import {
3
+ createCoreV1NamespacedSecret,
4
+ deleteCoreV1NamespacedSecret,
5
+ readCoreV1NamespacedSecret,
6
+ replaceCoreV1NamespacedSecret,
7
+ } from "@distilled.cloud/kubernetes/core";
2
8
  import { Resource } from "alchemy";
3
9
  import { Unowned } from "alchemy/AdoptPolicy";
4
10
  import { isResolved, somePropsAreDifferent } from "alchemy/Diff";
@@ -8,35 +14,36 @@ import * as Effect from "effect/Effect";
8
14
  import * as Redacted from "effect/Redacted";
9
15
  import type * as GCP from "../Providers.ts";
10
16
  import { gcpInternalLabels, hasAlchemyLabels } from "../Tags.ts";
11
- import {
12
- applySecret,
13
- deleteSecret,
14
- getSecret,
15
- KubernetesApiError,
16
- type GkeConnection,
17
- type SecretObject,
18
- } from "./client.ts";
17
+ import { clusterLayer, type ClusterConnection } from "./connection.ts";
19
18
 
20
19
  /**
21
20
  * An Opaque Kubernetes Secret in a GKE cluster.
22
21
  *
23
- * Unlike the rest of the GCP provider (which calls typed
24
- * `@distilled.cloud/gcp` operations), this resource talks directly to a
25
- * GKE cluster's Kubernetes API server alchemy's upstream Kubernetes
26
- * provider is EKS-only. The control-plane connection (`endpoint` +
27
- * `caCertificate`) comes from a {@link GCP.Cluster}'s attributes; the
28
- * bearer token is minted from the provider's ADC {@link Credentials}.
22
+ * Driven through the typed `@distilled.cloud/kubernetes` SDK (core/v1) rather
23
+ * than alchemy's upstream Kubernetes provider, which is EKS-only. The
24
+ * control-plane connection (`endpoint` + `caCertificate`) comes from a
25
+ * {@link GCP.Cluster}'s attributes; the bearer token is minted from the
26
+ * provider's ADC {@link Credentials} and threaded into the cluster via
27
+ * {@link clusterLayer} (which also trusts the per-cluster CA).
28
+ *
29
+ * Convergence is a typed **create-or-replace upsert** (not server-side apply):
30
+ * `reconcile` reads the Secret, then `createCoreV1NamespacedSecret` if absent
31
+ * or `replaceCoreV1NamespacedSecret` (a full PUT, carrying the observed
32
+ * `resourceVersion` for optimistic concurrency) if present. A `PUT` is a full
33
+ * object replacement, so dropping a key from `data` removes it from the stored
34
+ * Secret — the same pruning SSA gave us, without needing the
35
+ * `application/apply-patch+yaml` content type the typed PATCH op can't express.
29
36
  *
30
- * Ownership for adoption is gated on alchemy-internal `metadata.labels`
31
- * (same triple as labelled GCP resources), so a Secret created out of
32
- * band reads back as {@link Unowned} until adopted.
37
+ * Ownership for adoption is gated on alchemy-internal `metadata.labels` (same
38
+ * triple as labelled GCP resources), so a Secret created out of band reads back
39
+ * as {@link Unowned} until adopted.
33
40
  *
34
41
  * @section Creating a Secret
35
42
  * @example Wire a Cloudflare tunnel token into the cluster
36
43
  * ```typescript
37
44
  * // `tunnel.token` is a `Redacted<string>`; pass it straight through —
38
- * // the value is unwrapped only at the moment it's written to the API,
39
- * // and stays redacted in logs/plan output.
45
+ * // the value is unwrapped only at the moment it's written to the API, and
46
+ * // stays redacted in logs/plan output.
40
47
  * yield* GCP.KubernetesSecret("CloudflaredTunnelSecret", {
41
48
  * endpoint: cluster.endpoint,
42
49
  * caCertificate: cluster.clusterCaCertificate,
@@ -67,15 +74,14 @@ export type KubernetesSecretProps = {
67
74
  /** Secret `type`. Default `"Opaque"`. Mutable. */
68
75
  type?: string;
69
76
  /**
70
- * String data (written under `stringData`; UTF-8 values). Mutable.
77
+ * String data (UTF-8 values, base64-encoded on the way to the API). Mutable.
71
78
  *
72
- * Values may be `Redacted<string>` (e.g. a resource's secret output
73
- * like `tunnel.token`) — they are unwrapped only when written to the
74
- * Kubernetes API and stay opaque in logs/plan output. Plain strings
75
- * are accepted too.
79
+ * Values may be `Redacted<string>` (e.g. a resource's secret output like
80
+ * `tunnel.token`) — they are unwrapped only when written to the Kubernetes
81
+ * API and stay opaque in logs/plan output. Plain strings are accepted too.
76
82
  *
77
- * NOTE: the underlying values are persisted in alchemy stack state
78
- * (the engine diffs on the real value) — protect the state backend.
83
+ * NOTE: the underlying values are persisted in alchemy stack state (the
84
+ * engine diffs on the real value) — protect the state backend.
79
85
  */
80
86
  stringData: Record<string, Redacted.Redacted<string> | string>;
81
87
  /** Extra metadata labels (alchemy ownership labels merge on top). Mutable. */
@@ -110,6 +116,18 @@ export const KubernetesSecret = Resource<KubernetesSecret>(
110
116
  "GCP.KubernetesSecret",
111
117
  );
112
118
 
119
+ /** The subset of a distilled Secret response we read back. */
120
+ type SecretObject = {
121
+ metadata?: {
122
+ name?: string;
123
+ namespace?: string;
124
+ uid?: string;
125
+ resourceVersion?: string;
126
+ labels?: Record<string, string>;
127
+ };
128
+ type?: string;
129
+ };
130
+
113
131
  const toAttributes = (
114
132
  s: SecretObject | undefined,
115
133
  parent: {
@@ -138,50 +156,66 @@ const connect = Effect.fn("k8sSecret.connect")(function* (props: {
138
156
  return {
139
157
  endpoint: props.endpoint,
140
158
  caCertificate: props.caCertificate,
141
- token: Redacted.value(accessToken),
142
- } satisfies GkeConnection;
159
+ token: accessToken,
160
+ } satisfies ClusterConnection;
143
161
  });
144
162
 
145
- const observe = (connection: GkeConnection, namespace: string, name: string) =>
146
- getSecret(connection, namespace, name).pipe(
147
- Effect.catchIf(
148
- (e): e is KubernetesApiError =>
149
- e instanceof KubernetesApiError && e.statusCode === 404,
150
- () => Effect.succeed(undefined as SecretObject | undefined),
163
+ /** GET a Secret, mapping a 404 (`NotFound`) to `undefined`. */
164
+ const observe = (
165
+ connection: ClusterConnection,
166
+ namespace: string,
167
+ name: string,
168
+ ) =>
169
+ readCoreV1NamespacedSecret({ namespace, name }).pipe(
170
+ Effect.provide(clusterLayer(connection)),
171
+ Effect.catchTag("NotFound", () =>
172
+ Effect.succeed(undefined as SecretObject | undefined),
151
173
  ),
152
174
  );
153
175
 
176
+ /** Base64-encode the (possibly redacted) string values for the `data` field. */
177
+ const encodeData = (
178
+ stringData: Record<string, Redacted.Redacted<string> | string>,
179
+ ): Record<string, string> => {
180
+ const data: Record<string, string> = {};
181
+ for (const [k, v] of Object.entries(stringData)) {
182
+ const raw = Redacted.isRedacted(v) ? Redacted.value(v) : v;
183
+ data[k] = Buffer.from(raw, "utf8").toString("base64");
184
+ }
185
+ return data;
186
+ };
187
+
154
188
  export const KubernetesSecretProvider = () =>
155
189
  Provider.effect(
156
190
  KubernetesSecret,
157
191
  Effect.gen(function* () {
158
192
  return {
159
- // Attributes unchanged by an in-place update (so dependents can
160
- // resolve them at plan time): identity (name/namespace), the
161
- // server-assigned uid, and endpoint — which is a replace trigger,
162
- // so it never changes on update. caCertificate is intentionally
163
- // excluded: it can rotate on the same cluster.
193
+ // Attributes unchanged by an in-place update (so dependents can resolve
194
+ // them at plan time): identity (name/namespace), the server-assigned
195
+ // uid, and endpoint — which is a replace trigger, so it never changes
196
+ // on update. caCertificate is intentionally excluded: it can rotate on
197
+ // the same cluster.
164
198
  stables: ["name", "namespace", "endpoint", "uid"],
165
199
  diff: Effect.fn(function* ({ id, news, olds = {} }) {
166
200
  if (!isResolved(news)) return undefined;
167
201
  const o = olds as KubernetesSecretProps;
168
- // Compare the RESOLVED name and type (with their defaults
169
- // applied), not the raw props — otherwise omitting `name` on one
170
- // side and setting it to the generated physical name on the
171
- // other (or the same for `type`/"Opaque") triggers a spurious
172
- // replace.
202
+ // Compare the RESOLVED name and type (with their defaults applied),
203
+ // not the raw props — otherwise omitting `name` on one side and
204
+ // setting it to the generated physical name on the other (or the
205
+ // same for `type`/"Opaque") triggers a spurious replace.
173
206
  const defaultName = (
174
207
  yield* createPhysicalName({ id, maxLength: 63 })
175
208
  ).toLowerCase();
176
- const sameName = (o.name ?? defaultName) === (news.name ?? defaultName);
209
+ const sameName =
210
+ (o.name ?? defaultName) === (news.name ?? defaultName);
177
211
  const sameType = (o.type ?? "Opaque") === (news.type ?? "Opaque");
178
- // Replace (create new + delete old) rather than in-place SSA on:
179
- // - `endpoint`: points at a different cluster (would orphan the
180
- // old Secret). caCertificate is excluded — it can rotate on
181
- // the same cluster and should just re-apply.
212
+ // Replace (create new + delete old) rather than in-place upsert on:
213
+ // - `endpoint`: points at a different cluster (would orphan the old
214
+ // Secret). caCertificate is excluded — it can rotate on the same
215
+ // cluster and should just re-apply.
182
216
  // - `namespace`/`name`: the object's identity.
183
- // - `type`: immutable on a Kubernetes Secret; an in-place apply
184
- // with a new type is rejected by the API.
217
+ // - `type`: immutable on a Kubernetes Secret; a PUT with a new type
218
+ // is rejected by the API.
185
219
  if (
186
220
  !sameName ||
187
221
  !sameType ||
@@ -201,37 +235,70 @@ export const KubernetesSecretProvider = () =>
201
235
  ...(yield* gcpInternalLabels(id)),
202
236
  };
203
237
  const connection = yield* connect(news);
238
+ const layer = clusterLayer(connection);
239
+ const data = encodeData(news.stringData);
204
240
 
205
- // Unwrap any Redacted values at the last moment Redacted is
206
- // kept opaque through Output resolution, so it arrives here as
207
- // a Redacted object that would JSON-serialize to "<redacted>"
208
- // if passed through verbatim — then base64-encode for `data`.
209
- const data: Record<string, string> = {};
210
- for (const [k, v] of Object.entries(news.stringData)) {
211
- const raw = Redacted.isRedacted(v) ? Redacted.value(v) : v;
212
- data[k] = Buffer.from(raw, "utf8").toString("base64");
213
- }
241
+ // Full-object PUT for an existing Secret carries the observed
242
+ // resourceVersion (the API requires it for updates and uses it for
243
+ // optimistic concurrency).
244
+ const replaceFrom = (observed: SecretObject) =>
245
+ replaceCoreV1NamespacedSecret({
246
+ namespace: news.namespace,
247
+ name: desiredName,
248
+ fieldManager: "alchemy",
249
+ // Read-modify-write: a PUT replaces the WHOLE object, so preserve
250
+ // the observed metadata (annotations, ownerReferences, finalizers,
251
+ // …) and overlay only what we manage — otherwise every reconcile
252
+ // strips fields other managers set (e.g. an ownerReference, which
253
+ // would break garbage collection). `observed.metadata` carries the
254
+ // full server object at runtime (the local type is a subset).
255
+ // Labels merge (ours win) so foreign labels survive too. (The SSA
256
+ // path — GCP.KubernetesManifest — does field-level pruning instead;
257
+ // the typed PUT path deliberately preserves rather than prunes.)
258
+ metadata: {
259
+ ...observed.metadata,
260
+ name: desiredName,
261
+ labels: { ...observed.metadata?.labels, ...labels },
262
+ resourceVersion: observed.metadata?.resourceVersion,
263
+ },
264
+ type: desiredType,
265
+ data,
266
+ }).pipe(Effect.provide(layer));
214
267
 
215
- // Server-side apply is an idempotent upsert it both creates
216
- // the Secret if missing and converges its data/labels if it
217
- // exists, taking field-manager ownership (force=true).
218
- const applied = yield* applySecret(connection, {
219
- metadata: { name: desiredName, namespace: news.namespace, labels },
220
- type: desiredType,
221
- data,
222
- });
268
+ // Observe ensure: create if absent, else read-modify-write replace.
269
+ // Adoption (output defined, olds undefined) traverses the same flow.
270
+ const writeOnce = observe(
271
+ connection,
272
+ news.namespace,
273
+ desiredName,
274
+ ).pipe(
275
+ Effect.flatMap((observed) =>
276
+ observed
277
+ ? replaceFrom(observed)
278
+ : createCoreV1NamespacedSecret({
279
+ namespace: news.namespace,
280
+ fieldManager: "alchemy",
281
+ metadata: { name: desiredName, labels },
282
+ type: desiredType,
283
+ data,
284
+ }).pipe(Effect.provide(layer)),
285
+ ),
286
+ );
223
287
 
224
- // A 2xx apply normally echoes the object, but a successful
225
- // empty-body response is possible re-read so we always return
226
- // real attributes (uid/resourceVersion) rather than crash. Use
227
- // getSecret (NOT observe): if the Secret is somehow absent after
228
- // a successful apply, propagate the 404 and fail the reconcile
229
- // instead of recording an empty success.
230
- const final = applied?.metadata
231
- ? applied
232
- : yield* getSecret(connection, news.namespace, desiredName);
288
+ // Retry the WHOLE observe→write flow on Conflict a stale
289
+ // resourceVersion (replace) or a create/delete race re-observes fresh
290
+ // state and writes again, so a second conflict is handled too. Bounded
291
+ // so a persistently-contended object fails (and the engine
292
+ // re-reconciles) rather than spinning forever.
293
+ const upsert = (attempt: number): typeof writeOnce =>
294
+ writeOnce.pipe(
295
+ Effect.catchTag("Conflict", (e) =>
296
+ attempt < 3 ? upsert(attempt + 1) : Effect.fail(e),
297
+ ),
298
+ );
299
+ const result = yield* upsert(0);
233
300
 
234
- return toAttributes(final, {
301
+ return toAttributes(result, {
235
302
  name: desiredName,
236
303
  namespace: news.namespace,
237
304
  endpoint: news.endpoint,
@@ -241,7 +308,14 @@ export const KubernetesSecretProvider = () =>
241
308
  }),
242
309
  delete: Effect.fn(function* ({ output }) {
243
310
  const connection = yield* connect(output);
244
- yield* deleteSecret(connection, output.namespace, output.name);
311
+ yield* deleteCoreV1NamespacedSecret({
312
+ namespace: output.namespace,
313
+ name: output.name,
314
+ }).pipe(
315
+ Effect.provide(clusterLayer(connection)),
316
+ // 404 is success (idempotent teardown).
317
+ Effect.catchTag("NotFound", () => Effect.void),
318
+ );
245
319
  }),
246
320
  read: Effect.fn(function* ({ id, output, olds }) {
247
321
  const endpoint = output?.endpoint ?? olds?.endpoint;