@alchemy.run/docker 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.
Files changed (98) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +46 -4
  3. package/lib/Bootstrap.d.ts +7 -0
  4. package/lib/Bootstrap.d.ts.map +1 -0
  5. package/lib/Bootstrap.js +25 -0
  6. package/lib/Bootstrap.js.map +1 -0
  7. package/lib/BuildHash.d.ts +47 -0
  8. package/lib/BuildHash.d.ts.map +1 -0
  9. package/lib/BuildHash.js +165 -0
  10. package/lib/BuildHash.js.map +1 -0
  11. package/lib/Container.d.ts +352 -0
  12. package/lib/Container.d.ts.map +1 -0
  13. package/lib/Container.js +531 -0
  14. package/lib/Container.js.map +1 -0
  15. package/lib/Context.d.ts +86 -0
  16. package/lib/Context.d.ts.map +1 -0
  17. package/lib/Context.js +146 -0
  18. package/lib/Context.js.map +1 -0
  19. package/lib/Docker.d.ts +448 -0
  20. package/lib/Docker.d.ts.map +1 -0
  21. package/lib/Docker.js +416 -0
  22. package/lib/Docker.js.map +1 -0
  23. package/lib/Dockerfile.d.ts +72 -0
  24. package/lib/Dockerfile.d.ts.map +1 -0
  25. package/lib/Dockerfile.js +12 -0
  26. package/lib/Dockerfile.js.map +1 -0
  27. package/lib/HealthcheckCommand.d.ts +17 -0
  28. package/lib/HealthcheckCommand.d.ts.map +1 -0
  29. package/lib/HealthcheckCommand.js +33 -0
  30. package/lib/HealthcheckCommand.js.map +1 -0
  31. package/lib/Image.d.ts +151 -0
  32. package/lib/Image.d.ts.map +1 -0
  33. package/lib/Image.js +192 -0
  34. package/lib/Image.js.map +1 -0
  35. package/lib/ImageSource.d.ts +234 -0
  36. package/lib/ImageSource.d.ts.map +1 -0
  37. package/lib/ImageSource.js +469 -0
  38. package/lib/ImageSource.js.map +1 -0
  39. package/lib/Network.d.ts +70 -0
  40. package/lib/Network.d.ts.map +1 -0
  41. package/lib/Network.js +111 -0
  42. package/lib/Network.js.map +1 -0
  43. package/lib/Providers.d.ts +21 -0
  44. package/lib/Providers.d.ts.map +1 -0
  45. package/lib/Providers.js +26 -0
  46. package/lib/Providers.js.map +1 -0
  47. package/lib/Registry.d.ts +47 -0
  48. package/lib/Registry.d.ts.map +1 -0
  49. package/lib/Registry.js +67 -0
  50. package/lib/Registry.js.map +1 -0
  51. package/lib/RegistryError.d.ts +21 -0
  52. package/lib/RegistryError.d.ts.map +1 -0
  53. package/lib/RegistryError.js +28 -0
  54. package/lib/RegistryError.js.map +1 -0
  55. package/lib/RemoteImage.d.ts +118 -0
  56. package/lib/RemoteImage.d.ts.map +1 -0
  57. package/lib/RemoteImage.js +164 -0
  58. package/lib/RemoteImage.js.map +1 -0
  59. package/lib/Service.d.ts +478 -0
  60. package/lib/Service.d.ts.map +1 -0
  61. package/lib/Service.js +588 -0
  62. package/lib/Service.js.map +1 -0
  63. package/lib/ServiceImage.d.ts +78 -0
  64. package/lib/ServiceImage.d.ts.map +1 -0
  65. package/lib/ServiceImage.js +162 -0
  66. package/lib/ServiceImage.js.map +1 -0
  67. package/lib/Swarm.d.ts +119 -0
  68. package/lib/Swarm.d.ts.map +1 -0
  69. package/lib/Swarm.js +148 -0
  70. package/lib/Swarm.js.map +1 -0
  71. package/lib/Volume.d.ts +94 -0
  72. package/lib/Volume.d.ts.map +1 -0
  73. package/lib/Volume.js +133 -0
  74. package/lib/Volume.js.map +1 -0
  75. package/lib/index.d.ts +14 -0
  76. package/lib/index.d.ts.map +1 -0
  77. package/lib/index.js +13 -0
  78. package/lib/index.js.map +1 -0
  79. package/package.json +58 -3
  80. package/src/Bootstrap.ts +51 -0
  81. package/src/BuildHash.ts +255 -0
  82. package/src/Container.ts +792 -0
  83. package/src/Context.ts +215 -0
  84. package/src/Docker.ts +1079 -0
  85. package/src/Dockerfile.ts +81 -0
  86. package/src/HealthcheckCommand.ts +35 -0
  87. package/src/Image.ts +293 -0
  88. package/src/ImageSource.ts +769 -0
  89. package/src/Network.ts +169 -0
  90. package/src/Providers.ts +46 -0
  91. package/src/Registry.ts +91 -0
  92. package/src/RegistryError.ts +48 -0
  93. package/src/RemoteImage.ts +255 -0
  94. package/src/Service.ts +1080 -0
  95. package/src/ServiceImage.ts +252 -0
  96. package/src/Swarm.ts +221 -0
  97. package/src/Volume.ts +193 -0
  98. package/src/index.ts +17 -0
