@microagi/alchemy-gcp 0.8.1 → 0.9.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/lib/ArtifactRegistry/Operations.d.ts +2 -2
- package/lib/ArtifactRegistry/Operations.d.ts.map +1 -1
- package/lib/ArtifactRegistry/Repository.d.ts +1 -1
- package/lib/ArtifactRegistry/Validation.d.ts.map +1 -1
- package/lib/Auth/AuthProvider.d.ts.map +1 -1
- package/lib/Auth/AuthProvider.js.map +1 -1
- package/lib/Auth/Credentials.d.ts +1 -1
- package/lib/Auth/Credentials.d.ts.map +1 -1
- package/lib/CloudResourceManager/Project.d.ts +1 -1
- package/lib/CloudResourceManager/ProjectIamMember.d.ts.map +1 -1
- package/lib/Compute/Address.d.ts +1 -1
- package/lib/Compute/Firewall.d.ts +1 -1
- package/lib/Compute/ForwardingRule.d.ts +1 -1
- package/lib/Compute/GlobalAddress.d.ts +1 -1
- package/lib/Compute/Network.d.ts +1 -1
- package/lib/Compute/Network.d.ts.map +1 -1
- package/lib/Compute/Operations.d.ts +4 -4
- package/lib/Compute/Operations.d.ts.map +1 -1
- package/lib/Compute/Subnetwork.d.ts +1 -1
- package/lib/Compute/SubnetworkIamMember.d.ts.map +1 -1
- package/lib/Container/Cluster.d.ts +1 -1
- package/lib/Container/NodePool.d.ts +1 -1
- package/lib/Container/NodePool.d.ts.map +1 -1
- package/lib/Container/Operations.d.ts +2 -2
- package/lib/Container/Operations.d.ts.map +1 -1
- package/lib/Iam/ServiceAccount.d.ts +1 -1
- package/lib/Iam/ServiceAccountIamMember.d.ts.map +1 -1
- package/lib/Iam/ServiceAccountKey.js +1 -1
- package/lib/Iam/ServiceAccountKey.js.map +1 -1
- package/lib/Kubernetes/Helm.d.ts +132 -0
- package/lib/Kubernetes/Helm.d.ts.map +1 -0
- package/lib/Kubernetes/Helm.js +314 -0
- package/lib/Kubernetes/Helm.js.map +1 -0
- package/lib/Kubernetes/KubernetesManifest.d.ts +1 -1
- package/lib/Kubernetes/Secret.d.ts +1 -1
- package/lib/Kubernetes/client.d.ts.map +1 -1
- package/lib/Kubernetes/connection.d.ts.map +1 -1
- package/lib/Kubernetes/index.d.ts +1 -0
- package/lib/Kubernetes/index.d.ts.map +1 -1
- package/lib/Kubernetes/index.js +1 -0
- package/lib/Kubernetes/index.js.map +1 -1
- package/lib/ManagedLustre/Instance.d.ts +1 -1
- package/lib/Providers.d.ts +1 -1
- package/lib/Providers.d.ts.map +1 -1
- package/lib/Providers.js +3 -1
- package/lib/Providers.js.map +1 -1
- package/lib/Run/IamMember.d.ts.map +1 -1
- package/lib/Run/IamSync.d.ts.map +1 -1
- package/lib/Run/Job.d.ts +1 -1
- package/lib/Run/Operations.d.ts +2 -2
- package/lib/Run/Operations.d.ts.map +1 -1
- package/lib/Run/Service.d.ts +1 -1
- package/lib/Run/Validation.d.ts.map +1 -1
- package/lib/Sqladmin/Instance.d.ts +1 -1
- package/lib/Sqladmin/Operations.d.ts +2 -2
- package/lib/Sqladmin/Operations.d.ts.map +1 -1
- package/lib/Sqladmin/Types.d.ts.map +1 -1
- package/lib/Sqladmin/Validation.d.ts.map +1 -1
- package/lib/Storage/Bucket.d.ts +1 -1
- package/lib/Storage/BucketIamMember.d.ts.map +1 -1
- package/lib/Tags.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/Iam/ServiceAccountKey.ts +1 -1
- package/src/Kubernetes/Helm.ts +536 -0
- package/src/Kubernetes/index.ts +1 -0
- package/src/Providers.ts +6 -0
|
@@ -0,0 +1,536 @@
|
|
|
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 Data from "effect/Data";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as FileSystem from "effect/FileSystem";
|
|
9
|
+
import * as Path from "effect/Path";
|
|
10
|
+
import * as Redacted from "effect/Redacted";
|
|
11
|
+
import * as Stream from "effect/Stream";
|
|
12
|
+
import { ChildProcess } from "effect/unstable/process";
|
|
13
|
+
import type * as GCP from "../Providers.ts";
|
|
14
|
+
import { gcpInternalLabels, hasAlchemyLabels } from "../Tags.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Install or upgrade a **Helm release** in a GKE cluster.
|
|
18
|
+
*
|
|
19
|
+
* Shells out to the `helm` CLI (v3.8+, required for OCI chart support) with a
|
|
20
|
+
* temporary kubeconfig minted from the GKE cluster's endpoint + CA + an ADC
|
|
21
|
+
* bearer token. The release is branded with alchemy-internal labels
|
|
22
|
+
* (`alchemy_app` / `alchemy_stage` / `alchemy_id`) via `--labels`, so adoption
|
|
23
|
+
* gating works the same as labelled GCP resources and K8s manifests — a
|
|
24
|
+
* release created out of band reads back as {@link Unowned} until adopted.
|
|
25
|
+
*
|
|
26
|
+
* **Reconcile** runs `helm upgrade --install` (idempotent for both first
|
|
27
|
+
* install and subsequent upgrades). **Delete** runs `helm uninstall`. **Read**
|
|
28
|
+
* runs `helm status -o json` and checks the release's labels for ownership.
|
|
29
|
+
*
|
|
30
|
+
* Both OCI charts (`oci://…`) and repository charts (`repo/chart` with an
|
|
31
|
+
* optional `repoUrl`) are supported. Inline `values` are serialized to a
|
|
32
|
+
* temporary JSON file and passed via `--values`.
|
|
33
|
+
*
|
|
34
|
+
* @section Installing a chart
|
|
35
|
+
* @example OCI chart with inline values
|
|
36
|
+
* ```typescript
|
|
37
|
+
* yield* GCP.HelmRelease("ArchilCsi", {
|
|
38
|
+
* endpoint: cluster.endpoint,
|
|
39
|
+
* caCertificate: cluster.clusterCaCertificate,
|
|
40
|
+
* name: "archil-csi-driver",
|
|
41
|
+
* namespace: "archil-system",
|
|
42
|
+
* chart: "oci://registry-1.docker.io/archildata/csi-driver-chart",
|
|
43
|
+
* createNamespace: true,
|
|
44
|
+
* values: {
|
|
45
|
+
* tolerations: [
|
|
46
|
+
* { key: "nvidia.com/gpu", operator: "Equal", value: "present", effect: "NoSchedule" },
|
|
47
|
+
* ],
|
|
48
|
+
* },
|
|
49
|
+
* });
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export type HelmReleaseProps = {
|
|
53
|
+
/**
|
|
54
|
+
* GKE control-plane endpoint (IP or hostname, no scheme) — typically
|
|
55
|
+
* `cluster.endpoint`. A change points at a different cluster → replace.
|
|
56
|
+
*/
|
|
57
|
+
endpoint: string;
|
|
58
|
+
/**
|
|
59
|
+
* Base64-encoded cluster CA certificate (PEM) — typically
|
|
60
|
+
* `cluster.clusterCaCertificate`. Mutable (can rotate on the same cluster).
|
|
61
|
+
*/
|
|
62
|
+
caCertificate: string;
|
|
63
|
+
/** Release name (helm `--name`). Immutable — replace if changed. */
|
|
64
|
+
name: string;
|
|
65
|
+
/** Target namespace. Immutable — replace if changed. */
|
|
66
|
+
namespace: string;
|
|
67
|
+
/**
|
|
68
|
+
* Chart reference as you'd pass to `helm install`: an OCI URL
|
|
69
|
+
* (`oci://registry…/chart`), a `repo/chart` pair, or a local path.
|
|
70
|
+
* Immutable — replace if changed.
|
|
71
|
+
*/
|
|
72
|
+
chart: string;
|
|
73
|
+
/**
|
|
74
|
+
* Chart version to pin. Mutable (upgrading the chart version is an
|
|
75
|
+
* in-place `helm upgrade`).
|
|
76
|
+
*/
|
|
77
|
+
version?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Inline Helm values (serialized to a temp JSON file and passed via
|
|
80
|
+
* `--values`). Mutable.
|
|
81
|
+
*/
|
|
82
|
+
values?: Record<string, unknown>;
|
|
83
|
+
/**
|
|
84
|
+
* Create the namespace if it doesn't exist (`--create-namespace`).
|
|
85
|
+
* @default false
|
|
86
|
+
*/
|
|
87
|
+
createNamespace?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Helm repository URL to add before install (`helm repo add`). Only used
|
|
90
|
+
* when `chart` is a `repo/chart` reference (not OCI). The repo name is
|
|
91
|
+
* derived from the first segment of `chart`.
|
|
92
|
+
*/
|
|
93
|
+
repoUrl?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Wait for all Kubernetes resources to be ready before returning
|
|
96
|
+
* (`--wait --atomic`). Rolls back on failure.
|
|
97
|
+
* @default false
|
|
98
|
+
*/
|
|
99
|
+
wait?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Timeout for `--wait` (Helm duration format, e.g. `"5m"`, `"300s"`).
|
|
102
|
+
* @default "5m"
|
|
103
|
+
*/
|
|
104
|
+
timeout?: string;
|
|
105
|
+
/** Extra labels (alchemy ownership labels merge on top). Mutable. */
|
|
106
|
+
labels?: Record<string, string>;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type HelmReleaseAttributes = {
|
|
110
|
+
/** Release name. */
|
|
111
|
+
name: string;
|
|
112
|
+
/** Namespace. */
|
|
113
|
+
namespace: string;
|
|
114
|
+
/** Chart reference (as declared). */
|
|
115
|
+
chart: string;
|
|
116
|
+
/** Resolved chart version (from `helm status`). */
|
|
117
|
+
version: string | undefined;
|
|
118
|
+
/** Release status (`"deployed"`, `"failed"`, `"pending-rollout"`, …). */
|
|
119
|
+
status: string | undefined;
|
|
120
|
+
/** Release revision number (increments on each upgrade). */
|
|
121
|
+
revision: number | undefined;
|
|
122
|
+
/** Control-plane endpoint, threaded through for delete/read. */
|
|
123
|
+
endpoint: string;
|
|
124
|
+
/** Cluster CA certificate, threaded through for delete/read. */
|
|
125
|
+
caCertificate: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export type HelmRelease = Resource<
|
|
129
|
+
"GCP.HelmRelease",
|
|
130
|
+
HelmReleaseProps,
|
|
131
|
+
HelmReleaseAttributes,
|
|
132
|
+
never,
|
|
133
|
+
GCP.Providers
|
|
134
|
+
>;
|
|
135
|
+
export const HelmRelease = Resource<HelmRelease>(
|
|
136
|
+
"GCP.HelmRelease",
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/** Error from a `helm` CLI invocation. */
|
|
140
|
+
export class HelmError extends Data.TaggedError("HelmError")<{
|
|
141
|
+
command: string;
|
|
142
|
+
exitCode: number;
|
|
143
|
+
stderr: string;
|
|
144
|
+
stdout: string;
|
|
145
|
+
}> {}
|
|
146
|
+
|
|
147
|
+
/** Shape of `helm status -o json` output (only the fields we read). */
|
|
148
|
+
interface HelmReleaseStatus {
|
|
149
|
+
name: string;
|
|
150
|
+
namespace: string;
|
|
151
|
+
revision: number;
|
|
152
|
+
info: {
|
|
153
|
+
status: string;
|
|
154
|
+
first_deployed?: string;
|
|
155
|
+
last_deployed?: string;
|
|
156
|
+
};
|
|
157
|
+
chart: {
|
|
158
|
+
metadata: {
|
|
159
|
+
name: string;
|
|
160
|
+
version: string;
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
labels?: Record<string, string>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const toAttributes = (
|
|
167
|
+
status: HelmReleaseStatus | undefined,
|
|
168
|
+
parent: {
|
|
169
|
+
name: string;
|
|
170
|
+
namespace: string;
|
|
171
|
+
chart: string;
|
|
172
|
+
endpoint: string;
|
|
173
|
+
caCertificate: string;
|
|
174
|
+
},
|
|
175
|
+
): HelmReleaseAttributes => ({
|
|
176
|
+
name: status?.name ?? parent.name,
|
|
177
|
+
namespace: status?.namespace ?? parent.namespace,
|
|
178
|
+
chart: parent.chart,
|
|
179
|
+
version: status?.chart?.metadata?.version,
|
|
180
|
+
status: status?.info?.status,
|
|
181
|
+
revision: status?.revision,
|
|
182
|
+
endpoint: parent.endpoint,
|
|
183
|
+
caCertificate: parent.caCertificate,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/** Mint a fresh GKE connection (bearer token) from props + ADC credentials. */
|
|
187
|
+
const connect = Effect.fn("helm.connect")(function* (props: {
|
|
188
|
+
endpoint: string;
|
|
189
|
+
caCertificate: string;
|
|
190
|
+
}) {
|
|
191
|
+
const { accessToken } = yield* yield* Credentials;
|
|
192
|
+
return {
|
|
193
|
+
endpoint: props.endpoint,
|
|
194
|
+
caCertificate: props.caCertificate,
|
|
195
|
+
token: Redacted.value(accessToken),
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
export const HelmReleaseProvider = () =>
|
|
200
|
+
Provider.effect(
|
|
201
|
+
HelmRelease,
|
|
202
|
+
Effect.gen(function* () {
|
|
203
|
+
const fs = yield* FileSystem.FileSystem;
|
|
204
|
+
const path = yield* Path.Path;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Write a temp kubeconfig (and optional values file) to a temp dir,
|
|
208
|
+
* returning the paths. The temp dir is tied to a Scope via
|
|
209
|
+
* `acquireRelease` — if any step after `makeTempDirectory` fails, the
|
|
210
|
+
* dir (which contains the sensitive kubeconfig with an ADC bearer
|
|
211
|
+
* token) is removed immediately. Callers wrap their body in
|
|
212
|
+
* `Effect.scoped` so the dir also cleans up on success/interruption.
|
|
213
|
+
*/
|
|
214
|
+
const prepare = (props: {
|
|
215
|
+
endpoint: string;
|
|
216
|
+
caCertificate: string;
|
|
217
|
+
values?: Record<string, unknown>;
|
|
218
|
+
}) =>
|
|
219
|
+
Effect.acquireRelease(
|
|
220
|
+
Effect.gen(function* () {
|
|
221
|
+
const connection = yield* connect(props);
|
|
222
|
+
const dir = yield* fs.makeTempDirectory({
|
|
223
|
+
prefix: "helm-alchemy-",
|
|
224
|
+
});
|
|
225
|
+
const kubeconfigPath = path.join(dir, "kubeconfig.json");
|
|
226
|
+
const valuesPath = props.values
|
|
227
|
+
? path.join(dir, "values.json")
|
|
228
|
+
: undefined;
|
|
229
|
+
|
|
230
|
+
// Kubeconfig with the cluster's CA + the ADC bearer token. The CA
|
|
231
|
+
// is already base64-encoded (as GKE returns it), so it goes
|
|
232
|
+
// straight into `certificate-authority-data`.
|
|
233
|
+
const kubeconfig = JSON.stringify({
|
|
234
|
+
apiVersion: "v1",
|
|
235
|
+
kind: "Config",
|
|
236
|
+
clusters: [
|
|
237
|
+
{
|
|
238
|
+
name: "gke",
|
|
239
|
+
cluster: {
|
|
240
|
+
"certificate-authority-data": props.caCertificate,
|
|
241
|
+
server: `https://${props.endpoint}`,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
contexts: [
|
|
246
|
+
{
|
|
247
|
+
name: "gke",
|
|
248
|
+
context: { cluster: "gke", user: "gke-user" },
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
"current-context": "gke",
|
|
252
|
+
users: [
|
|
253
|
+
{
|
|
254
|
+
name: "gke-user",
|
|
255
|
+
user: { token: connection.token },
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
yield* fs.writeFileString(kubeconfigPath, kubeconfig);
|
|
260
|
+
|
|
261
|
+
if (valuesPath && props.values) {
|
|
262
|
+
// JSON is valid YAML — helm accepts it for --values.
|
|
263
|
+
yield* fs.writeFileString(valuesPath, JSON.stringify(props.values));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { dir, kubeconfigPath, valuesPath, connection };
|
|
267
|
+
}),
|
|
268
|
+
// Release: always remove the temp dir, even on success.
|
|
269
|
+
({ dir }) => fs.remove(dir, { recursive: true }).pipe(Effect.ignore),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Run a `helm` command with `KUBECONFIG` pointing at the temp file and
|
|
274
|
+
* `HELM_CONFIG_HOME`/`HELM_CACHE_HOME` isolated to the temp dir so
|
|
275
|
+
* `helm repo add`/`helm repo update` don't mutate the user's global
|
|
276
|
+
* Helm config. Returns stdout/stderr/exitCode. Scoped so the child
|
|
277
|
+
* process is cleaned up even on interruption.
|
|
278
|
+
*/
|
|
279
|
+
const runHelm = Effect.fn("helm.run")(function* (args: {
|
|
280
|
+
kubeconfigPath: string;
|
|
281
|
+
dir: string;
|
|
282
|
+
helmArgs: string[];
|
|
283
|
+
}) {
|
|
284
|
+
const cmd = ChildProcess.make("helm", args.helmArgs, {
|
|
285
|
+
shell: false,
|
|
286
|
+
env: {
|
|
287
|
+
...process.env,
|
|
288
|
+
KUBECONFIG: args.kubeconfigPath,
|
|
289
|
+
// Isolate helm config/cache to the temp dir so parallel
|
|
290
|
+
// reconciles or CI jobs don't race on the shared repository
|
|
291
|
+
// index (~/.config/helm/repositories.yaml).
|
|
292
|
+
HELM_CONFIG_HOME: args.dir,
|
|
293
|
+
HELM_CACHE_HOME: args.dir,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
const handle = yield* cmd;
|
|
297
|
+
const [exitCode, stdout, stderr] = yield* Effect.all(
|
|
298
|
+
[
|
|
299
|
+
handle.exitCode,
|
|
300
|
+
Stream.mkString(Stream.decodeText(handle.stdout)),
|
|
301
|
+
Stream.mkString(Stream.decodeText(handle.stderr)),
|
|
302
|
+
] as const,
|
|
303
|
+
{ concurrency: 3 },
|
|
304
|
+
);
|
|
305
|
+
return { exitCode, stdout, stderr };
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
/** Run a helm command, mapping a non-zero exit to a HelmError. */
|
|
309
|
+
const runHelmOrFail = Effect.fn("helm.runOrFail")(function* (args: {
|
|
310
|
+
kubeconfigPath: string;
|
|
311
|
+
dir: string;
|
|
312
|
+
helmArgs: string[];
|
|
313
|
+
}) {
|
|
314
|
+
const result = yield* runHelm(args);
|
|
315
|
+
if (result.exitCode !== 0) {
|
|
316
|
+
return yield* Effect.fail(
|
|
317
|
+
new HelmError({
|
|
318
|
+
command: `helm ${args.helmArgs.join(" ")}`,
|
|
319
|
+
exitCode: result.exitCode,
|
|
320
|
+
stderr: result.stderr,
|
|
321
|
+
stdout: result.stdout,
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return result;
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
// Identity (name/namespace/chart) and endpoint are unchanged by an
|
|
330
|
+
// in-place upgrade. revision increments on every upgrade — it is NOT
|
|
331
|
+
// stable (an update must persist the new revision).
|
|
332
|
+
stables: ["name", "namespace", "chart", "endpoint"],
|
|
333
|
+
diff: Effect.fn(function* ({ news, olds = {} }) {
|
|
334
|
+
if (!isResolved(news)) return undefined;
|
|
335
|
+
const o = olds as HelmReleaseProps;
|
|
336
|
+
// endpoint points at a specific cluster, and name/namespace/chart
|
|
337
|
+
// are the release's identity — any change orphans the old release.
|
|
338
|
+
if (
|
|
339
|
+
somePropsAreDifferent(o, news, [
|
|
340
|
+
"name",
|
|
341
|
+
"namespace",
|
|
342
|
+
"chart",
|
|
343
|
+
"endpoint",
|
|
344
|
+
])
|
|
345
|
+
) {
|
|
346
|
+
return { action: "replace" } as const;
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}),
|
|
350
|
+
reconcile: Effect.fn(function* ({ id, news, session }) {
|
|
351
|
+
return yield* Effect.scoped(
|
|
352
|
+
Effect.gen(function* () {
|
|
353
|
+
const { dir, kubeconfigPath, valuesPath } = yield* prepare(news);
|
|
354
|
+
|
|
355
|
+
// Add helm repo if needed (non-OCI charts with repoUrl).
|
|
356
|
+
if (news.repoUrl && !news.chart.startsWith("oci://")) {
|
|
357
|
+
const repoName = news.chart.split("/")[0];
|
|
358
|
+
yield* session.note(`Adding helm repo: ${repoName}`);
|
|
359
|
+
yield* runHelmOrFail({
|
|
360
|
+
dir,
|
|
361
|
+
kubeconfigPath,
|
|
362
|
+
helmArgs: [
|
|
363
|
+
"repo",
|
|
364
|
+
"add",
|
|
365
|
+
repoName,
|
|
366
|
+
news.repoUrl,
|
|
367
|
+
"--force-update",
|
|
368
|
+
],
|
|
369
|
+
});
|
|
370
|
+
yield* runHelmOrFail({
|
|
371
|
+
dir,
|
|
372
|
+
kubeconfigPath,
|
|
373
|
+
helmArgs: ["repo", "update"],
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Build alchemy ownership labels.
|
|
378
|
+
const labels = {
|
|
379
|
+
...(news.labels ?? {}),
|
|
380
|
+
...(yield* gcpInternalLabels(id)),
|
|
381
|
+
};
|
|
382
|
+
const labelsFlag = Object.entries(labels)
|
|
383
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
384
|
+
.join(",");
|
|
385
|
+
|
|
386
|
+
// helm upgrade --install is idempotent for both first install
|
|
387
|
+
// and subsequent upgrades. --reset-values makes the declared
|
|
388
|
+
// `values` authoritative: if a key is removed from `values`,
|
|
389
|
+
// the next upgrade reverts it to the chart default rather than
|
|
390
|
+
// silently retaining the prior release's value.
|
|
391
|
+
const helmArgs = [
|
|
392
|
+
"upgrade",
|
|
393
|
+
"--install",
|
|
394
|
+
"--reset-values",
|
|
395
|
+
news.name,
|
|
396
|
+
news.chart,
|
|
397
|
+
"--namespace",
|
|
398
|
+
news.namespace,
|
|
399
|
+
...(news.createNamespace ? ["--create-namespace"] : []),
|
|
400
|
+
...(news.version ? ["--version", news.version] : []),
|
|
401
|
+
...(valuesPath ? ["--values", valuesPath] : []),
|
|
402
|
+
...(labelsFlag ? ["--labels", labelsFlag] : []),
|
|
403
|
+
...(news.wait
|
|
404
|
+
? ["--wait", "--atomic", "--timeout", news.timeout ?? "5m"]
|
|
405
|
+
: []),
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
yield* session.note(
|
|
409
|
+
`Running helm upgrade --install ${news.name} ${news.chart}`,
|
|
410
|
+
);
|
|
411
|
+
yield* runHelmOrFail({ dir, kubeconfigPath, helmArgs });
|
|
412
|
+
|
|
413
|
+
// Read the release status to return fresh attributes.
|
|
414
|
+
const statusResult = yield* runHelmOrFail({
|
|
415
|
+
dir,
|
|
416
|
+
kubeconfigPath,
|
|
417
|
+
helmArgs: [
|
|
418
|
+
"status",
|
|
419
|
+
news.name,
|
|
420
|
+
"--namespace",
|
|
421
|
+
news.namespace,
|
|
422
|
+
"-o",
|
|
423
|
+
"json",
|
|
424
|
+
],
|
|
425
|
+
});
|
|
426
|
+
const status = JSON.parse(
|
|
427
|
+
statusResult.stdout,
|
|
428
|
+
) as HelmReleaseStatus;
|
|
429
|
+
|
|
430
|
+
return toAttributes(status, {
|
|
431
|
+
name: news.name,
|
|
432
|
+
namespace: news.namespace,
|
|
433
|
+
chart: news.chart,
|
|
434
|
+
endpoint: news.endpoint,
|
|
435
|
+
caCertificate: news.caCertificate,
|
|
436
|
+
});
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
}),
|
|
440
|
+
delete: Effect.fn(function* ({ output }) {
|
|
441
|
+
yield* Effect.scoped(
|
|
442
|
+
Effect.gen(function* () {
|
|
443
|
+
const { dir, kubeconfigPath } = yield* prepare(output);
|
|
444
|
+
|
|
445
|
+
const result = yield* runHelm({
|
|
446
|
+
dir,
|
|
447
|
+
kubeconfigPath,
|
|
448
|
+
helmArgs: [
|
|
449
|
+
"uninstall",
|
|
450
|
+
output.name,
|
|
451
|
+
"--namespace",
|
|
452
|
+
output.namespace,
|
|
453
|
+
],
|
|
454
|
+
});
|
|
455
|
+
// "release: not found" is success (idempotent teardown).
|
|
456
|
+
if (
|
|
457
|
+
result.exitCode !== 0 &&
|
|
458
|
+
!/not found/i.test(result.stderr)
|
|
459
|
+
) {
|
|
460
|
+
return yield* Effect.fail(
|
|
461
|
+
new HelmError({
|
|
462
|
+
command: `helm uninstall ${output.name}`,
|
|
463
|
+
exitCode: result.exitCode,
|
|
464
|
+
stderr: result.stderr,
|
|
465
|
+
stdout: result.stdout,
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
}),
|
|
472
|
+
read: Effect.fn(function* ({ id, output, olds }) {
|
|
473
|
+
const endpoint = output?.endpoint ?? olds?.endpoint;
|
|
474
|
+
const caCertificate = output?.caCertificate ?? olds?.caCertificate;
|
|
475
|
+
const name = output?.name ?? olds?.name;
|
|
476
|
+
const namespace = output?.namespace ?? olds?.namespace;
|
|
477
|
+
const chart = output?.chart ?? olds?.chart;
|
|
478
|
+
if (!endpoint || !caCertificate || !name || !namespace || !chart) {
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return yield* Effect.scoped(
|
|
483
|
+
Effect.gen(function* () {
|
|
484
|
+
const { dir, kubeconfigPath } = yield* prepare({
|
|
485
|
+
endpoint,
|
|
486
|
+
caCertificate,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const result = yield* runHelm({
|
|
490
|
+
dir,
|
|
491
|
+
kubeconfigPath,
|
|
492
|
+
helmArgs: [
|
|
493
|
+
"status",
|
|
494
|
+
name,
|
|
495
|
+
"--namespace",
|
|
496
|
+
namespace,
|
|
497
|
+
"-o",
|
|
498
|
+
"json",
|
|
499
|
+
],
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Release not found → absent.
|
|
503
|
+
if (
|
|
504
|
+
result.exitCode !== 0 &&
|
|
505
|
+
/not found/i.test(result.stderr)
|
|
506
|
+
) {
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
if (result.exitCode !== 0) {
|
|
510
|
+
return yield* Effect.fail(
|
|
511
|
+
new HelmError({
|
|
512
|
+
command: `helm status ${name}`,
|
|
513
|
+
exitCode: result.exitCode,
|
|
514
|
+
stderr: result.stderr,
|
|
515
|
+
stdout: result.stdout,
|
|
516
|
+
}),
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const status = JSON.parse(result.stdout) as HelmReleaseStatus;
|
|
521
|
+
const attrs = toAttributes(status, {
|
|
522
|
+
name,
|
|
523
|
+
namespace,
|
|
524
|
+
chart,
|
|
525
|
+
endpoint,
|
|
526
|
+
caCertificate,
|
|
527
|
+
});
|
|
528
|
+
return (yield* hasAlchemyLabels(id, status.labels))
|
|
529
|
+
? attrs
|
|
530
|
+
: Unowned(attrs);
|
|
531
|
+
}),
|
|
532
|
+
);
|
|
533
|
+
}),
|
|
534
|
+
};
|
|
535
|
+
}),
|
|
536
|
+
);
|
package/src/Kubernetes/index.ts
CHANGED
package/src/Providers.ts
CHANGED
|
@@ -31,6 +31,10 @@ import {
|
|
|
31
31
|
ServiceAccountKey,
|
|
32
32
|
ServiceAccountKeyProvider,
|
|
33
33
|
} from "./Iam/ServiceAccountKey.ts";
|
|
34
|
+
import {
|
|
35
|
+
HelmRelease,
|
|
36
|
+
HelmReleaseProvider,
|
|
37
|
+
} from "./Kubernetes/Helm.ts";
|
|
34
38
|
import {
|
|
35
39
|
KubernetesManifest,
|
|
36
40
|
KubernetesManifestProvider,
|
|
@@ -106,6 +110,7 @@ export const providers = () =>
|
|
|
106
110
|
ServiceAccountKey,
|
|
107
111
|
KubernetesSecret,
|
|
108
112
|
KubernetesManifest,
|
|
113
|
+
HelmRelease,
|
|
109
114
|
]),
|
|
110
115
|
).pipe(
|
|
111
116
|
Layer.provide(
|
|
@@ -135,6 +140,7 @@ export const providers = () =>
|
|
|
135
140
|
ServiceAccountKeyProvider(),
|
|
136
141
|
KubernetesSecretProvider(),
|
|
137
142
|
KubernetesManifestProvider(),
|
|
143
|
+
HelmReleaseProvider(),
|
|
138
144
|
),
|
|
139
145
|
),
|
|
140
146
|
Layer.provideMerge(fromAuthProvider()),
|