| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Equal from "effect/Equal"; |
| 3 | import { Unowned } from "../AdoptPolicy.ts"; |
| 4 | import { isResolved } from "../Diff.ts"; |
| 5 | import * as Provider from "../Provider.ts"; |
| 6 | import { Resource } from "../Resource.ts"; |
| 7 | import { |
| 8 | createInternalTags, |
| 9 | hasAlchemyTags, |
| 10 | stripInternalTags, |
| 11 | } from "../Tags.ts"; |
| 12 | import { Docker, dockerContextName, dockerPhysicalName } from "./Docker.ts"; |
| 13 | import type { Providers } from "./Providers.ts"; |
| 14 | |
| 15 | export interface VolumeLabel { |
| 16 | /** Label name. */ |
| 17 | name: string; |
| 18 | /** Label value. */ |
| 19 | value: string; |
| 20 | } |
| 21 | |
| 22 | export 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 | |
| 39 | export 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 | */ |
| 109 | export const Volume = Resource<Volume>("Docker.Volume"); |
| 110 | |
| 111 | export 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 | |
| 194 | const 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 | |
| 206 | export 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 | }); |