@microagi/alchemy-gcp 0.5.0 → 0.6.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/CHANGELOG.md +24 -0
- package/lib/ArtifactRegistry/Repository.d.ts +1 -1
- package/lib/ArtifactRegistry/Repository.d.ts.map +1 -1
- package/lib/ArtifactRegistry/Repository.js +8 -9
- package/lib/ArtifactRegistry/Repository.js.map +1 -1
- package/lib/Auth/Credentials.d.ts +10 -3
- package/lib/Auth/Credentials.d.ts.map +1 -1
- package/lib/Auth/Credentials.js +13 -3
- package/lib/Auth/Credentials.js.map +1 -1
- package/lib/CloudResourceManager/Project.d.ts +1 -1
- package/lib/Compute/Address.d.ts +97 -0
- package/lib/Compute/Address.d.ts.map +1 -0
- package/lib/Compute/Address.js +143 -0
- package/lib/Compute/Address.js.map +1 -0
- package/lib/Compute/Firewall.d.ts +108 -0
- package/lib/Compute/Firewall.d.ts.map +1 -0
- package/lib/Compute/Firewall.js +163 -0
- package/lib/Compute/Firewall.js.map +1 -0
- package/lib/Compute/ForwardingRule.d.ts +106 -0
- package/lib/Compute/ForwardingRule.d.ts.map +1 -0
- package/lib/Compute/ForwardingRule.js +152 -0
- package/lib/Compute/ForwardingRule.js.map +1 -0
- package/lib/Compute/GlobalAddress.d.ts +1 -1
- package/lib/Compute/Network.d.ts +1 -1
- package/lib/Compute/SharedVpcHost.d.ts +1 -1
- package/lib/Compute/SharedVpcServiceProject.d.ts +1 -1
- package/lib/Compute/Subnetwork.d.ts +1 -1
- package/lib/Compute/index.d.ts +3 -0
- package/lib/Compute/index.d.ts.map +1 -1
- package/lib/Compute/index.js +3 -0
- package/lib/Compute/index.js.map +1 -1
- package/lib/Container/Cluster.d.ts +21 -1
- package/lib/Container/Cluster.d.ts.map +1 -1
- package/lib/Container/Cluster.js +5 -2
- package/lib/Container/Cluster.js.map +1 -1
- package/lib/Container/NodePool.d.ts +27 -2
- package/lib/Container/NodePool.d.ts.map +1 -1
- package/lib/Container/NodePool.js +45 -8
- package/lib/Container/NodePool.js.map +1 -1
- package/lib/Kubernetes/Secret.d.ts +89 -0
- package/lib/Kubernetes/Secret.d.ts.map +1 -0
- package/lib/Kubernetes/Secret.js +139 -0
- package/lib/Kubernetes/Secret.js.map +1 -0
- package/lib/Kubernetes/client.d.ts +58 -0
- package/lib/Kubernetes/client.d.ts.map +1 -0
- package/lib/Kubernetes/client.js +131 -0
- package/lib/Kubernetes/client.js.map +1 -0
- package/lib/Kubernetes/index.d.ts +2 -0
- package/lib/Kubernetes/index.d.ts.map +1 -0
- package/lib/Kubernetes/index.js +2 -0
- package/lib/Kubernetes/index.js.map +1 -0
- package/lib/ManagedLustre/Instance.d.ts +1 -1
- package/lib/ManagedLustre/Instance.d.ts.map +1 -1
- package/lib/ManagedLustre/Instance.js +25 -5
- package/lib/ManagedLustre/Instance.js.map +1 -1
- package/lib/Providers.d.ts +1 -1
- package/lib/Providers.d.ts.map +1 -1
- package/lib/Providers.js +9 -1
- package/lib/Providers.js.map +1 -1
- package/lib/Run/Job.d.ts +1 -1
- package/lib/Run/Service.d.ts +1 -1
- package/lib/ServiceNetworking/PsaConnection.d.ts +1 -1
- package/lib/ServiceUsage/ApiEnable.d.ts +1 -1
- package/lib/Sqladmin/Database.d.ts +1 -1
- package/lib/Sqladmin/Instance.d.ts +1 -1
- package/lib/Sqladmin/Instance.d.ts.map +1 -1
- package/lib/Sqladmin/Instance.js +28 -6
- package/lib/Sqladmin/Instance.js.map +1 -1
- package/lib/Sqladmin/User.d.ts +1 -1
- package/lib/Tags.d.ts +4 -4
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/ArtifactRegistry/Repository.ts +13 -9
- package/src/Auth/Credentials.ts +15 -4
- package/src/Compute/Address.ts +288 -0
- package/src/Compute/Firewall.ts +330 -0
- package/src/Compute/ForwardingRule.ts +301 -0
- package/src/Compute/index.ts +3 -0
- package/src/Container/Cluster.ts +25 -2
- package/src/Container/NodePool.ts +87 -13
- package/src/Kubernetes/Secret.ts +274 -0
- package/src/Kubernetes/client.ts +219 -0
- package/src/Kubernetes/index.ts +1 -0
- package/src/ManagedLustre/Instance.ts +35 -6
- package/src/Providers.ts +18 -0
- package/src/Sqladmin/Instance.ts +36 -7
- package/src/index.ts +1 -0
|
@@ -113,8 +113,31 @@ export type NodePoolProps = {
|
|
|
113
113
|
enableSecureBoot?: boolean;
|
|
114
114
|
enableIntegrityMonitoring?: boolean;
|
|
115
115
|
};
|
|
116
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Local SSDs via the legacy SCSI-era count field. Prefer
|
|
118
|
+
* `localNvmeSsdBlockConfig` / `ephemeralStorageLocalSsdConfig` for
|
|
119
|
+
* NVMe-only machine families (Gen3+, Titanium SSD). Mutually
|
|
120
|
+
* exclusive with both. **Replace-only**.
|
|
121
|
+
*/
|
|
117
122
|
localSsdCount?: number;
|
|
123
|
+
/**
|
|
124
|
+
* Raw-block local NVMe SSDs — exposed to workloads as unformatted
|
|
125
|
+
* block devices (local PVs / your own RAID). For machine types with
|
|
126
|
+
* a fixed disk count (e.g. g4-standard-48 → 4), `localSsdCount`
|
|
127
|
+
* must equal that count or be 0/unset for the machine default.
|
|
128
|
+
* **Replace-only**.
|
|
129
|
+
*/
|
|
130
|
+
localNvmeSsdBlockConfig?: { localSsdCount?: number };
|
|
131
|
+
/**
|
|
132
|
+
* Local NVMe SSDs backing node ephemeral storage (emptyDir, image
|
|
133
|
+
* layers, logs) — GKE RAID0s the disks. Same fixed-count rule as
|
|
134
|
+
* `localNvmeSsdBlockConfig`. `dataCacheCount` carves disks out for
|
|
135
|
+
* GKE Data Cache. **Replace-only**.
|
|
136
|
+
*/
|
|
137
|
+
ephemeralStorageLocalSsdConfig?: {
|
|
138
|
+
localSsdCount?: number;
|
|
139
|
+
dataCacheCount?: number;
|
|
140
|
+
};
|
|
118
141
|
/** Workload Identity metadata. Mutable via `update`. */
|
|
119
142
|
workloadMetadataConfig?: { mode: "GKE_METADATA" | "GCE_METADATA" };
|
|
120
143
|
/** GVNIC. Mutable via `update`. */
|
|
@@ -261,6 +284,12 @@ export const toNodeConfigCreateBody = (
|
|
|
261
284
|
...(config.localSsdCount !== undefined
|
|
262
285
|
? { localSsdCount: config.localSsdCount }
|
|
263
286
|
: {}),
|
|
287
|
+
...(config.localNvmeSsdBlockConfig
|
|
288
|
+
? { localNvmeSsdBlockConfig: config.localNvmeSsdBlockConfig }
|
|
289
|
+
: {}),
|
|
290
|
+
...(config.ephemeralStorageLocalSsdConfig
|
|
291
|
+
? { ephemeralStorageLocalSsdConfig: config.ephemeralStorageLocalSsdConfig }
|
|
292
|
+
: {}),
|
|
264
293
|
...(config.workloadMetadataConfig
|
|
265
294
|
? { workloadMetadataConfig: config.workloadMetadataConfig }
|
|
266
295
|
: {}),
|
|
@@ -320,8 +349,23 @@ const toNodePoolUpdateBody = (
|
|
|
320
349
|
if (nc.taints && !deepEqual(oc.taints ?? [], nc.taints)) {
|
|
321
350
|
body.taints = { taints: nc.taints };
|
|
322
351
|
}
|
|
323
|
-
|
|
324
|
-
|
|
352
|
+
// GKE injects its own `goog-*` resource labels (accelerator type,
|
|
353
|
+
// provisioning model, …) after create. Treat them as server-managed:
|
|
354
|
+
// fold the observed ones into the desired set before diffing, or
|
|
355
|
+
// every reconcile issues a labels update — which on a large pool is
|
|
356
|
+
// a long rolling cluster operation that wedges all other mutations
|
|
357
|
+
// on the cluster ("incompatible operation").
|
|
358
|
+
const observedResourceLabels = oc.resourceLabels ?? {};
|
|
359
|
+
const desiredWithServerManaged = {
|
|
360
|
+
...Object.fromEntries(
|
|
361
|
+
Object.entries(observedResourceLabels).filter(([k]) =>
|
|
362
|
+
k.startsWith("goog-"),
|
|
363
|
+
),
|
|
364
|
+
),
|
|
365
|
+
...desiredResourceLabels,
|
|
366
|
+
};
|
|
367
|
+
if (!deepEqual(observedResourceLabels, desiredWithServerManaged)) {
|
|
368
|
+
body.resourceLabels = { labels: desiredWithServerManaged };
|
|
325
369
|
}
|
|
326
370
|
|
|
327
371
|
if (nc.gvnic && !deepEqual(oc.gvnic, nc.gvnic)) {
|
|
@@ -365,6 +409,7 @@ export const NodePoolProvider = () =>
|
|
|
365
409
|
NodePool,
|
|
366
410
|
Effect.gen(function* () {
|
|
367
411
|
const getNodePools = yield* cont.getProjectsLocationsClustersNodePools;
|
|
412
|
+
const listNodePools = yield* cont.listProjectsLocationsClustersNodePools;
|
|
368
413
|
const createNodePools = yield* cont.createProjectsLocationsClustersNodePools;
|
|
369
414
|
const updateNodePools = yield* cont.updateProjectsLocationsClustersNodePools;
|
|
370
415
|
const setSize = yield* cont.setSizeProjectsLocationsClustersNodePools;
|
|
@@ -500,7 +545,12 @@ export const NodePoolProvider = () =>
|
|
|
500
545
|
!deepEqual(oc.metadata, nc.metadata) ||
|
|
501
546
|
!deepEqual(oc.sandboxConfig, nc.sandboxConfig) ||
|
|
502
547
|
!deepEqual(oc.reservationAffinity, nc.reservationAffinity) ||
|
|
503
|
-
!deepEqual(oc.shieldedInstanceConfig, nc.shieldedInstanceConfig)
|
|
548
|
+
!deepEqual(oc.shieldedInstanceConfig, nc.shieldedInstanceConfig) ||
|
|
549
|
+
!deepEqual(oc.localNvmeSsdBlockConfig, nc.localNvmeSsdBlockConfig) ||
|
|
550
|
+
!deepEqual(
|
|
551
|
+
oc.ephemeralStorageLocalSsdConfig,
|
|
552
|
+
nc.ephemeralStorageLocalSsdConfig,
|
|
553
|
+
)
|
|
504
554
|
) {
|
|
505
555
|
return { action: "replace" } as const;
|
|
506
556
|
}
|
|
@@ -596,15 +646,39 @@ export const NodePoolProvider = () =>
|
|
|
596
646
|
const location = output?.location ?? olds?.location;
|
|
597
647
|
const clusterName = output?.clusterName ?? olds?.clusterName;
|
|
598
648
|
if (!project || !location || !clusterName) return undefined;
|
|
599
|
-
const name =
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
649
|
+
const name = output?.name ?? olds?.name;
|
|
650
|
+
let observed: cont.NodePool | undefined;
|
|
651
|
+
if (name) {
|
|
652
|
+
const fqName = `projects/${project}/locations/${location}/clusters/${clusterName}/nodePools/${name}`;
|
|
653
|
+
observed = yield* getNodePools({ name: fqName }).pipe(
|
|
654
|
+
Effect.catchTag("NotFound", () => Effect.succeed(undefined as cont.NodePool | undefined)),
|
|
655
|
+
Effect.catchTag("Forbidden", () => Effect.succeed(undefined as cont.NodePool | undefined)),
|
|
656
|
+
);
|
|
657
|
+
} else {
|
|
658
|
+
// Cold recovery (lost state): the pool name truncates the
|
|
659
|
+
// logical id and appends a random per-instance suffix that
|
|
660
|
+
// lived only in state, so a probe against a freshly-generated
|
|
661
|
+
// name can never hit. Scan the cluster's pools for our
|
|
662
|
+
// alchemy labels (stamped into config.resourceLabels).
|
|
663
|
+
const page = yield* listNodePools({
|
|
664
|
+
parent: `projects/${project}/locations/${location}/clusters/${clusterName}`,
|
|
665
|
+
}).pipe(
|
|
666
|
+
Effect.catchTag("NotFound", () =>
|
|
667
|
+
Effect.succeed(undefined as cont.ListNodePoolsResponse | undefined),
|
|
668
|
+
),
|
|
669
|
+
Effect.catchTag("Forbidden", () =>
|
|
670
|
+
Effect.succeed(undefined as cont.ListNodePoolsResponse | undefined),
|
|
671
|
+
),
|
|
672
|
+
);
|
|
673
|
+
for (const candidate of page?.nodePools ?? []) {
|
|
674
|
+
if (
|
|
675
|
+
yield* hasAlchemyLabels(id, candidate.config?.resourceLabels)
|
|
676
|
+
) {
|
|
677
|
+
observed = candidate;
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
608
682
|
if (!observed) return undefined;
|
|
609
683
|
const attrs = toNodePoolAttributes(observed, {
|
|
610
684
|
project,
|
|
@@ -0,0 +1,274 @@
|
|
|
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 { createPhysicalName } from "alchemy/PhysicalName";
|
|
6
|
+
import * as Provider from "alchemy/Provider";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as Redacted from "effect/Redacted";
|
|
9
|
+
import type * as GCP from "../Providers.ts";
|
|
10
|
+
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";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An Opaque Kubernetes Secret in a GKE cluster.
|
|
22
|
+
*
|
|
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}.
|
|
29
|
+
*
|
|
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.
|
|
33
|
+
*
|
|
34
|
+
* @section Creating a Secret
|
|
35
|
+
* @example Wire a Cloudflare tunnel token into the cluster
|
|
36
|
+
* ```typescript
|
|
37
|
+
* // `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.
|
|
40
|
+
* yield* GCP.KubernetesSecret("CloudflaredTunnelSecret", {
|
|
41
|
+
* endpoint: cluster.endpoint,
|
|
42
|
+
* caCertificate: cluster.clusterCaCertificate,
|
|
43
|
+
* namespace: "admin",
|
|
44
|
+
* name: "cloudflared-tunnel",
|
|
45
|
+
* stringData: { TUNNEL_TOKEN: tunnel.token },
|
|
46
|
+
* }).pipe(adopt(true)); // take over a secret previously applied by kubectl
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export type KubernetesSecretProps = {
|
|
50
|
+
/**
|
|
51
|
+
* GKE control-plane endpoint (IP or hostname, no scheme) — typically
|
|
52
|
+
* `cluster.endpoint`. Mutable (a cluster replace yields a new one).
|
|
53
|
+
*/
|
|
54
|
+
endpoint: string;
|
|
55
|
+
/**
|
|
56
|
+
* Base64-encoded cluster CA certificate (PEM) — typically
|
|
57
|
+
* `cluster.clusterCaCertificate`. Mutable.
|
|
58
|
+
*/
|
|
59
|
+
caCertificate: string;
|
|
60
|
+
/** Target namespace. Immutable — replace if changed. */
|
|
61
|
+
namespace: string;
|
|
62
|
+
/**
|
|
63
|
+
* Secret name. Defaults to `createPhysicalName({ id, lowercase: true,
|
|
64
|
+
* maxLength: 63 })`. Immutable — replace if changed.
|
|
65
|
+
*/
|
|
66
|
+
name?: string;
|
|
67
|
+
/** Secret `type`. Default `"Opaque"`. Mutable. */
|
|
68
|
+
type?: string;
|
|
69
|
+
/**
|
|
70
|
+
* String data (written under `stringData`; UTF-8 values). Mutable.
|
|
71
|
+
*
|
|
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.
|
|
76
|
+
*
|
|
77
|
+
* NOTE: the underlying values are persisted in alchemy stack state
|
|
78
|
+
* (the engine diffs on the real value) — protect the state backend.
|
|
79
|
+
*/
|
|
80
|
+
stringData: Record<string, Redacted.Redacted<string> | string>;
|
|
81
|
+
/** Extra metadata labels (alchemy ownership labels merge on top). Mutable. */
|
|
82
|
+
labels?: Record<string, string>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type KubernetesSecretAttributes = {
|
|
86
|
+
/** Secret name. */
|
|
87
|
+
name: string;
|
|
88
|
+
/** Namespace. */
|
|
89
|
+
namespace: string;
|
|
90
|
+
/** Control-plane endpoint, threaded through for delete/read. */
|
|
91
|
+
endpoint: string;
|
|
92
|
+
/** Cluster CA certificate, threaded through for delete/read. */
|
|
93
|
+
caCertificate: string;
|
|
94
|
+
/** Secret type. */
|
|
95
|
+
type: string;
|
|
96
|
+
/** Server-assigned uid. */
|
|
97
|
+
uid: string | undefined;
|
|
98
|
+
/** Server-assigned resourceVersion at last apply. */
|
|
99
|
+
resourceVersion: string | undefined;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type KubernetesSecret = Resource<
|
|
103
|
+
"GCP.KubernetesSecret",
|
|
104
|
+
KubernetesSecretProps,
|
|
105
|
+
KubernetesSecretAttributes,
|
|
106
|
+
never,
|
|
107
|
+
GCP.Providers
|
|
108
|
+
>;
|
|
109
|
+
export const KubernetesSecret = Resource<KubernetesSecret>(
|
|
110
|
+
"GCP.KubernetesSecret",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const toAttributes = (
|
|
114
|
+
s: SecretObject | undefined,
|
|
115
|
+
parent: {
|
|
116
|
+
name: string;
|
|
117
|
+
namespace: string;
|
|
118
|
+
endpoint: string;
|
|
119
|
+
caCertificate: string;
|
|
120
|
+
type: string;
|
|
121
|
+
},
|
|
122
|
+
): KubernetesSecretAttributes => ({
|
|
123
|
+
name: s?.metadata?.name ?? parent.name,
|
|
124
|
+
namespace: s?.metadata?.namespace ?? parent.namespace,
|
|
125
|
+
endpoint: parent.endpoint,
|
|
126
|
+
caCertificate: parent.caCertificate,
|
|
127
|
+
type: s?.type ?? parent.type,
|
|
128
|
+
uid: s?.metadata?.uid,
|
|
129
|
+
resourceVersion: s?.metadata?.resourceVersion,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/** Mint a fresh GKE connection from props + ADC credentials. */
|
|
133
|
+
const connect = Effect.fn("k8sSecret.connect")(function* (props: {
|
|
134
|
+
endpoint: string;
|
|
135
|
+
caCertificate: string;
|
|
136
|
+
}) {
|
|
137
|
+
const { accessToken } = yield* yield* Credentials;
|
|
138
|
+
return {
|
|
139
|
+
endpoint: props.endpoint,
|
|
140
|
+
caCertificate: props.caCertificate,
|
|
141
|
+
token: Redacted.value(accessToken),
|
|
142
|
+
} satisfies GkeConnection;
|
|
143
|
+
});
|
|
144
|
+
|
|
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),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
export const KubernetesSecretProvider = () =>
|
|
155
|
+
Provider.effect(
|
|
156
|
+
KubernetesSecret,
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
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.
|
|
164
|
+
stables: ["name", "namespace", "endpoint", "uid"],
|
|
165
|
+
diff: Effect.fn(function* ({ id, news, olds = {} }) {
|
|
166
|
+
if (!isResolved(news)) return undefined;
|
|
167
|
+
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.
|
|
173
|
+
const defaultName = (
|
|
174
|
+
yield* createPhysicalName({ id, maxLength: 63 })
|
|
175
|
+
).toLowerCase();
|
|
176
|
+
const sameName = (o.name ?? defaultName) === (news.name ?? defaultName);
|
|
177
|
+
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.
|
|
182
|
+
// - `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.
|
|
185
|
+
if (
|
|
186
|
+
!sameName ||
|
|
187
|
+
!sameType ||
|
|
188
|
+
somePropsAreDifferent(o, news, ["namespace", "endpoint"])
|
|
189
|
+
) {
|
|
190
|
+
return { action: "replace" } as const;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}),
|
|
194
|
+
reconcile: Effect.fn(function* ({ id, news }) {
|
|
195
|
+
const desiredName =
|
|
196
|
+
news.name ??
|
|
197
|
+
(yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
|
|
198
|
+
const desiredType = news.type ?? "Opaque";
|
|
199
|
+
const labels = {
|
|
200
|
+
...(news.labels ?? {}),
|
|
201
|
+
...(yield* gcpInternalLabels(id)),
|
|
202
|
+
};
|
|
203
|
+
const connection = yield* connect(news);
|
|
204
|
+
|
|
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
|
+
}
|
|
214
|
+
|
|
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
|
+
});
|
|
223
|
+
|
|
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);
|
|
233
|
+
|
|
234
|
+
return toAttributes(final, {
|
|
235
|
+
name: desiredName,
|
|
236
|
+
namespace: news.namespace,
|
|
237
|
+
endpoint: news.endpoint,
|
|
238
|
+
caCertificate: news.caCertificate,
|
|
239
|
+
type: desiredType,
|
|
240
|
+
});
|
|
241
|
+
}),
|
|
242
|
+
delete: Effect.fn(function* ({ output }) {
|
|
243
|
+
const connection = yield* connect(output);
|
|
244
|
+
yield* deleteSecret(connection, output.namespace, output.name);
|
|
245
|
+
}),
|
|
246
|
+
read: Effect.fn(function* ({ id, output, olds }) {
|
|
247
|
+
const endpoint = output?.endpoint ?? olds?.endpoint;
|
|
248
|
+
const caCertificate = output?.caCertificate ?? olds?.caCertificate;
|
|
249
|
+
const namespace = output?.namespace ?? olds?.namespace;
|
|
250
|
+
if (!endpoint || !caCertificate || !namespace) return undefined;
|
|
251
|
+
const name =
|
|
252
|
+
output?.name ??
|
|
253
|
+
olds?.name ??
|
|
254
|
+
(yield* createPhysicalName({ id, maxLength: 63 })).toLowerCase();
|
|
255
|
+
const type = output?.type ?? olds?.type ?? "Opaque";
|
|
256
|
+
|
|
257
|
+
const connection = yield* connect({ endpoint, caCertificate });
|
|
258
|
+
const observed = yield* observe(connection, namespace, name);
|
|
259
|
+
if (!observed) return undefined;
|
|
260
|
+
|
|
261
|
+
const attrs = toAttributes(observed, {
|
|
262
|
+
name,
|
|
263
|
+
namespace,
|
|
264
|
+
endpoint,
|
|
265
|
+
caCertificate,
|
|
266
|
+
type,
|
|
267
|
+
});
|
|
268
|
+
return (yield* hasAlchemyLabels(id, observed.metadata?.labels))
|
|
269
|
+
? attrs
|
|
270
|
+
: Unowned(attrs);
|
|
271
|
+
}),
|
|
272
|
+
};
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as Data from "effect/Data";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as https from "node:https";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal GKE Kubernetes REST client.
|
|
7
|
+
*
|
|
8
|
+
* Talks to a GKE cluster's control plane over HTTPS using:
|
|
9
|
+
* - a Google OAuth bearer token (cloud-platform scope), minted from the
|
|
10
|
+
* provider's ADC {@link Credentials}, and
|
|
11
|
+
* - the cluster's own CA certificate for TLS verification.
|
|
12
|
+
*
|
|
13
|
+
* `node:https` is used deliberately here (rather than the Effect
|
|
14
|
+
* `HttpClient`): the GKE API server presents a certificate signed by the
|
|
15
|
+
* *per-cluster* CA — not a public root — so the request must trust an
|
|
16
|
+
* explicit, runtime-resolved `ca` PEM. This is the same justified TLS
|
|
17
|
+
* exception alchemy makes in its own (EKS-only) Kubernetes client. Calls
|
|
18
|
+
* are wrapped in `Effect.tryPromise` so failures surface as typed errors
|
|
19
|
+
* in the Effect runtime; a socket timeout bounds hung requests. (Effect
|
|
20
|
+
* interruption won't abort an in-flight socket — the timeout is what
|
|
21
|
+
* guarantees a deploy can't block forever.)
|
|
22
|
+
*/
|
|
23
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
24
|
+
|
|
25
|
+
/** Connection details for a single GKE control plane. */
|
|
26
|
+
export interface GkeConnection {
|
|
27
|
+
/** Master endpoint — IP or hostname, no scheme (e.g. `34.1.2.3`). */
|
|
28
|
+
endpoint: string;
|
|
29
|
+
/** Base64-encoded cluster CA certificate (PEM), as GKE returns it. */
|
|
30
|
+
caCertificate: string;
|
|
31
|
+
/** Google OAuth access token (cloud-platform scope) for `Authorization`. */
|
|
32
|
+
token: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A non-2xx (or transport) response from the Kubernetes API server. */
|
|
36
|
+
export class KubernetesApiError extends Data.TaggedError(
|
|
37
|
+
"KubernetesApiError",
|
|
38
|
+
)<{
|
|
39
|
+
method: string;
|
|
40
|
+
path: string;
|
|
41
|
+
statusCode: number;
|
|
42
|
+
body: string;
|
|
43
|
+
}> {}
|
|
44
|
+
|
|
45
|
+
const requestJson = Effect.fn("k8s.requestJson")(function* ({
|
|
46
|
+
connection,
|
|
47
|
+
method,
|
|
48
|
+
path,
|
|
49
|
+
body,
|
|
50
|
+
contentType,
|
|
51
|
+
}: {
|
|
52
|
+
connection: GkeConnection;
|
|
53
|
+
method: string;
|
|
54
|
+
path: string;
|
|
55
|
+
body?: Record<string, unknown>;
|
|
56
|
+
contentType?: string;
|
|
57
|
+
}) {
|
|
58
|
+
const url = new URL(path, `https://${connection.endpoint}`);
|
|
59
|
+
const payload = body ? JSON.stringify(body) : undefined;
|
|
60
|
+
const ca = Buffer.from(connection.caCertificate, "base64").toString("utf8");
|
|
61
|
+
|
|
62
|
+
return yield* Effect.tryPromise({
|
|
63
|
+
try: () =>
|
|
64
|
+
new Promise<unknown>((resolve, reject) => {
|
|
65
|
+
const request = https.request(
|
|
66
|
+
{
|
|
67
|
+
protocol: url.protocol,
|
|
68
|
+
hostname: url.hostname,
|
|
69
|
+
port: url.port || 443,
|
|
70
|
+
path: `${url.pathname}${url.search}`,
|
|
71
|
+
method,
|
|
72
|
+
headers: {
|
|
73
|
+
Authorization: `Bearer ${connection.token}`,
|
|
74
|
+
Accept: "application/json",
|
|
75
|
+
...(payload
|
|
76
|
+
? {
|
|
77
|
+
"Content-Type":
|
|
78
|
+
contentType ?? "application/json",
|
|
79
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
80
|
+
}
|
|
81
|
+
: {}),
|
|
82
|
+
},
|
|
83
|
+
ca,
|
|
84
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
85
|
+
},
|
|
86
|
+
(response) => {
|
|
87
|
+
const chunks: Buffer[] = [];
|
|
88
|
+
response.on("data", (chunk) => {
|
|
89
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
90
|
+
});
|
|
91
|
+
response.on("end", () => {
|
|
92
|
+
const responseBody = Buffer.concat(chunks).toString("utf8");
|
|
93
|
+
const statusCode = response.statusCode ?? 500;
|
|
94
|
+
|
|
95
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
96
|
+
reject(
|
|
97
|
+
new KubernetesApiError({
|
|
98
|
+
method,
|
|
99
|
+
path,
|
|
100
|
+
statusCode,
|
|
101
|
+
body: responseBody,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!responseBody.trim()) {
|
|
108
|
+
resolve(undefined);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
resolve(JSON.parse(responseBody));
|
|
114
|
+
} catch {
|
|
115
|
+
resolve(responseBody);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
request.on("error", reject);
|
|
122
|
+
// `timeout` only fires the event — we must destroy the socket
|
|
123
|
+
// ourselves so the promise rejects instead of hanging.
|
|
124
|
+
request.on("timeout", () =>
|
|
125
|
+
request.destroy(
|
|
126
|
+
new KubernetesApiError({
|
|
127
|
+
method,
|
|
128
|
+
path,
|
|
129
|
+
statusCode: 0,
|
|
130
|
+
body: `request timed out after ${REQUEST_TIMEOUT_MS}ms`,
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
if (payload) request.write(payload);
|
|
135
|
+
request.end();
|
|
136
|
+
}),
|
|
137
|
+
catch: (error) =>
|
|
138
|
+
error instanceof KubernetesApiError
|
|
139
|
+
? error
|
|
140
|
+
: new KubernetesApiError({
|
|
141
|
+
method,
|
|
142
|
+
path,
|
|
143
|
+
statusCode: 0,
|
|
144
|
+
body: error instanceof Error ? error.message : String(error),
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** Live view of a Kubernetes Secret object (subset we care about). */
|
|
150
|
+
export interface SecretObject {
|
|
151
|
+
apiVersion?: string;
|
|
152
|
+
kind?: string;
|
|
153
|
+
metadata?: {
|
|
154
|
+
name?: string;
|
|
155
|
+
namespace?: string;
|
|
156
|
+
uid?: string;
|
|
157
|
+
resourceVersion?: string;
|
|
158
|
+
labels?: Record<string, string>;
|
|
159
|
+
};
|
|
160
|
+
type?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const secretPath = (namespace: string, name: string) =>
|
|
164
|
+
`/api/v1/namespaces/${namespace}/secrets/${name}`;
|
|
165
|
+
|
|
166
|
+
/** GET a Secret; the caller maps `KubernetesApiError(404)` to "missing". */
|
|
167
|
+
export const getSecret = (
|
|
168
|
+
connection: GkeConnection,
|
|
169
|
+
namespace: string,
|
|
170
|
+
name: string,
|
|
171
|
+
) =>
|
|
172
|
+
requestJson({
|
|
173
|
+
connection,
|
|
174
|
+
method: "GET",
|
|
175
|
+
path: secretPath(namespace, name),
|
|
176
|
+
}) as Effect.Effect<SecretObject, KubernetesApiError>;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Server-side apply a Secret (idempotent create-or-update). `force=true`
|
|
180
|
+
* makes alchemy the field manager even if another manager owns fields.
|
|
181
|
+
*
|
|
182
|
+
* Values go through `data` (base64), NOT `stringData`: server-side apply
|
|
183
|
+
* tracks the field manager's ownership of `data` keys, so dropping a key
|
|
184
|
+
* from a later apply prunes it from the stored Secret. `stringData` is
|
|
185
|
+
* write-only/ephemeral and isn't tracked, which would leave stale keys.
|
|
186
|
+
*/
|
|
187
|
+
export const applySecret = (
|
|
188
|
+
connection: GkeConnection,
|
|
189
|
+
secret: {
|
|
190
|
+
metadata: { name: string; namespace: string; labels?: Record<string, string> };
|
|
191
|
+
type: string;
|
|
192
|
+
data: Record<string, string>;
|
|
193
|
+
},
|
|
194
|
+
) =>
|
|
195
|
+
requestJson({
|
|
196
|
+
connection,
|
|
197
|
+
method: "PATCH",
|
|
198
|
+
path: `${secretPath(secret.metadata.namespace, secret.metadata.name)}?fieldManager=alchemy&force=true`,
|
|
199
|
+
contentType: "application/apply-patch+yaml",
|
|
200
|
+
body: { apiVersion: "v1", kind: "Secret", ...secret },
|
|
201
|
+
}) as Effect.Effect<SecretObject | undefined, KubernetesApiError>;
|
|
202
|
+
|
|
203
|
+
/** DELETE a Secret; 404 is tolerated as success (idempotent teardown). */
|
|
204
|
+
export const deleteSecret = (
|
|
205
|
+
connection: GkeConnection,
|
|
206
|
+
namespace: string,
|
|
207
|
+
name: string,
|
|
208
|
+
) =>
|
|
209
|
+
requestJson({
|
|
210
|
+
connection,
|
|
211
|
+
method: "DELETE",
|
|
212
|
+
path: secretPath(namespace, name),
|
|
213
|
+
}).pipe(
|
|
214
|
+
Effect.catchIf(
|
|
215
|
+
(e): e is KubernetesApiError =>
|
|
216
|
+
e instanceof KubernetesApiError && e.statusCode === 404,
|
|
217
|
+
() => Effect.void,
|
|
218
|
+
),
|
|
219
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./Secret.ts";
|