1import * as Effect from "effect/Effect";
2import * as Equal from "effect/Equal";
3import { deepEqual, isResolved } from "../Diff.ts";
4import * as Provider from "../Provider.ts";
5import { Resource } from "../Resource.ts";
6import { Docker, dockerPhysicalName } from "./Docker.ts";
7import type { Providers } from "./Providers.ts";
8
9export interface ContextProps {
10 /**
11 * Context name.
12 *
13 * @default Generated from stack, stage, logical id, and instance id.
14 */
15 name?: string;
16 /** Human-readable description displayed in `docker context ls`. */
17 description?: string;
18 /**
19 * Docker endpoint spec, for example `host=ssh://user@host`.
20 *
21 * Connection metadata only — never credentials. SSH endpoints
22 * authenticate with keys/agent (Docker rejects passwords in the URL), and
23 * TLS endpoints reference cert/key material by file path
24 * (`host=tcp://...,ca=...,cert=...,key=...`), not by value. Docker itself
25 * stores this spec in plaintext under `~/.docker/contexts`.
26 */
27 docker?: string;
28}
29
30export interface Context extends Resource<
31 "Docker.Context",
32 ContextProps,
33 {
34 /** Docker context name (contexts are identified by name). */
35 id: string;
36 /** Docker context name. */
37 name: string;
38 /** Human-readable description. */
39 description: string;
40 /** Docker endpoint the context targets. */
41 docker?: string;
42 },
43 never,
44 Providers
45> {}
46
47/**
48 * A named Docker CLI context — a pointer to a Docker engine (local socket,
49 * SSH host, or TCP endpoint) that other Docker resources deploy through.
50 *
51 * Pass the context (or its name) to any Docker resource's `context` prop to
52 * run that resource's operations against the referenced engine instead of the
53 * default one. Changing the endpoint updates the context in place; renaming
54 * it, or clearing a previously-set endpoint, replaces it.
55 *
56 *
57 * ### Creating Contexts
58 * **Example:** Remote engine over SSH
59 * ```typescript
60 * const vps = yield* Docker.Context("vps", {
61 * docker: "host=ssh://deploy@example.com",
62 * description: "production swarm manager",
63 * });
64 * ```
65 *
66 * ### Using a Context
67 * **Example:** Deploy resources through the context
68 * ```typescript
69 * const vps = yield* Docker.Context("vps", {
70 * docker: "host=ssh://deploy@example.com",
71 * });
72 * const network = yield* Docker.Network("app-net", {
73 * context: vps,
74 * driver: "overlay",
75 * });
76 * const app = yield* Docker.Service("app", {
77 * context: vps,
78 * image: "nginx:alpine",
79 * networks: [network.name],
80 * });
81 * ```
82 *
83 * **Example:** Local development vs production
84 * ```typescript
85 * const dev = yield* Alchemy.ALCHEMY_DEV;
86 * const context = yield* Docker.Context("target", {
87 * name: dev ? "local" : "vps",
88 * docker: dev ? undefined : "host=ssh://deploy@example.com",
89 * });
90 * ```
91 *
92 * @resource
93 * @product Context
94 */
95export const Context = Resource<Context>("Docker.Context");
96
97export const ContextProvider = () =>
98 Provider.effect(
99 Context,
100 Effect.gen(function* () {
101 const docker = yield* Docker;
102
103 const inspect = (nameOrId: string) =>
104 docker.context
105 .inspect(nameOrId)
106 .pipe(
107 Effect.catchReason(
108 "PlatformError",
109 "NotFound",
110 () => Effect.undefined,
111 ),
112 );
113
114 return Context.Provider.of({
115 list: () => Effect.succeed([]),
116 read: Effect.fn(function* ({ id, instanceId, olds, output }) {
117 const name = yield* dockerPhysicalName(id, olds, instanceId);
118 const live = yield* inspect(name);
119 if (!live) return undefined;
120 const attrs = toContextAttributes(live);
121 if (output) return attrs;
122 return attrs;
123 }),
124 diff: Effect.fn(function* ({ id, instanceId, news, olds }) {
125 if (!isResolved(news)) return undefined;
126
127 const oldDesired = yield* normalizeDesired(id, olds, instanceId);
128 const newDesired = yield* normalizeDesired(id, news, instanceId);
129
130 if (oldDesired.name !== newDesired.name) {
131 return { action: "replace" as const, deleteFirst: true };
132 }
133
134 // `docker context update` can set docker host, but cannot reliably
135 // clear it once set. Recreate when transitioning to no docker.
136 if (oldDesired.docker && !newDesired.docker) {
137 return { action: "replace" as const, deleteFirst: true };
138 }
139
140 if (!Equal.equals(oldDesired, newDesired)) {
141 return { action: "update" as const };
142 }
143
144 return { action: "noop" as const };
145 }),
146 reconcile: Effect.fn(function* ({
147 id,
148 instanceId,
149 news,
150 olds,
151 output,
152 }) {
153 const desired = yield* normalizeDesired(id, news, instanceId);
154
155 if (output && olds) {
156 const oldDesired = yield* normalizeDesired(id, olds, instanceId);
157 if (deepEqual(oldDesired, desired)) {
158 const current = yield* inspect(output.id);
159 if (current) {
160 return toContextAttributes(current);
161 }
162 }
163 }
164
165 const existing = output
166 ? yield* inspect(output.id)
167 : yield* inspect(desired.name);
168
169 if (!existing) {
170 const createArgs = {
171 name: desired.name,
172 ...(desired.docker ? { docker: desired.docker } : {}),
173 ...(desired.description.length > 0
174 ? { description: desired.description }
175 : {}),
176 };
177 yield* docker.context.create(createArgs);
178 return toContextAttributes(
179 yield* docker.context.inspect(desired.name),
180 );
181 }
182
183 const current = toContextAttributes(existing);
184 const needsUpdate =
185 current.description !== desired.description ||
186 current.docker !== desired.docker;
187
188 if (needsUpdate) {
189 yield* docker.context.update({
190 name: desired.name,
191 ...(desired.docker ? { docker: desired.docker } : {}),
192 description: desired.description,
193 });
194 }
195
196 return toContextAttributes(
197 yield* docker.context.inspect(desired.name),
198 );
199 }),
200 delete: Effect.fn(({ output }) =>
201 docker.context.remove(output.id, true).pipe(
202 Effect.catchReason("PlatformError", "NotFound", () => Effect.void),
203 Effect.as(undefined),
204 ),
205 ),
206 });
207 }),
208 );
209
210const normalizeDesired = (
211 id: string,
212 props: ContextProps,
213 instanceId: string,
214) =>
215 dockerPhysicalName(id, props, instanceId).pipe(
216 Effect.map((name) => ({
217 name,
218 description: normalizeDescription(props.description),
219 docker: normalizeDocker(props.docker),
220 })),
221 );
222
223const normalizeDescription = (description: string | undefined): string =>
224 description?.trim() ?? "";
225
226const normalizeDocker = (docker: string | undefined): string | undefined => {
227 const value = docker?.trim();
228 return value && value.length > 0 ? value : undefined;
229};
230
231const toContextAttributes = (
232 context: Docker.Context,
233): Context["Attributes"] => ({
234 id: context.Name,
235 name: context.Name,
236 description: context.Metadata?.Description ?? "",
237 docker: context.Endpoints?.docker,
238});