@alchemy.run/kubernetes 0.0.0 → 2.0.0-beta.84
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/LICENSE +201 -0
- package/README.md +41 -4
- package/lib/BuiltinAdapters.d.ts +23 -0
- package/lib/BuiltinAdapters.d.ts.map +1 -0
- package/lib/BuiltinAdapters.js +124 -0
- package/lib/BuiltinAdapters.js.map +1 -0
- package/lib/Client.d.ts +80 -0
- package/lib/Client.d.ts.map +1 -0
- package/lib/Client.js +296 -0
- package/lib/Client.js.map +1 -0
- package/lib/ClusterAdapter.d.ts +322 -0
- package/lib/ClusterAdapter.d.ts.map +1 -0
- package/lib/ClusterAdapter.js +65 -0
- package/lib/ClusterAdapter.js.map +1 -0
- package/lib/Connection.d.ts +210 -0
- package/lib/Connection.d.ts.map +1 -0
- package/lib/Connection.js +58 -0
- package/lib/Connection.js.map +1 -0
- package/lib/Deployment.d.ts +382 -0
- package/lib/Deployment.d.ts.map +1 -0
- package/lib/Deployment.js +533 -0
- package/lib/Deployment.js.map +1 -0
- package/lib/HelmChart.d.ts +147 -0
- package/lib/HelmChart.d.ts.map +1 -0
- package/lib/HelmChart.js +248 -0
- package/lib/HelmChart.js.map +1 -0
- package/lib/Job.d.ts +314 -0
- package/lib/Job.d.ts.map +1 -0
- package/lib/Job.js +426 -0
- package/lib/Job.js.map +1 -0
- package/lib/LocalCluster.d.ts +243 -0
- package/lib/LocalCluster.d.ts.map +1 -0
- package/lib/LocalCluster.js +359 -0
- package/lib/LocalCluster.js.map +1 -0
- package/lib/Manifest.d.ts +148 -0
- package/lib/Manifest.d.ts.map +1 -0
- package/lib/Manifest.js +200 -0
- package/lib/Manifest.js.map +1 -0
- package/lib/Objects.d.ts +60 -0
- package/lib/Objects.d.ts.map +1 -0
- package/lib/Objects.js +132 -0
- package/lib/Objects.js.map +1 -0
- package/lib/Providers.d.ts +24 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +36 -0
- package/lib/Providers.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +10 -0
- package/lib/index.js.map +1 -0
- package/lib/internal/declared.d.ts +19 -0
- package/lib/internal/declared.d.ts.map +1 -0
- package/lib/internal/declared.js +435 -0
- package/lib/internal/declared.js.map +1 -0
- package/lib/internal/helm.d.ts +65 -0
- package/lib/internal/helm.d.ts.map +1 -0
- package/lib/internal/helm.js +173 -0
- package/lib/internal/helm.js.map +1 -0
- package/lib/internal/kind.d.ts +17 -0
- package/lib/internal/kind.d.ts.map +1 -0
- package/lib/internal/kind.js +31 -0
- package/lib/internal/kind.js.map +1 -0
- package/lib/internal/kubeconfig.d.ts +79 -0
- package/lib/internal/kubeconfig.d.ts.map +1 -0
- package/lib/internal/kubeconfig.js +222 -0
- package/lib/internal/kubeconfig.js.map +1 -0
- package/lib/internal/registry.d.ts +44 -0
- package/lib/internal/registry.d.ts.map +1 -0
- package/lib/internal/registry.js +72 -0
- package/lib/internal/registry.js.map +1 -0
- package/lib/internal/workload.d.ts +127 -0
- package/lib/internal/workload.d.ts.map +1 -0
- package/lib/internal/workload.js +411 -0
- package/lib/internal/workload.js.map +1 -0
- package/package.json +55 -3
- package/src/BuiltinAdapters.ts +194 -0
- package/src/Client.ts +465 -0
- package/src/ClusterAdapter.ts +370 -0
- package/src/Connection.ts +234 -0
- package/src/Deployment.ts +851 -0
- package/src/HelmChart.ts +388 -0
- package/src/Job.ts +689 -0
- package/src/LocalCluster.ts +631 -0
- package/src/Manifest.ts +295 -0
- package/src/Objects.ts +213 -0
- package/src/Providers.ts +52 -0
- package/src/globals.d.ts +2 -0
- package/src/index.ts +9 -0
- package/src/internal/declared.ts +471 -0
- package/src/internal/helm.ts +245 -0
- package/src/internal/kind.ts +43 -0
- package/src/internal/kubeconfig.ts +364 -0
- package/src/internal/registry.ts +108 -0
- package/src/internal/workload.ts +503 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { RegistryCredentials } from "@alchemy.run/docker/Docker";
|
|
2
|
+
import {
|
|
3
|
+
makeContainerImageSource,
|
|
4
|
+
type ImageRegistryTarget,
|
|
5
|
+
} from "@alchemy.run/docker/ImageSource";
|
|
6
|
+
import { createPhysicalName } from "alchemy/PhysicalName";
|
|
7
|
+
/**
|
|
8
|
+
* Image publishing for connections that declare a
|
|
9
|
+
* {@link Connection.registry}: `main` programs and `context` Dockerfiles are
|
|
10
|
+
* built on the deploying machine and pushed to `<server>/<name>:<hash>`,
|
|
11
|
+
* where the cluster's nodes pull them. Used when the cluster's platform
|
|
12
|
+
* adapter has no managed registry of its own (EKS pushes to ECR instead).
|
|
13
|
+
*/
|
|
14
|
+
import * as Effect from "effect/Effect";
|
|
15
|
+
import type { RegistryState, WorkloadImageSource } from "../ClusterAdapter.ts";
|
|
16
|
+
import type { ContainerRegistry } from "../Connection.ts";
|
|
17
|
+
|
|
18
|
+
/** Docker credentials for a registry, or `undefined` to use `docker login`. */
|
|
19
|
+
const credentialsOf = (registry: ContainerRegistry): RegistryCredentials | undefined =>
|
|
20
|
+
registry.username !== undefined && registry.password !== undefined
|
|
21
|
+
? {
|
|
22
|
+
// Docker keys credentials by host; `server` may carry a namespace.
|
|
23
|
+
server: registry.server.split("/")[0]!,
|
|
24
|
+
username: registry.username,
|
|
25
|
+
password: registry.password,
|
|
26
|
+
}
|
|
27
|
+
: undefined;
|
|
28
|
+
|
|
29
|
+
const trimServer = (server: string) => server.replace(/\/+$/, "");
|
|
30
|
+
|
|
31
|
+
export const makeConnectionRegistry = Effect.gen(function* () {
|
|
32
|
+
const images = yield* makeContainerImageSource;
|
|
33
|
+
|
|
34
|
+
const resolve = Effect.fn(function* (options: {
|
|
35
|
+
id: string;
|
|
36
|
+
registry: ContainerRegistry;
|
|
37
|
+
source: WorkloadImageSource;
|
|
38
|
+
platform: string;
|
|
39
|
+
port?: number | undefined;
|
|
40
|
+
isExternal?: boolean | undefined;
|
|
41
|
+
bootstrap: (importPath: string) => string;
|
|
42
|
+
state: Record<string, unknown> | undefined;
|
|
43
|
+
session: { note: (message: string) => Effect.Effect<void> };
|
|
44
|
+
}) {
|
|
45
|
+
const server = trimServer(options.registry.server);
|
|
46
|
+
// Keep the repository stable across updates; a changed `server` starts
|
|
47
|
+
// a new one.
|
|
48
|
+
const repository =
|
|
49
|
+
options.state?.kind === "registry" &&
|
|
50
|
+
options.state.server === server &&
|
|
51
|
+
typeof options.state.repository === "string"
|
|
52
|
+
? options.state.repository
|
|
53
|
+
: `${server}/${yield* createPhysicalName({
|
|
54
|
+
id: options.id,
|
|
55
|
+
maxLength: 96,
|
|
56
|
+
lowercase: true,
|
|
57
|
+
})}`;
|
|
58
|
+
const target: ImageRegistryTarget = {
|
|
59
|
+
repositoryUri: Effect.succeed(repository),
|
|
60
|
+
// Generic registries have no cheap existence probe; resolve only runs
|
|
61
|
+
// when the workload changes, and the local build cache makes a
|
|
62
|
+
// rebuild of unchanged content fast.
|
|
63
|
+
hasTag: () => Effect.succeed(false),
|
|
64
|
+
credentials: Effect.succeed(credentialsOf(options.registry)),
|
|
65
|
+
};
|
|
66
|
+
const resolved = yield* images.resolve(
|
|
67
|
+
{
|
|
68
|
+
id: options.id,
|
|
69
|
+
source: options.source,
|
|
70
|
+
platform: options.platform,
|
|
71
|
+
port: options.port,
|
|
72
|
+
isExternal: options.isExternal,
|
|
73
|
+
bootstrap: options.bootstrap,
|
|
74
|
+
session: options.session,
|
|
75
|
+
},
|
|
76
|
+
target,
|
|
77
|
+
);
|
|
78
|
+
return {
|
|
79
|
+
imageUri: resolved.imageUri,
|
|
80
|
+
codeHash: resolved.codeHash,
|
|
81
|
+
state: {
|
|
82
|
+
kind: "registry" as const,
|
|
83
|
+
server,
|
|
84
|
+
repository,
|
|
85
|
+
} satisfies RegistryState,
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const hash = (options: {
|
|
90
|
+
source: WorkloadImageSource;
|
|
91
|
+
platform: string;
|
|
92
|
+
port?: number | undefined;
|
|
93
|
+
isExternal?: boolean | undefined;
|
|
94
|
+
bootstrap: (importPath: string) => string;
|
|
95
|
+
}) =>
|
|
96
|
+
images.hash({
|
|
97
|
+
source: options.source,
|
|
98
|
+
platform: options.platform,
|
|
99
|
+
port: options.port,
|
|
100
|
+
isExternal: options.isExternal,
|
|
101
|
+
bootstrap: options.bootstrap,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return { resolve, hash };
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** The publisher returned by {@link makeConnectionRegistry}. */
|
|
108
|
+
export interface ConnectionRegistry extends Effect.Success<typeof makeConnectionRegistry> {}
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { isInlineDockerfile } from "@alchemy.run/docker/Dockerfile";
|
|
2
|
+
import { hashDirectory } from "alchemy/Command/Memo";
|
|
3
|
+
import type { ResourceBinding } from "alchemy/Resource";
|
|
4
|
+
import { Self } from "alchemy/Self";
|
|
5
|
+
import { unwrapRedacted } from "alchemy/Util/data";
|
|
6
|
+
import { collectRedactedSecrets } from "alchemy/Util/Redaction";
|
|
7
|
+
import { sha256Object } from "alchemy/Util/sha256";
|
|
8
|
+
/**
|
|
9
|
+
* Internal helpers shared by the Kubernetes workload platforms
|
|
10
|
+
* (`Kubernetes.Deployment`, `Kubernetes.Job`): image-source dispatch
|
|
11
|
+
* through the cluster adapter, binding-env collection, connection
|
|
12
|
+
* identity, and the platform-neutral generated container entries.
|
|
13
|
+
*/
|
|
14
|
+
import * as Effect from "effect/Effect";
|
|
15
|
+
import * as FileSystem from "effect/FileSystem";
|
|
16
|
+
import * as Path from "effect/Path";
|
|
17
|
+
import * as Redacted from "effect/Redacted";
|
|
18
|
+
import type {
|
|
19
|
+
ClusterAdapterService,
|
|
20
|
+
IdentityState,
|
|
21
|
+
WorkloadBindingContract,
|
|
22
|
+
WorkloadImageSource,
|
|
23
|
+
} from "../ClusterAdapter.ts";
|
|
24
|
+
import type { ClusterLike, Connection, ConnectionAuth } from "../Connection.ts";
|
|
25
|
+
import type { ConnectionRegistry } from "./registry.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Structural deep merge: objects merge recursively; arrays and primitives
|
|
29
|
+
* from `override` replace the base value wholesale. Powers escape hatches
|
|
30
|
+
* like `Kubernetes.Deployment.podTemplate`.
|
|
31
|
+
*/
|
|
32
|
+
export const deepMerge = <T>(base: T, override: unknown): T => {
|
|
33
|
+
if (override === undefined) return base;
|
|
34
|
+
if (
|
|
35
|
+
base === null ||
|
|
36
|
+
override === null ||
|
|
37
|
+
typeof base !== "object" ||
|
|
38
|
+
typeof override !== "object" ||
|
|
39
|
+
Array.isArray(base) ||
|
|
40
|
+
Array.isArray(override)
|
|
41
|
+
) {
|
|
42
|
+
return override as T;
|
|
43
|
+
}
|
|
44
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
45
|
+
for (const [key, value] of Object.entries(override)) {
|
|
46
|
+
out[key] =
|
|
47
|
+
key in (base as Record<string, unknown>)
|
|
48
|
+
? deepMerge((base as Record<string, unknown>)[key], value)
|
|
49
|
+
: value;
|
|
50
|
+
}
|
|
51
|
+
return out as T;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The image build platform of a workload: its own `architecture`, else the
|
|
56
|
+
* connection's node architecture, else `linux/amd64`.
|
|
57
|
+
*/
|
|
58
|
+
export const imagePlatformOf = (
|
|
59
|
+
architecture: "amd64" | "arm64" | undefined,
|
|
60
|
+
connection?: Connection | undefined,
|
|
61
|
+
): string =>
|
|
62
|
+
(architecture ?? connection?.architecture) === "arm64" ? "linux/arm64" : "linux/amd64";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Best-effort {@link Connection} of a `cluster` prop value — `undefined`
|
|
66
|
+
* instead of throwing, for plan-time diffs where the referenced resource
|
|
67
|
+
* may resolve to stables-only (or `{}`).
|
|
68
|
+
*/
|
|
69
|
+
export const tryConnectionOf = (cluster: ClusterLike | undefined): Connection | undefined => {
|
|
70
|
+
if (cluster === undefined) return undefined;
|
|
71
|
+
if ("auth" in cluster && cluster.auth !== undefined) return cluster;
|
|
72
|
+
if ("connection" in cluster && cluster.connection !== undefined) {
|
|
73
|
+
return cluster.connection;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const sortKeysDeep = (value: unknown): unknown => {
|
|
79
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
80
|
+
if (value === null || typeof value !== "object") return value;
|
|
81
|
+
return Object.fromEntries(
|
|
82
|
+
Object.entries(value as Record<string, unknown>)
|
|
83
|
+
.filter(([, entry]) => entry !== undefined)
|
|
84
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
85
|
+
.map(([key, entry]) => [key, sortKeysDeep(entry)]),
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A stable identity string for a connection's *target cluster* — the auth
|
|
91
|
+
* descriptor (which names the cluster for managed clouds and the
|
|
92
|
+
* kubeconfig context otherwise). Changing it means the workload moves
|
|
93
|
+
* clusters, which is a replacement. Deliberately excludes `endpoint` /
|
|
94
|
+
* CA: managed clusters can rotate those in place.
|
|
95
|
+
*/
|
|
96
|
+
export const connectionIdentity = (connection: Connection | undefined): string | undefined =>
|
|
97
|
+
connection === undefined ? undefined : JSON.stringify(sortKeysDeep(connection.auth));
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The persisted connection of a workload's attributes, tolerating legacy
|
|
101
|
+
* pre-rename rows (`AWS.EKS.*` attributes carried a flat `clusterName`
|
|
102
|
+
* instead of a `connection`) by synthesizing an `aws-eks` connection —
|
|
103
|
+
* the only platform those legacy types could target.
|
|
104
|
+
*/
|
|
105
|
+
export const connectionOfOutput = (output: Record<string, unknown>): Connection | undefined => {
|
|
106
|
+
const connection = output.connection as Connection | undefined;
|
|
107
|
+
if (connection?.auth !== undefined) return connection;
|
|
108
|
+
if (typeof output.clusterName === "string") {
|
|
109
|
+
return {
|
|
110
|
+
auth: { kind: "aws-eks", clusterName: output.clusterName } as unknown as ConnectionAuth,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Collect environment variables from a host's active bindings and report
|
|
118
|
+
* which cloud-grant channels (any binding-data key besides `env`, e.g.
|
|
119
|
+
* AWS `policyStatements`) are in play — the caller fails when grants
|
|
120
|
+
* exist but the cluster has no identity adapter to materialize them.
|
|
121
|
+
*/
|
|
122
|
+
export const collectBindingEnv = (
|
|
123
|
+
bindings: ResourceBinding<WorkloadBindingContract>[],
|
|
124
|
+
): { env: Record<string, any>; grantKeys: string[] } => {
|
|
125
|
+
const activeBindings = bindings.filter(
|
|
126
|
+
(binding: ResourceBinding<WorkloadBindingContract> & { action?: string }) =>
|
|
127
|
+
binding.action !== "delete",
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const env = activeBindings
|
|
131
|
+
.map((binding) => binding?.data?.env)
|
|
132
|
+
.reduce<Record<string, any>>((acc, value) => ({ ...acc, ...value }), {});
|
|
133
|
+
|
|
134
|
+
const grantKeys = [
|
|
135
|
+
...new Set(
|
|
136
|
+
activeBindings.flatMap((binding) =>
|
|
137
|
+
Object.keys(binding?.data ?? {}).filter(
|
|
138
|
+
(key) => key !== "env" && (binding.data as Record<string, unknown>)[key] !== undefined,
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
return { env, grantKeys };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Kubernetes env values are strings. A {@link Redacted} string stays wrapped
|
|
149
|
+
* so the request serializer unwraps it. Any other value that holds a secret
|
|
150
|
+
* is JSON, still wrapped, so a failed apply can scrub the inner strings.
|
|
151
|
+
*/
|
|
152
|
+
export const containerEnvValue = (value: unknown): string | Redacted.Redacted<string> => {
|
|
153
|
+
if (typeof value === "string") return value;
|
|
154
|
+
if (Redacted.isRedacted(value)) {
|
|
155
|
+
const inner = Redacted.value(value);
|
|
156
|
+
if (typeof inner === "string") return Redacted.make(inner);
|
|
157
|
+
}
|
|
158
|
+
const json = JSON.stringify(unwrapRedacted(value));
|
|
159
|
+
return collectRedactedSecrets(value).length > 0 ? Redacted.make(json) : json;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export type ImageSourceKind = "main" | "context" | "image";
|
|
163
|
+
|
|
164
|
+
/** Which image source a props bag declares (`main` always wins). */
|
|
165
|
+
export const imageSourceKind = (source: WorkloadImageSource): ImageSourceKind | undefined =>
|
|
166
|
+
source.main !== undefined
|
|
167
|
+
? "main"
|
|
168
|
+
: source.image !== undefined
|
|
169
|
+
? "image"
|
|
170
|
+
: source.context !== undefined || source.dockerfile !== undefined
|
|
171
|
+
? "context"
|
|
172
|
+
: undefined;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Content hash for image sources whose identity is computable without a
|
|
176
|
+
* bundler: `image` (the ref + platform) and `context` (the build-context
|
|
177
|
+
* directory + Dockerfile content + platform). `main` returns `undefined`
|
|
178
|
+
* — its hash comes from the bundle output inside the registry adapter.
|
|
179
|
+
*/
|
|
180
|
+
export const computeStaticWorkloadImageHash = Effect.fn(function* (
|
|
181
|
+
source: WorkloadImageSource,
|
|
182
|
+
platform: string,
|
|
183
|
+
) {
|
|
184
|
+
const kind = imageSourceKind(source);
|
|
185
|
+
if (kind === "image") {
|
|
186
|
+
return (yield* sha256Object({ image: source.image!, platform })).slice(0, 16);
|
|
187
|
+
}
|
|
188
|
+
if (kind === "context") {
|
|
189
|
+
if (source.dockerfile !== undefined && isInlineDockerfile(source.dockerfile)) {
|
|
190
|
+
if (typeof source.dockerfile.content !== "string") return undefined;
|
|
191
|
+
return (yield* sha256Object({ dockerfile: source.dockerfile.content, platform })).slice(
|
|
192
|
+
0,
|
|
193
|
+
16,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const fs = yield* FileSystem.FileSystem;
|
|
197
|
+
const path = yield* Path.Path;
|
|
198
|
+
const context = path.resolve(source.context ?? ".");
|
|
199
|
+
const dockerfile =
|
|
200
|
+
typeof source.dockerfile === "string"
|
|
201
|
+
? path.resolve(source.dockerfile)
|
|
202
|
+
: path.join(context, "Dockerfile");
|
|
203
|
+
if (!(yield* fs.exists(context)) || !(yield* fs.exists(dockerfile))) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const contextHash = yield* hashDirectory({ cwd: context });
|
|
207
|
+
const dockerfileContent = yield* fs.readFileString(dockerfile);
|
|
208
|
+
return (yield* sha256Object({ contextHash, dockerfile: dockerfileContent, platform })).slice(
|
|
209
|
+
0,
|
|
210
|
+
16,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
export interface ResolveWorkloadImageOptions {
|
|
217
|
+
adapter: ClusterAdapterService;
|
|
218
|
+
/** The target connection — its `registry`, if any, receives builds. */
|
|
219
|
+
connection: Connection;
|
|
220
|
+
/** Publisher for connection-level registries. */
|
|
221
|
+
connectionRegistry: ConnectionRegistry;
|
|
222
|
+
id: string;
|
|
223
|
+
source: WorkloadImageSource;
|
|
224
|
+
platform: string;
|
|
225
|
+
port?: number | undefined;
|
|
226
|
+
isExternal?: boolean | undefined;
|
|
227
|
+
bootstrap: (importPath: string) => string;
|
|
228
|
+
tags: Record<string, string>;
|
|
229
|
+
/** Persisted registry state hints (legacy-shape tolerant). */
|
|
230
|
+
state: Record<string, unknown> | undefined;
|
|
231
|
+
session: { note: (message: string) => Effect.Effect<void> };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Resolve the container image for a workload: through the cluster
|
|
236
|
+
* adapter's managed registry when it has one (build/mirror + push). Other
|
|
237
|
+
* clusters run a pre-built `image` reference verbatim and build `main` /
|
|
238
|
+
* `context` sources into the connection's `registry`, which those sources
|
|
239
|
+
* require.
|
|
240
|
+
*/
|
|
241
|
+
export const resolveWorkloadImage = Effect.fn(function* (options: ResolveWorkloadImageOptions) {
|
|
242
|
+
const { adapter, source } = options;
|
|
243
|
+
if (adapter.registry !== undefined) {
|
|
244
|
+
return yield* adapter.registry.resolve({
|
|
245
|
+
id: options.id,
|
|
246
|
+
source,
|
|
247
|
+
platform: options.platform,
|
|
248
|
+
port: options.port,
|
|
249
|
+
isExternal: options.isExternal,
|
|
250
|
+
bootstrap: options.bootstrap,
|
|
251
|
+
tags: options.tags,
|
|
252
|
+
state: options.state,
|
|
253
|
+
session: options.session,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const kind = imageSourceKind(source);
|
|
258
|
+
if (kind === "image") {
|
|
259
|
+
const codeHash = (yield* computeStaticWorkloadImageHash(source, options.platform))!;
|
|
260
|
+
return { imageUri: source.image!, codeHash, state: undefined };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const registry = options.connection.registry;
|
|
264
|
+
if (registry !== undefined) {
|
|
265
|
+
return yield* options.connectionRegistry.resolve({
|
|
266
|
+
id: options.id,
|
|
267
|
+
registry,
|
|
268
|
+
source,
|
|
269
|
+
platform: options.platform,
|
|
270
|
+
port: options.port,
|
|
271
|
+
isExternal: options.isExternal,
|
|
272
|
+
bootstrap: options.bootstrap,
|
|
273
|
+
state: options.state,
|
|
274
|
+
session: options.session,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return yield* Effect.die(
|
|
279
|
+
new Error(
|
|
280
|
+
`'${options.id}': 'main' and 'context' image sources are built and ` +
|
|
281
|
+
"pushed to a container registry, and this cluster connection has " +
|
|
282
|
+
"none. Add a `registry` to the connection (e.g. " +
|
|
283
|
+
'`Kubernetes.KubeConfig({ context, registry: { server: "ghcr.io/acme" } })`), ' +
|
|
284
|
+
"use `Kubernetes.LocalCluster`, which includes one, or run a " +
|
|
285
|
+
"pre-built `image` reference the cluster can pull.",
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
/** Plan-time content hash for `diff` — adapter-aware. */
|
|
291
|
+
export const workloadImageHash = Effect.fn(function* (options: {
|
|
292
|
+
adapter: ClusterAdapterService;
|
|
293
|
+
connection: Connection;
|
|
294
|
+
connectionRegistry: ConnectionRegistry;
|
|
295
|
+
source: WorkloadImageSource;
|
|
296
|
+
platform: string;
|
|
297
|
+
port?: number | undefined;
|
|
298
|
+
isExternal?: boolean | undefined;
|
|
299
|
+
bootstrap: (importPath: string) => string;
|
|
300
|
+
}) {
|
|
301
|
+
if (options.adapter.registry !== undefined) {
|
|
302
|
+
return yield* options.adapter.registry.hash({
|
|
303
|
+
source: options.source,
|
|
304
|
+
platform: options.platform,
|
|
305
|
+
port: options.port,
|
|
306
|
+
isExternal: options.isExternal,
|
|
307
|
+
bootstrap: options.bootstrap,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (options.connection.registry !== undefined && imageSourceKind(options.source) === "main") {
|
|
311
|
+
return yield* options.connectionRegistry.hash({
|
|
312
|
+
source: options.source,
|
|
313
|
+
platform: options.platform,
|
|
314
|
+
port: options.port,
|
|
315
|
+
isExternal: options.isExternal,
|
|
316
|
+
bootstrap: options.bootstrap,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return yield* computeStaticWorkloadImageHash(options.source, options.platform);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
/** The identity-adapter state persisted on workload attributes. */
|
|
323
|
+
export type PersistedIdentityState = IdentityState | undefined;
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Platform-neutral generated container entry for an Effect-native server
|
|
327
|
+
* workload: resolves the program's registered runners and serves the
|
|
328
|
+
* returned `{ fetch }` handler on `PORT`. Cloud adapters override this
|
|
329
|
+
* through {@link ClusterAdapterService.bootstrap} to wire their runtime
|
|
330
|
+
* credential chains (e.g. EKS Pod Identity).
|
|
331
|
+
*/
|
|
332
|
+
export const makeServerBootstrap =
|
|
333
|
+
(handler: string) =>
|
|
334
|
+
(importPath: string): string =>
|
|
335
|
+
`
|
|
336
|
+
import { BunServices } from "@effect/platform-bun";
|
|
337
|
+
import { BunHttpServer } from "alchemy/Http";
|
|
338
|
+
import { Stack } from "alchemy/Stack";
|
|
339
|
+
import { Stage } from "alchemy/Stage";
|
|
340
|
+
import { makeEntrypointLayer, reifyBoundConfigProvider } from "alchemy/Runtime";
|
|
341
|
+
import { provideProcessTelemetry } from "alchemy/Telemetry";
|
|
342
|
+
import * as Context from "effect/Context";
|
|
343
|
+
import * as Config from "effect/Config";
|
|
344
|
+
import * as ConfigProvider from "effect/ConfigProvider";
|
|
345
|
+
import * as Effect from "effect/Effect";
|
|
346
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
347
|
+
import * as Layer from "effect/Layer";
|
|
348
|
+
import * as Logger from "effect/Logger";
|
|
349
|
+
|
|
350
|
+
import { ${handler} as entrypoint } from ${JSON.stringify(importPath)};
|
|
351
|
+
|
|
352
|
+
// Normalize the entrypoint export: an inline-effect class default export is
|
|
353
|
+
// an Effect resolving the platform instance, while the tagged form
|
|
354
|
+
// (X.make(props, impl)) exports a Layer providing the Self tag. Both fold
|
|
355
|
+
// into a Layer via makeEntrypointLayer (same pattern as the ECS/Lambda/
|
|
356
|
+
// Cloudflare Container bridges).
|
|
357
|
+
const tag = Context.Service("${Self.key}");
|
|
358
|
+
const layer = makeEntrypointLayer(tag, entrypoint);
|
|
359
|
+
|
|
360
|
+
const platform = Layer.mergeAll(
|
|
361
|
+
BunServices.layer,
|
|
362
|
+
FetchHttpClient.layer,
|
|
363
|
+
Logger.layer([Logger.consolePretty()]),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// Resolve the bundled program (the runners registered via host.run / serve)
|
|
367
|
+
// and run it with a Bun HTTP server bound to PORT, so a returned { fetch }
|
|
368
|
+
// handler is served and host.run loops stay alive.
|
|
369
|
+
const program = tag.pipe(
|
|
370
|
+
// Process-lifetime telemetry: built once into the root scope; exporters
|
|
371
|
+
// batch on their intervals and flush when the scope closes on graceful
|
|
372
|
+
// shutdown.
|
|
373
|
+
Effect.flatMap((host) =>
|
|
374
|
+
host.RuntimeContext.exports.pipe(
|
|
375
|
+
Effect.flatMap((exports) => exports.program),
|
|
376
|
+
provideProcessTelemetry(host.RuntimeContext),
|
|
377
|
+
),
|
|
378
|
+
),
|
|
379
|
+
Effect.provide(
|
|
380
|
+
layer.pipe(Layer.provideMerge(Layer.mergeAll(
|
|
381
|
+
Layer.effect(
|
|
382
|
+
Stack,
|
|
383
|
+
Effect.all([
|
|
384
|
+
Config.String("ALCHEMY_STACK_NAME"),
|
|
385
|
+
Config.String("ALCHEMY_STAGE")
|
|
386
|
+
]).pipe(
|
|
387
|
+
Effect.map(([name, stage]) => ({
|
|
388
|
+
name,
|
|
389
|
+
stage,
|
|
390
|
+
bindings: {},
|
|
391
|
+
resources: {}
|
|
392
|
+
}))
|
|
393
|
+
)
|
|
394
|
+
),
|
|
395
|
+
// Module-scope declarations shared with the Stack may read the stage.
|
|
396
|
+
Layer.effect(Stage, Config.String("ALCHEMY_STAGE")),
|
|
397
|
+
)),
|
|
398
|
+
Layer.provideMerge(BunHttpServer()),
|
|
399
|
+
Layer.provideMerge(platform),
|
|
400
|
+
Layer.provideMerge(
|
|
401
|
+
Layer.succeed(
|
|
402
|
+
ConfigProvider.ConfigProvider,
|
|
403
|
+
reifyBoundConfigProvider(ConfigProvider.fromEnv(), process.env)
|
|
404
|
+
)
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
),
|
|
408
|
+
Effect.scoped
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
console.log(\`Kubernetes Deployment bootstrap starting on port \${process.env.PORT ?? 3000}...\`);
|
|
412
|
+
await Effect.runPromise(program).catch((err) => {
|
|
413
|
+
console.error("Kubernetes Deployment bootstrap failed:", err);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
});
|
|
416
|
+
`;
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Platform-neutral generated container entry for an Effect-native one-shot
|
|
420
|
+
* Job: resolves the program's `run` effect, executes it to completion, and
|
|
421
|
+
* exits.
|
|
422
|
+
*/
|
|
423
|
+
export const makeJobBootstrap =
|
|
424
|
+
(handler: string) =>
|
|
425
|
+
(importPath: string): string =>
|
|
426
|
+
`
|
|
427
|
+
import { BunServices } from "@effect/platform-bun";
|
|
428
|
+
import { Stack } from "alchemy/Stack";
|
|
429
|
+
import { Stage } from "alchemy/Stage";
|
|
430
|
+
import { makeEntrypointLayer, reifyBoundConfigProvider } from "alchemy/Runtime";
|
|
431
|
+
import { provideProcessTelemetry } from "alchemy/Telemetry";
|
|
432
|
+
import * as Context from "effect/Context";
|
|
433
|
+
import * as Config from "effect/Config";
|
|
434
|
+
import * as ConfigProvider from "effect/ConfigProvider";
|
|
435
|
+
import * as Effect from "effect/Effect";
|
|
436
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
437
|
+
import * as Layer from "effect/Layer";
|
|
438
|
+
import * as Logger from "effect/Logger";
|
|
439
|
+
|
|
440
|
+
import { ${handler} as entrypoint } from ${JSON.stringify(importPath)};
|
|
441
|
+
|
|
442
|
+
// Normalize the entrypoint export (see the server bootstrap).
|
|
443
|
+
const tag = Context.Service("${Self.key}");
|
|
444
|
+
const layer = makeEntrypointLayer(tag, entrypoint);
|
|
445
|
+
|
|
446
|
+
const platform = Layer.mergeAll(
|
|
447
|
+
BunServices.layer,
|
|
448
|
+
FetchHttpClient.layer,
|
|
449
|
+
Logger.layer([Logger.consolePretty()]),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Resolve the bundled program's registered one-shot runners (the shape's
|
|
453
|
+
// \`run\` effect and any host.run work) and execute them to completion.
|
|
454
|
+
const program = tag.pipe(
|
|
455
|
+
// Process-lifetime telemetry: built once into the root scope; exporters
|
|
456
|
+
// batch on their intervals and flush when the scope closes as the
|
|
457
|
+
// one-shot program completes.
|
|
458
|
+
Effect.flatMap((host) =>
|
|
459
|
+
host.RuntimeContext.exports.pipe(
|
|
460
|
+
Effect.flatMap((exports) => exports.program),
|
|
461
|
+
provideProcessTelemetry(host.RuntimeContext),
|
|
462
|
+
),
|
|
463
|
+
),
|
|
464
|
+
Effect.provide(
|
|
465
|
+
layer.pipe(Layer.provideMerge(Layer.mergeAll(
|
|
466
|
+
Layer.effect(
|
|
467
|
+
Stack,
|
|
468
|
+
Effect.all([
|
|
469
|
+
Config.String("ALCHEMY_STACK_NAME"),
|
|
470
|
+
Config.String("ALCHEMY_STAGE")
|
|
471
|
+
]).pipe(
|
|
472
|
+
Effect.map(([name, stage]) => ({
|
|
473
|
+
name,
|
|
474
|
+
stage,
|
|
475
|
+
bindings: {},
|
|
476
|
+
resources: {}
|
|
477
|
+
}))
|
|
478
|
+
)
|
|
479
|
+
),
|
|
480
|
+
// Module-scope declarations shared with the Stack may read the stage.
|
|
481
|
+
Layer.effect(Stage, Config.String("ALCHEMY_STAGE")),
|
|
482
|
+
)),
|
|
483
|
+
Layer.provideMerge(platform),
|
|
484
|
+
Layer.provideMerge(
|
|
485
|
+
Layer.succeed(
|
|
486
|
+
ConfigProvider.ConfigProvider,
|
|
487
|
+
reifyBoundConfigProvider(ConfigProvider.fromEnv(), process.env)
|
|
488
|
+
)
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
),
|
|
492
|
+
Effect.scoped
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
console.log("Kubernetes Job bootstrap starting...");
|
|
496
|
+
await Effect.runPromise(program).catch((err) => {
|
|
497
|
+
console.error("Kubernetes Job bootstrap failed:", err);
|
|
498
|
+
process.exit(1);
|
|
499
|
+
});
|
|
500
|
+
// Run-to-completion semantics: exit 0 explicitly so lingering handles
|
|
501
|
+
// (sockets, timers) never keep the pod alive after the work is done.
|
|
502
|
+
process.exit(0);
|
|
503
|
+
`;
|