@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.
- package/LICENSE +201 -0
- package/README.md +46 -4
- package/lib/Bootstrap.d.ts +7 -0
- package/lib/Bootstrap.d.ts.map +1 -0
- package/lib/Bootstrap.js +25 -0
- package/lib/Bootstrap.js.map +1 -0
- package/lib/BuildHash.d.ts +47 -0
- package/lib/BuildHash.d.ts.map +1 -0
- package/lib/BuildHash.js +165 -0
- package/lib/BuildHash.js.map +1 -0
- package/lib/Container.d.ts +352 -0
- package/lib/Container.d.ts.map +1 -0
- package/lib/Container.js +531 -0
- package/lib/Container.js.map +1 -0
- package/lib/Context.d.ts +86 -0
- package/lib/Context.d.ts.map +1 -0
- package/lib/Context.js +146 -0
- package/lib/Context.js.map +1 -0
- package/lib/Docker.d.ts +448 -0
- package/lib/Docker.d.ts.map +1 -0
- package/lib/Docker.js +416 -0
- package/lib/Docker.js.map +1 -0
- package/lib/Dockerfile.d.ts +72 -0
- package/lib/Dockerfile.d.ts.map +1 -0
- package/lib/Dockerfile.js +12 -0
- package/lib/Dockerfile.js.map +1 -0
- package/lib/HealthcheckCommand.d.ts +17 -0
- package/lib/HealthcheckCommand.d.ts.map +1 -0
- package/lib/HealthcheckCommand.js +33 -0
- package/lib/HealthcheckCommand.js.map +1 -0
- package/lib/Image.d.ts +151 -0
- package/lib/Image.d.ts.map +1 -0
- package/lib/Image.js +192 -0
- package/lib/Image.js.map +1 -0
- package/lib/ImageSource.d.ts +234 -0
- package/lib/ImageSource.d.ts.map +1 -0
- package/lib/ImageSource.js +469 -0
- package/lib/ImageSource.js.map +1 -0
- package/lib/Network.d.ts +70 -0
- package/lib/Network.d.ts.map +1 -0
- package/lib/Network.js +111 -0
- package/lib/Network.js.map +1 -0
- package/lib/Providers.d.ts +21 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +26 -0
- package/lib/Providers.js.map +1 -0
- package/lib/Registry.d.ts +47 -0
- package/lib/Registry.d.ts.map +1 -0
- package/lib/Registry.js +67 -0
- package/lib/Registry.js.map +1 -0
- package/lib/RegistryError.d.ts +21 -0
- package/lib/RegistryError.d.ts.map +1 -0
- package/lib/RegistryError.js +28 -0
- package/lib/RegistryError.js.map +1 -0
- package/lib/RemoteImage.d.ts +118 -0
- package/lib/RemoteImage.d.ts.map +1 -0
- package/lib/RemoteImage.js +164 -0
- package/lib/RemoteImage.js.map +1 -0
- package/lib/Service.d.ts +478 -0
- package/lib/Service.d.ts.map +1 -0
- package/lib/Service.js +588 -0
- package/lib/Service.js.map +1 -0
- package/lib/ServiceImage.d.ts +78 -0
- package/lib/ServiceImage.d.ts.map +1 -0
- package/lib/ServiceImage.js +162 -0
- package/lib/ServiceImage.js.map +1 -0
- package/lib/Swarm.d.ts +119 -0
- package/lib/Swarm.d.ts.map +1 -0
- package/lib/Swarm.js +148 -0
- package/lib/Swarm.js.map +1 -0
- package/lib/Volume.d.ts +94 -0
- package/lib/Volume.d.ts.map +1 -0
- package/lib/Volume.js +133 -0
- package/lib/Volume.js.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -0
- package/package.json +58 -3
- package/src/Bootstrap.ts +51 -0
- package/src/BuildHash.ts +255 -0
- package/src/Container.ts +792 -0
- package/src/Context.ts +215 -0
- package/src/Docker.ts +1079 -0
- package/src/Dockerfile.ts +81 -0
- package/src/HealthcheckCommand.ts +35 -0
- package/src/Image.ts +293 -0
- package/src/ImageSource.ts +769 -0
- package/src/Network.ts +169 -0
- package/src/Providers.ts +46 -0
- package/src/Registry.ts +91 -0
- package/src/RegistryError.ts +48 -0
- package/src/RemoteImage.ts +255 -0
- package/src/Service.ts +1080 -0
- package/src/ServiceImage.ts +252 -0
- package/src/Swarm.ts +221 -0
- package/src/Volume.ts +193 -0
- 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
|
+
});
|
package/src/Providers.ts
ADDED
|
@@ -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
|
+
);
|
package/src/Registry.ts
ADDED
|
@@ -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
|
+
};
|