1import * as Effect from "effect/Effect";
2import * as Equal from "effect/Equal";
3import { Unowned } from "../AdoptPolicy.ts";
4import { isResolved } from "../Diff.ts";
5import * as Provider from "../Provider.ts";
6import { Resource } from "../Resource.ts";
7import {
8 createInternalTags,
9 hasAlchemyTags,
10 stripInternalTags,
11} from "../Tags.ts";
12import { Docker, dockerContextName, dockerPhysicalName } from "./Docker.ts";
13import type { Providers } from "./Providers.ts";
14
15export interface VolumeLabel {
16 /** Label name. */
17 name: string;
18 /** Label value. */
19 value: string;
20}
21
22export interface VolumeProps {
23 /**
24 * Docker volume name.
25 *
26 * @default Generated from stack, stage, logical id, and instance id.
27 */
28 name?: string;
29 /** Volume driver. @default "local" */
30 driver?: string;
31 /** Driver-specific options. */
32 driverOpts?: Record<string, string>;
33 /** Custom metadata labels. */
34 labels?: Record<string, string>;
35 /** Docker context name or context resource. */
36 context?: Docker.ContextRef;
37}
38
39export interface Volume extends Resource<
40 "Docker.Volume",
41 VolumeProps,
42 {
43 /** Docker volume name. */
44 id: string;
45 /** Docker volume name. */
46 name: string;
47 /** Volume driver. */
48 driver: string;
49 /** Driver-specific options reported by Docker. */
50 driverOpts: Record<string, string>;
51 /** Labels reported by Docker. */
52 labels: Record<string, string>;
53 /** Host mountpoint path. */
54 mountpoint?: string;
55 /** Creation timestamp in milliseconds since epoch. */
56 createdAt: number;
57 },
58 never,
59 Providers
60> {}
61
62/**
63 * A Docker volume managed through the active Docker context.
64 *
65 * Pre-existing same-name volumes are treated as foreign until the engine is
66 * allowed to adopt them with `--adopt` or `adopt(true)`.
67 *
68 *
69 * ### Creating Volumes
70 * **Example:** Basic volume
71 * ```typescript
72 * const data = yield* Docker.Volume("data", {
73 * name: "app-data",
74 * });
75 * ```
76 *
77 * **Example:** PostgreSQL data volume
78 * ```typescript
79 * const data = yield* Docker.Volume("postgres-data");
80 * ```
81 *
82 * **Example:** Driver options and labels
83 * ```typescript
84 * const data = yield* Docker.Volume("db-data", {
85 * driver: "local",
86 * driverOpts: {
87 * type: "nfs",
88 * o: "addr=10.0.0.1,rw",
89 * device: ":/path/to/dir",
90 * },
91 * labels: {
92 * "com.example.usage": "database",
93 * },
94 * });
95 * ```
96 *
97 * ### Docker Context
98 * **Example:** Create a volume in a named Docker context
99 * ```typescript
100 * const data = yield* Docker.Volume("data", {
101 * name: "app-data",
102 * context: "remote-build",
103 * });
104 * ```
105 *
106 * @resource
107 * @product Volume
108 */
109export const Volume = Resource<Volume>("Docker.Volume");
110
111export const VolumeProvider = () =>
112 Provider.effect(
113 Volume,
114 Effect.gen(function* () {
115 const docker = yield* Docker;
116
117 return Volume.Provider.of({
118 list: () => Effect.succeed([]),
119 read: Effect.fn(function* ({ id, instanceId, olds, output }) {
120 const context = dockerContextName(olds.context);
121 const name = yield* dockerPhysicalName(id, olds, instanceId);
122 const info = yield* docker.volume
123 .inspect(name, context)
124 .pipe(
125 Effect.catchReason(
126 "PlatformError",
127 "NotFound",
128 () => Effect.undefined,
129 ),
130 );
131 if (!info) return undefined;
132 const attrs = toVolumeAttributes(info);
133 if (output) return attrs;
134 // Without prior state, only adopt a volume that carries our branding;
135 // anything else is foreign and gated behind `--adopt`.
136 const owned = yield* hasAlchemyTags(id, info.Labels ?? undefined);
137 return owned ? attrs : Unowned(attrs);
138 }),
139 diff: Effect.fn(function* ({ id, instanceId, output, news, olds }) {
140 if (!isResolved(news)) return undefined;
141 if (
142 dockerContextName(olds.context) !== dockerContextName(news.context)
143 ) {
144 return { action: "replace" as const, deleteFirst: true };
145 }
146 const args = yield* makeVolumeArgs(id, news, instanceId);
147 // Auto-generated names are engine-owned: the deployed name stays
148 // authoritative even if the generator would name this id differently
149 // today. Only an explicit user-provided name can force a replace.
150 const desiredName = news?.name ?? output?.name ?? args.name;
151 if (
152 output?.name !== desiredName ||
153 output?.driver !== args.driver ||
154 !Equal.equals(output?.driverOpts ?? {}, args.opt ?? {}) ||
155 // Compare only user labels; internal `alchemy::*` branding lives on
156 // the observed volume but must not drive replacement.
157 !Equal.equals(stripInternalTags(output?.labels), args.label ?? {})
158 ) {
159 return { action: "replace" as const, deleteFirst: true };
160 }
161 }),
162 reconcile: Effect.fn(function* ({ id, instanceId, news, output }) {
163 const context = dockerContextName(news.context);
164 const args = yield* makeVolumeArgs(id, news, instanceId);
165 // Prefer the deployed name: regenerating would target a different
166 // volume if the generator's output for this id ever drifts.
167 const name = news?.name ?? output?.name ?? args.name;
168 const internalTags = yield* createInternalTags(id);
169 const result = yield* docker.volume.create({
170 ...args,
171 name,
172 label: { ...internalTags, ...args.label },
173 context,
174 });
175 return toVolumeAttributes(
176 yield* docker.volume.inspect(result.stdout, context),
177 );
178 }),
179 delete: Effect.fn(({ olds, output }) =>
180 docker.volume
181 .remove(output.name, dockerContextName(olds.context))
182 .pipe(
183 Effect.catchReason(
184 "PlatformError",
185 "NotFound",
186 () => Effect.void,
187 ),
188 ),
189 ),
190 });
191 }),
192 );
193
194const makeVolumeArgs = (id: string, props: VolumeProps, instanceId: string) =>
195 dockerPhysicalName(id, props, instanceId).pipe(
196 Effect.map(
197 (name): Parameters<Docker["Service"]["volume"]["create"]>[0] => ({
198 name,
199 driver: props.driver ?? "local",
200 opt: props.driverOpts,
201 label: props.labels,
202 }),
203 ),
204 );
205
206export const toVolumeAttributes = (
207 info: Docker.Volume,
208): Volume["Attributes"] => ({
209 id: info.Name,
210 name: info.Name,
211 driver: info.Driver,
212 driverOpts: info.Options ?? {},
213 labels: info.Labels ?? {},
214 mountpoint: info.Mountpoint,
215 createdAt: Date.parse(info.CreatedAt) || Date.now(),
216});