1/**
2 * Image publishing for connections that declare a
3 * {@link Connection.registry}: `main` programs and `context` Dockerfiles are
4 * built on the deploying machine and pushed to `<server>/<name>:<hash>`,
5 * where the cluster's nodes pull them. Used when the cluster's platform
6 * adapter has no managed registry of its own (EKS pushes to ECR instead).
7 */
8import * as Effect from "effect/Effect";
9import type { RegistryCredentials } from "../../Docker/Docker.ts";
10import {
11 makeContainerImageSource,
12 type ImageRegistryTarget,
13} from "../../Docker/ImageSource.ts";
14import { createPhysicalName } from "../../PhysicalName.ts";
15import type { RegistryState, WorkloadImageSource } from "../ClusterAdapter.ts";
16import type { ContainerRegistry } from "../Connection.ts";
17
18/** Docker credentials for a registry, or `undefined` to use `docker login`. */
19const credentialsOf = (
20 registry: ContainerRegistry,
21): RegistryCredentials | undefined =>
22 registry.username !== undefined && registry.password !== undefined
23 ? {
24 // Docker keys credentials by host; `server` may carry a namespace.
25 server: registry.server.split("/")[0]!,
26 username: registry.username,
27 password: registry.password,
28 }
29 : undefined;
30
31const trimServer = (server: string) => server.replace(/\/+$/, "");
32
33export const makeConnectionRegistry = Effect.gen(function* () {
34 const images = yield* makeContainerImageSource;
35
36 const resolve = Effect.fn(function* (options: {
37 id: string;
38 registry: ContainerRegistry;
39 source: WorkloadImageSource;
40 platform: string;
41 port?: number | undefined;
42 isExternal?: boolean | undefined;
43 bootstrap: (importPath: string) => string;
44 state: Record<string, unknown> | undefined;
45 session: { note: (message: string) => Effect.Effect<void> };
46 }) {
47 const server = trimServer(options.registry.server);
48 // Keep the repository stable across updates; a changed `server` starts
49 // a new one.
50 const repository =
51 options.state?.kind === "registry" &&
52 options.state.server === server &&
53 typeof options.state.repository === "string"
54 ? options.state.repository
55 : `${server}/${yield* createPhysicalName({
56 id: options.id,
57 maxLength: 96,
58 lowercase: true,
59 })}`;
60 const target: ImageRegistryTarget = {
61 repositoryUri: Effect.succeed(repository),
62 // Generic registries have no cheap existence probe; resolve only runs
63 // when the workload changes, and the local build cache makes a
64 // rebuild of unchanged content fast.
65 hasTag: () => Effect.succeed(false),
66 credentials: Effect.succeed(credentialsOf(options.registry)),
67 };
68 const resolved = yield* images.resolve(
69 {
70 id: options.id,
71 source: options.source,
72 platform: options.platform,
73 port: options.port,
74 isExternal: options.isExternal,
75 bootstrap: options.bootstrap,
76 session: options.session,
77 },
78 target,
79 );
80 return {
81 imageUri: resolved.imageUri,
82 codeHash: resolved.codeHash,
83 state: {
84 kind: "registry" as const,
85 server,
86 repository,
87 } satisfies RegistryState,
88 };
89 });
90
91 const hash = (options: {
92 source: WorkloadImageSource;
93 platform: string;
94 port?: number | undefined;
95 isExternal?: boolean | undefined;
96 bootstrap: (importPath: string) => string;
97 }) =>
98 images.hash({
99 source: options.source,
100 platform: options.platform,
101 port: options.port,
102 isExternal: options.isExternal,
103 bootstrap: options.bootstrap,
104 });
105
106 return { resolve, hash };
107});
108
109/** The publisher returned by {@link makeConnectionRegistry}. */
110export interface ConnectionRegistry extends Effect.Success<
111 typeof makeConnectionRegistry
112> {}