package/src/Network.ts ADDED
@@ -0,0 +1,169 @@
1
+ import { Unowned } from "alchemy/AdoptPolicy";
2
+ import { isResolved } from "alchemy/Diff";
3
+ import * as Provider from "alchemy/Provider";
4
+ import { Resource } from "alchemy/Resource";
5
+ import { createInternalTags, hasAlchemyTags, stripInternalTags } from "alchemy/Tags";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Equal from "effect/Equal";
8
+ import { Docker, dockerEngineContextName, dockerPhysicalName } from "./Docker.ts";
9
+ import type { Providers } from "./Providers.ts";
10
+
11
+ export interface NetworkProps {
12
+ /**
13
+ * Docker network name.
14
+ *
15
+ * @default Generated from stack, stage, logical id, and instance id.
16
+ */
17
+ name?: string;
18
+ /** Network driver. @default "bridge" */
19
+ driver?: "bridge" | "host" | "none" | "overlay" | "macvlan" | (string & {});
20
+ /** Enable IPv6 on the network. @default false */
21
+ enableIPv6?: boolean;
22
+ /** Network labels. */
23
+ labels?: Record<string, string>;
24
+ /**
25
+ * The engine the network is created on: a Docker context name, a
26
+ * `Docker.Context` resource, or a `Docker.Swarm` — overlay networks
27
+ * require a swarm manager, and passing the swarm orders the network after
28
+ * the swarm is initialized.
29
+ */
30
+ context?: Docker.EngineRef;
31
+ }
32
+
33
+ export interface Network extends Resource<
34
+ "Docker.Network",
35
+ NetworkProps,
36
+ {
37
+ /** Docker network ID. */
38
+ id: string;
39
+ /** Docker network name. */
40
+ name: string;
41
+ /** Network driver. */
42
+ driver: string;
43
+ /** Whether IPv6 is enabled. */
44
+ enableIPv6: boolean;
45
+ /** Labels reported by Docker. */
46
+ labels: Record<string, string>;
47
+ /** Creation timestamp in milliseconds since epoch. */
48
+ createdAt: number;
49
+ },
50
+ never,
51
+ Providers
52
+ > {}
53
+
54
+ /**
55
+ * A Docker network managed through the active Docker context.
56
+ *
57
+ * Existing same-name networks are treated as foreign unless the engine is
58
+ * explicitly allowed to adopt them with `--adopt` or `adopt(true)`.
59
+ *
60
+ *
61
+ * ### Creating Networks
62
+ * **Example:** Basic bridge network
63
+ * ```typescript
64
+ * const network = yield* Docker.Network("app-network", {
65
+ * name: "app-network",
66
+ * });
67
+ * ```
68
+ *
69
+ * ### Adoption
70
+ * **Example:** Adopt a pre-existing network
71
+ * ```typescript
72
+ * const network = yield* Docker.Network("app-network", {
73
+ * name: "shared-app-network",
74
+ * }).pipe(adopt(true));
75
+ * ```
76
+ *
77
+ * @resource
78
+ * @product Network
79
+ */
80
+ export const Network = Resource<Network>("Docker.Network");
81
+
82
+ export const NetworkProvider = () =>
83
+ Provider.effect(
84
+ Network,
85
+ Effect.gen(function* () {
86
+ const docker = yield* Docker;
87
+
88
+ return Network.Provider.of({
89
+ list: () => Effect.succeed([]),
90
+ read: Effect.fn(function* ({ id, instanceId, olds, output }) {
91
+ const context = dockerEngineContextName(olds?.context);
92
+ const name = yield* dockerPhysicalName(id, olds, instanceId);
93
+ const info = yield* docker.network
94
+ .inspect(name, context)
95
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined));
96
+ if (!info) return undefined;
97
+ const attrs = toNetworkAttributes(info);
98
+ if (output) return attrs;
99
+ // Without prior state, only adopt a network that carries our branding;
100
+ // anything else is foreign and gated behind `--adopt`.
101
+ const owned = yield* hasAlchemyTags(id, info.Labels ?? undefined);
102
+ return owned ? attrs : Unowned(attrs);
103
+ }),
104
+ diff: Effect.fn(function* ({ id, output, instanceId, news, olds }) {
105
+ if (!isResolved(news) || !output) return undefined;
106
+ if (dockerEngineContextName(olds?.context) !== dockerEngineContextName(news?.context)) {
107
+ return { action: "replace", deleteFirst: true };
108
+ }
109
+ const args = yield* makeNetworkArgs(id, news, instanceId);
110
+ // Auto-generated names are engine-owned: the deployed name stays
111
+ // authoritative even if the generator would name this id differently
112
+ // today. Only an explicit user-provided name can force a replace.
113
+ const desiredName = news?.name ?? output.name;
114
+ if (
115
+ output.name !== desiredName ||
116
+ output.driver !== args.driver ||
117
+ output.enableIPv6 !== args.ipv6 ||
118
+ // Compare only user labels; internal `alchemy::*` branding lives on
119
+ // the observed network but must not drive replacement.
120
+ !Equal.equals(stripInternalTags(output.labels), args.label ?? {})
121
+ ) {
122
+ return { action: "replace", deleteFirst: true };
123
+ }
124
+ }),
125
+ reconcile: Effect.fn(function* ({ output, id, instanceId, news }) {
126
+ const context = dockerEngineContextName(news?.context);
127
+ if (output) {
128
+ const refreshed = yield* docker.network.inspect(output.id, context).pipe(
129
+ Effect.map(toNetworkAttributes),
130
+ Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
131
+ );
132
+ if (refreshed) return refreshed;
133
+ }
134
+ const args = yield* makeNetworkArgs(id, news, instanceId);
135
+ const internalTags = yield* createInternalTags(id);
136
+ const { stdout: createdId } = yield* docker.network.create({
137
+ ...args,
138
+ label: { ...internalTags, ...args.label },
139
+ context,
140
+ });
141
+ return toNetworkAttributes(yield* docker.network.inspect(createdId, context));
142
+ }),
143
+ delete: Effect.fn(({ olds, output }) =>
144
+ docker.network
145
+ .remove(output.id, dockerEngineContextName(olds?.context))
146
+ .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.void)),
147
+ ),
148
+ });
149
+ }),
150
+ );
151
+
152
+ const makeNetworkArgs = (id: string, props: NetworkProps | undefined, instanceId: string) =>
153
+ dockerPhysicalName(id, props, instanceId).pipe(
154
+ Effect.map((name): Parameters<Docker["Service"]["network"]["create"]>[0] => ({
155
+ name,
156
+ driver: props?.driver ?? "bridge",
157
+ ipv6: props?.enableIPv6 ?? false,
158
+ label: props?.labels ?? {},
159
+ })),
160
+ );
161
+
162
+ export const toNetworkAttributes = (info: Docker.Network): Network["Attributes"] => ({
163
+ id: info.Id,
164
+ name: info.Name,
165
+ driver: info.Driver,
166
+ enableIPv6: info.EnableIPv6,
167
+ labels: info.Labels ?? {},
168
+ createdAt: Date.parse(info.Created) || Date.now(),
169
+ });
@@ -0,0 +1,46 @@
1
+ import * as Provider from "alchemy/Provider";
2
+ import * as Layer from "effect/Layer";
3
+ import { Container, ContainerProvider } from "./Container.ts";
4
+ import { Context, ContextProvider } from "./Context.ts";
5
+ import { type DockerOptions, dockerLive } from "./Docker.ts";
6
+ import { Image, ImageProvider } from "./Image.ts";
7
+ import { Network, NetworkProvider } from "./Network.ts";
8
+ import { RemoteImage, RemoteImageProvider } from "./RemoteImage.ts";
9
+ import { Service, ServiceProvider } from "./Service.ts";
10
+ import { Swarm, SwarmProvider } from "./Swarm.ts";
11
+ import { Volume, VolumeProvider } from "./Volume.ts";
12
+
13
+ export class Providers extends Provider.ProviderCollection<Providers>()("Docker") {}
14
+
15
+ export type ProviderRequirements = Layer.Services<ReturnType<typeof providers>>;
16
+
17
+ /**
18
+ * Registers all Docker resource providers.
19
+ *
20
+ * Docker providers use the active Docker CLI context and are intentionally
21
+ * separate from `Cloudflare.Container`. Pass `bin` to run a Docker-compatible
22
+ * CLI such as Podman; the `DOCKER_BIN` environment variable overrides it.
23
+ *
24
+ * ```typescript
25
+ * providers: Docker.providers({ bin: "podman" }),
26
+ * ```
27
+ */
28
+ export const providers = (options: DockerOptions = {}) =>
29
+ Layer.effect(
30
+ Providers,
31
+ Provider.collection([Container, Image, Network, RemoteImage, Volume, Context, Service, Swarm]),
32
+ ).pipe(
33
+ Layer.provide(
34
+ Layer.mergeAll(
35
+ ContainerProvider(),
36
+ ImageProvider(),
37
+ NetworkProvider(),
38
+ RemoteImageProvider(),
39
+ VolumeProvider(),
40
+ ContextProvider(),
41
+ ServiceProvider(),
42
+ SwarmProvider(),
43
+ ),
44
+ ),
45
+ Layer.provideMerge(dockerLive(options)),
46
+ );
@@ -0,0 +1,91 @@
1
+ import type * as Redacted from "effect/Redacted";
2
+
3
+ export interface ImageRegistry {
4
+ /** Registry host, e.g. `ghcr.io`. */
5
+ server: string;
6
+ /** Registry username. */
7
+ username: string;
8
+ /** Registry password. Use `Redacted.make(...)` or `Config.Redacted(...)`. */
9
+ password: Redacted.Redacted<string>;
10
+ }
11
+
12
+ /** Strips the tag and digest from an image reference, leaving the repository. */
13
+ export const repositoryFromImageRef = (imageRef: string): string => {
14
+ const withoutDigest = imageRef.includes("@")
15
+ ? imageRef.slice(0, imageRef.indexOf("@"))
16
+ : imageRef;
17
+ const tagSeparator = withoutDigest.lastIndexOf(":");
18
+ const pathSeparator = withoutDigest.lastIndexOf("/");
19
+ return tagSeparator > pathSeparator ? withoutDigest.slice(0, tagSeparator) : withoutDigest;
20
+ };
21
+
22
+ /**
23
+ * Prefixes an image reference with the registry host unless the reference
24
+ * already carries a registry prefix (a dotted host, a host:port, or `localhost`).
25
+ */
26
+ export const withRegistryHost = (imageRef: string, registry: { server: string }): string => {
27
+ const registryHost = registry.server.replace(/\/$/, "");
28
+ const firstSegment = imageRef.split("/")[0];
29
+ const hasRegistryPrefix =
30
+ imageRef.includes("/") &&
31
+ (firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost");
32
+ return hasRegistryPrefix ? imageRef : `${registryHost}/${imageRef}`;
33
+ };
34
+
35
+ /** Extracts the `repository@sha256:...` digest from `docker push` output. */
36
+ export const parseRepoDigest = (imageRef: string, output: string): string | undefined => {
37
+ const match = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/i.exec(output);
38
+ if (!match) return undefined;
39
+ return `${repositoryFromImageRef(imageRef)}@${match[1]}`;
40
+ };
41
+
42
+ /**
43
+ * Picks the `repository@digest` that inspect reports for this image.
44
+ *
45
+ * A fallback when push output has no `digest:` line. Podman pushes report the
46
+ * published digest through `--digestfile` instead, because Podman can list a
47
+ * pulled image's source digest under the pushed name here.
48
+ */
49
+ export const repoDigestFromInspect = (
50
+ imageRef: string,
51
+ repoDigests: ReadonlyArray<string> | null | undefined,
52
+ ): string | undefined => {
53
+ const repository = repositoryFromImageRef(imageRef);
54
+ for (const digest of repoDigests ?? []) {
55
+ const at = digest.lastIndexOf("@");
56
+ if (at > 0 && digest.slice(0, at) === repository) return digest;
57
+ }
58
+ return undefined;
59
+ };
60
+
61
+ /**
62
+ * Digest from push output when the engine prints one, otherwise the matching
63
+ * `RepoDigests` entry. `registryRef` is the host-qualified name when it
64
+ * differs from the local reference that was pushed.
65
+ */
66
+ export const publishedRepoDigest = (
67
+ imageRef: string,
68
+ output: { stdout: string; stderr: string },
69
+ repoDigests: ReadonlyArray<string> | null | undefined,
70
+ registryRef?: string,
71
+ ): string | undefined => {
72
+ const text = `${output.stdout}\n${output.stderr}`;
73
+ return (
74
+ parseRepoDigest(imageRef, text) ??
75
+ (registryRef === undefined ? undefined : repoDigestFromInspect(registryRef, repoDigests)) ??
76
+ repoDigestFromInspect(imageRef, repoDigests)
77
+ );
78
+ };
79
+
80
+ /**
81
+ * Parses an image's RFC 3339 `Created` timestamp into epoch milliseconds.
82
+ *
83
+ * Docker only reports `Created` when the image config carries a creation time:
84
+ * API >= 1.44 omits it, older APIs backfill the year-1 zero value
85
+ * (`0001-01-01T00:00:00Z`), and 25.0.0–25.0.3 returned an empty string. In any
86
+ * of those cases there is no real build time, so we fall back to the wall clock.
87
+ */
88
+ export const parseCreatedAt = (created: string | undefined): number => {
89
+ const parsed = created ? Date.parse(created) : Number.NaN;
90
+ return Number.isNaN(parsed) || parsed <= 0 ? Date.now() : parsed;
91
+ };
@@ -0,0 +1,48 @@
1
+ import * as Data from "effect/Data";
2
+ import type { PlatformError } from "effect/PlatformError";
3
+
4
+ export class DockerRegistryBlobUnknown extends Data.TaggedError("DockerRegistryBlobUnknown")<{
5
+ readonly cause: PlatformError;
6
+ }> {
7
+ override get message() {
8
+ return this.cause.message;
9
+ }
10
+ }
11
+
12
+ export class DockerRegistryUnavailable extends Data.TaggedError("DockerRegistryUnavailable")<{
13
+ readonly cause: PlatformError;
14
+ }> {
15
+ override get message() {
16
+ return this.cause.message;
17
+ }
18
+ }
19
+
20
+ export type DockerImagePublicationError =
21
+ | PlatformError
22
+ | DockerRegistryBlobUnknown
23
+ | DockerRegistryUnavailable;
24
+
25
+ export const classifyDockerRegistryError = (error: PlatformError): DockerImagePublicationError => {
26
+ // Build logs can contain arbitrary Dockerfile output before the final error.
27
+ const message =
28
+ error.reason.description
29
+ ?.split("\n")
30
+ .map((line) => line.trim())
31
+ .filter(
32
+ (line) =>
33
+ line.length > 0 &&
34
+ !line.startsWith("View build details: docker-desktop://dashboard/build/"),
35
+ )
36
+ .at(-1) ?? "";
37
+ if (/(?:^|:\s*)(?:unknown:\s*)?blob unknown to registry$/i.test(message)) {
38
+ return new DockerRegistryBlobUnknown({ cause: error });
39
+ }
40
+ if (
41
+ /(?:unexpected (?:http )?status(?:[^\n]*:\s*|\s+)5\d\d(?:\s|$)|(?:^|:\s*)(?:500 internal server error|502 bad gateway|503 service unavailable|504 gateway timeout)$)/i.test(
42
+ message,
43
+ )
44
+ ) {
45
+ return new DockerRegistryUnavailable({ cause: error });
46
+ }
47
+ return error;
48
+ };
@@ -0,0 +1,255 @@
1
+ import { isResolved } from "alchemy/Diff";
2
+ import * as Provider from "alchemy/Provider";
3
+ import { Resource } from "alchemy/Resource";
4
+ import * as Effect from "effect/Effect";
5
+ import type { PlatformError } from "effect/PlatformError";
6
+ import { Docker, dockerContextName } from "./Docker.ts";
7
+ import type { Providers } from "./Providers.ts";
8
+ import {
9
+ type ImageRegistry,
10
+ parseCreatedAt,
11
+ publishedRepoDigest,
12
+ repositoryFromImageRef,
13
+ withRegistryHost,
14
+ } from "./Registry.ts";
15
+
16
+ export interface RemoteImageProps {
17
+ /** Docker image name to use as the source, without tag. */
18
+ name: string;
19
+ /** Docker source image tag. @default "latest" */
20
+ tag?: string;
21
+ /** Pull for this platform when the source is not already local or alwaysPull is enabled. */
22
+ platform?: string;
23
+ /**
24
+ * Always pull the source image, even when that reference exists locally.
25
+ *
26
+ * @default true
27
+ */
28
+ alwaysPull?: boolean;
29
+ /**
30
+ * Re-tag the pulled image under this repository/name. When omitted the pulled
31
+ * `name` is kept.
32
+ */
33
+ targetName?: string;
34
+ /**
35
+ * Tag applied to the re-tagged image.
36
+ *
37
+ * @default The pulled `tag`.
38
+ */
39
+ targetTag?: string;
40
+ /** Registry credentials. When set, the (re-tagged) image is pushed. */
41
+ registry?: ImageRegistry;
42
+ /**
43
+ * Skip registry push even when `registry` is set.
44
+ *
45
+ * @default false
46
+ */
47
+ skipPush?: boolean;
48
+ /** Docker context name or context resource. */
49
+ context?: Docker.ContextRef;
50
+ }
51
+
52
+ export interface RemoteImage extends Resource<
53
+ "Docker.RemoteImage",
54
+ RemoteImageProps,
55
+ {
56
+ /** Final image reference. Includes the registry host when pushed there. */
57
+ imageRef: string;
58
+ /** Local image id after reuse or pull. */
59
+ imageId: string;
60
+ /** Source image id used for the last reconcile, preserved across refresh. */
61
+ sourceImageId?: string;
62
+ /** Pull timestamp in milliseconds since epoch. */
63
+ createdAt: number;
64
+ /** Final image repository/name. */
65
+ name: string;
66
+ /** Final image tag. */
67
+ tag: string;
68
+ /** Registry digest after push when available. */
69
+ repoDigest?: string;
70
+ },
71
+ never,
72
+ Providers
73
+ > {}
74
+
75
+ /**
76
+ * Makes a source Docker image available through the active Docker context,
77
+ * optionally re-tagging it and pushing it to a registry.
78
+ *
79
+ * The image is available to other Docker resources by `imageRef`. Use
80
+ * `alwaysPull: false` reuses the source tag when it exists in the configured
81
+ * Docker daemon, pulling only when it is missing. Set `targetName`/`targetTag`
82
+ * to re-tag the source image, and `registry` to push it (mirroring it from a
83
+ * source registry into your own, for example).
84
+ *
85
+ *
86
+ * ### Pulling Images
87
+ * **Example:** Pull nginx
88
+ * ```typescript
89
+ * const nginx = yield* Docker.RemoteImage("nginx", {
90
+ * name: "nginx",
91
+ * tag: "alpine",
92
+ * });
93
+ * ```
94
+ *
95
+ * **Example:** Reuse an existing daemon tag
96
+ * ```typescript
97
+ * const postgres = yield* Docker.RemoteImage("postgres", {
98
+ * name: "postgres",
99
+ * tag: "18-alpine",
100
+ * alwaysPull: false,
101
+ * });
102
+ * ```
103
+ *
104
+ * ### Re-tagging and Pushing
105
+ * **Example:** Mirror a public image into your registry
106
+ * ```typescript
107
+ * const mirrored = yield* Docker.RemoteImage("nginx-mirror", {
108
+ * name: "nginx",
109
+ * tag: "alpine",
110
+ * targetName: "acme/nginx",
111
+ * targetTag: "alpine",
112
+ * registry: {
113
+ * server: "ghcr.io",
114
+ * username: "octocat",
115
+ * password: Config.Redacted("GITHUB_TOKEN"),
116
+ * },
117
+ * });
118
+ * ```
119
+ *
120
+ * ### Docker Context
121
+ * **Example:** Pull through a named Docker context
122
+ * ```typescript
123
+ * const nginx = yield* Docker.RemoteImage("nginx", {
124
+ * name: "nginx",
125
+ * tag: "alpine",
126
+ * context: "remote-build",
127
+ * });
128
+ * ```
129
+ *
130
+ * @resource
131
+ * @product Image
132
+ */
133
+ export const RemoteImage = Resource<RemoteImage>("Docker.RemoteImage");
134
+
135
+ export const RemoteImageProvider = () =>
136
+ Provider.effect(
137
+ RemoteImage,
138
+ Effect.gen(function* () {
139
+ const docker = yield* Docker;
140
+ const inspectImage = (ref: string, context: string | undefined) =>
141
+ docker.image
142
+ .inspect(ref, context)
143
+ .pipe(Effect.catchIf(isMissingImageError, () => Effect.succeed(undefined)));
144
+
145
+ return RemoteImage.Provider.of({
146
+ list: () => Effect.succeed([]),
147
+ read: Effect.fn(function* ({ olds, output }) {
148
+ const context = dockerContextName(olds.context);
149
+ const ref = output?.imageRef ?? targetImageRef(olds);
150
+ return yield* inspectImage(ref, context).pipe(
151
+ Effect.map((image) =>
152
+ image
153
+ ? {
154
+ imageRef: ref,
155
+ imageId: image.Id,
156
+ createdAt: output?.createdAt ?? parseCreatedAt(image.Created),
157
+ sourceImageId: output?.sourceImageId ?? output?.imageId ?? image.Id,
158
+ name: output?.name ?? repositoryFromImageRef(ref),
159
+ tag: output?.tag ?? targetTag(olds),
160
+ repoDigest: output?.repoDigest,
161
+ }
162
+ : undefined,
163
+ ),
164
+ );
165
+ }),
166
+ diff: Effect.fn(function* ({ output, news, olds }) {
167
+ if (!isResolved(news)) return undefined;
168
+ if (
169
+ dockerContextName(olds.context) !== dockerContextName(news.context) ||
170
+ !output ||
171
+ news.alwaysPull !== false ||
172
+ output.imageRef !== targetImageRef(news)
173
+ ) {
174
+ return { action: "update" };
175
+ }
176
+ const source = yield* inspectImage(remoteImageRef(news), dockerContextName(news.context));
177
+ const target = yield* inspectImage(targetImageRef(news), dockerContextName(news.context));
178
+ if (
179
+ !source ||
180
+ !target ||
181
+ source.Id !== (output.sourceImageId ?? output.imageId) ||
182
+ source.Id !== target.Id
183
+ ) {
184
+ return { action: "update" };
185
+ }
186
+ }),
187
+ reconcile: Effect.fn(function* ({ news, session }) {
188
+ const context = dockerContextName(news.context);
189
+ const sourceRef = remoteImageRef(news);
190
+ let source: Docker.Image | undefined;
191
+ if (news.alwaysPull === false) {
192
+ source = yield* inspectImage(sourceRef, context);
193
+ if (source) {
194
+ yield* session.note(`Reusing local Docker image: ${sourceRef}`);
195
+ }
196
+ }
197
+ if (!source) {
198
+ yield* session.note(`Pulling Docker image: ${sourceRef}`);
199
+ yield* docker.image.pull(sourceRef, news.platform, context);
200
+ source = yield* docker.image.inspect(sourceRef, context);
201
+ }
202
+
203
+ const finalRef = targetImageRef(news);
204
+ if (finalRef !== sourceRef || (news.registry && !news.skipPush)) {
205
+ yield* session.note(`Tagging Docker image: ${source.Id} -> ${finalRef}`);
206
+ yield* docker.image.tag(source.Id, finalRef, context);
207
+ }
208
+
209
+ let pushed: { stdout: string; stderr: string } | undefined;
210
+ if (news.registry && !news.skipPush) {
211
+ yield* session.note(`Pushing image to registry "${news.registry.server}"`);
212
+ pushed = yield* docker.image.push(finalRef, news.registry, undefined, context);
213
+ }
214
+
215
+ const inspected = yield* docker.image.inspect(finalRef, context);
216
+ const repoDigest =
217
+ pushed === undefined
218
+ ? undefined
219
+ : publishedRepoDigest(finalRef, pushed, inspected.RepoDigests);
220
+ return {
221
+ imageRef: finalRef,
222
+ imageId: inspected.Id,
223
+ sourceImageId: source.Id,
224
+ createdAt: parseCreatedAt(inspected.Created),
225
+ name: repositoryFromImageRef(finalRef),
226
+ tag: targetTag(news),
227
+ repoDigest,
228
+ };
229
+ }),
230
+ delete: Effect.fn(function* () {
231
+ // Remote images are not removed on destroy because tags may be shared by
232
+ // unrelated local stacks or developer workflows.
233
+ }),
234
+ });
235
+ }),
236
+ );
237
+
238
+ /** The reference the image is pulled from. */
239
+ const remoteImageRef = (props: RemoteImageProps): string =>
240
+ `${props.name}:${props.tag ?? "latest"}`;
241
+
242
+ const isMissingImageError = (error: PlatformError): boolean =>
243
+ error.reason._tag === "NotFound" &&
244
+ /no such image|image not known|image not found/i.test(error.reason.description ?? "");
245
+
246
+ const targetTag = (props: RemoteImageProps): string => props.targetTag ?? props.tag ?? "latest";
247
+
248
+ /**
249
+ * The final reference after re-tagging and registry-host prefixing. Equals the
250
+ * pulled reference when no re-tag/registry is configured.
251
+ */
252
+ const targetImageRef = (props: RemoteImageProps): string => {
253
+ const local = `${props.targetName ?? props.name}:${targetTag(props)}`;
254
+ return props.registry && !props.skipPush ? withRegistryHost(local, props.registry) : local;
255
+ };