alchemy 0.40.1 → 0.41.0
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/lib/build-date.d.ts +1 -1
- package/lib/build-date.js +1 -1
- package/lib/cloudflare/bindings.d.ts +2 -1
- package/lib/cloudflare/bindings.d.ts.map +1 -1
- package/lib/cloudflare/bindings.js.map +1 -1
- package/lib/cloudflare/bound.d.ts +2 -1
- package/lib/cloudflare/bound.d.ts.map +1 -1
- package/lib/cloudflare/bundle/bundle-worker-dev.d.ts.map +1 -1
- package/lib/cloudflare/bundle/bundle-worker-dev.js +2 -1
- package/lib/cloudflare/bundle/bundle-worker-dev.js.map +1 -1
- package/lib/cloudflare/bundle/bundle-worker.d.ts.map +1 -1
- package/lib/cloudflare/bundle/bundle-worker.js +2 -1
- package/lib/cloudflare/bundle/bundle-worker.js.map +1 -1
- package/lib/cloudflare/container.d.ts +509 -0
- package/lib/cloudflare/container.d.ts.map +1 -0
- package/lib/cloudflare/container.js +274 -0
- package/lib/cloudflare/container.js.map +1 -0
- package/lib/cloudflare/index.d.ts +1 -0
- package/lib/cloudflare/index.d.ts.map +1 -1
- package/lib/cloudflare/index.js +1 -0
- package/lib/cloudflare/index.js.map +1 -1
- package/lib/cloudflare/worker/miniflare-worker-options.d.ts.map +1 -1
- package/lib/cloudflare/worker/miniflare-worker-options.js +28 -0
- package/lib/cloudflare/worker/miniflare-worker-options.js.map +1 -1
- package/lib/cloudflare/worker-metadata.d.ts +3 -0
- package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
- package/lib/cloudflare/worker-metadata.js +22 -3
- package/lib/cloudflare/worker-metadata.js.map +1 -1
- package/lib/cloudflare/worker.d.ts +2 -1
- package/lib/cloudflare/worker.d.ts.map +1 -1
- package/lib/cloudflare/worker.js +39 -3
- package/lib/cloudflare/worker.js.map +1 -1
- package/lib/cloudflare/wrangler.json.js +11 -0
- package/lib/cloudflare/wrangler.json.js.map +1 -1
- package/lib/docker/api.d.ts +15 -0
- package/lib/docker/api.d.ts.map +1 -1
- package/lib/docker/api.js +59 -0
- package/lib/docker/api.js.map +1 -1
- package/lib/docker/image.d.ts +22 -4
- package/lib/docker/image.d.ts.map +1 -1
- package/lib/docker/image.js +68 -24
- package/lib/docker/image.js.map +1 -1
- package/lib/github/index.d.ts +1 -0
- package/lib/github/index.d.ts.map +1 -1
- package/lib/github/index.js +1 -0
- package/lib/github/index.js.map +1 -1
- package/lib/github/repository-webhook.d.ts +123 -0
- package/lib/github/repository-webhook.d.ts.map +1 -0
- package/lib/github/repository-webhook.js +146 -0
- package/lib/github/repository-webhook.js.map +1 -0
- package/lib/scope.d.ts +2 -0
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +4 -0
- package/lib/scope.js.map +1 -1
- package/package.json +3 -2
- package/src/build-date.ts +1 -1
- package/src/cloudflare/bindings.ts +2 -0
- package/src/cloudflare/bound.ts +9 -1
- package/src/cloudflare/bundle/bundle-worker-dev.ts +2 -1
- package/src/cloudflare/bundle/bundle-worker.ts +2 -1
- package/src/cloudflare/container.ts +843 -0
- package/src/cloudflare/index.ts +1 -0
- package/src/cloudflare/worker/miniflare-worker-options.ts +28 -0
- package/src/cloudflare/worker-metadata.ts +28 -6
- package/src/cloudflare/worker.ts +67 -16
- package/src/cloudflare/wrangler.json.ts +12 -0
- package/src/docker/api.ts +74 -0
- package/src/docker/image.ts +99 -29
- package/src/github/index.ts +1 -0
- package/src/github/repository-webhook.ts +269 -0
- package/src/scope.ts +5 -0
package/src/cloudflare/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./browser-rendering.ts";
|
|
|
13
13
|
export * from "./bucket.ts";
|
|
14
14
|
export * from "./bundle/external.ts";
|
|
15
15
|
export * from "./bundle/local-dev-cloudflare-shim.ts";
|
|
16
|
+
export * from "./container.ts";
|
|
16
17
|
export * from "./custom-domain.ts";
|
|
17
18
|
export * from "./d1-clone.ts";
|
|
18
19
|
export * from "./d1-database.ts";
|
|
@@ -225,6 +225,11 @@ export function buildMiniflareWorkerOptions({
|
|
|
225
225
|
compatibilityDate,
|
|
226
226
|
compatibilityFlags,
|
|
227
227
|
unsafeDirectSockets: [{ entrypoint: undefined, proxy: true }],
|
|
228
|
+
// containerEngine: {
|
|
229
|
+
// localDocker: {
|
|
230
|
+
// socketPath: "/var/run/docker.sock",
|
|
231
|
+
// }
|
|
232
|
+
// }
|
|
228
233
|
};
|
|
229
234
|
for (const [name, binding] of Object.entries(bindings ?? {})) {
|
|
230
235
|
if (typeof binding === "string") {
|
|
@@ -513,6 +518,29 @@ export function buildMiniflareWorkerOptions({
|
|
|
513
518
|
};
|
|
514
519
|
break;
|
|
515
520
|
}
|
|
521
|
+
case "container": {
|
|
522
|
+
(options.durableObjects ??= {})[name] = {
|
|
523
|
+
className: binding.className,
|
|
524
|
+
scriptName: binding.scriptName,
|
|
525
|
+
useSQLite: binding.sqlite,
|
|
526
|
+
container: {
|
|
527
|
+
imageName: binding.image.name,
|
|
528
|
+
},
|
|
529
|
+
// namespaceId: binding.namespaceId,
|
|
530
|
+
// unsafeUniqueKey?: string | typeof kUnsafeEphemeralUniqueKey | undefined;
|
|
531
|
+
// unsafePreventEviction?: boolean | undefined;
|
|
532
|
+
// remoteProxyConnectionString: binding.local
|
|
533
|
+
// ? undefined
|
|
534
|
+
// : remoteProxyConnectionString,
|
|
535
|
+
};
|
|
536
|
+
if (!binding.scriptName || binding.scriptName === workerName) {
|
|
537
|
+
options.unsafeDirectSockets!.push({
|
|
538
|
+
entrypoint: binding.className,
|
|
539
|
+
proxy: true,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
516
544
|
default: {
|
|
517
545
|
assertNever(binding);
|
|
518
546
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type WorkerBindingDurableObjectNamespace,
|
|
9
9
|
type WorkerBindingSpec,
|
|
10
10
|
} from "./bindings.ts";
|
|
11
|
+
import { isContainer, type Container } from "./container.ts";
|
|
11
12
|
import {
|
|
12
13
|
isDurableObjectNamespace,
|
|
13
14
|
type DurableObjectNamespace,
|
|
@@ -194,6 +195,7 @@ export interface WorkerMetadata {
|
|
|
194
195
|
cron: string;
|
|
195
196
|
suspended: boolean;
|
|
196
197
|
}[];
|
|
198
|
+
containers?: { class_name: string }[];
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
export async function prepareWorkerMetadata(
|
|
@@ -207,8 +209,12 @@ export async function prepareWorkerMetadata(
|
|
|
207
209
|
},
|
|
208
210
|
): Promise<WorkerMetadata> {
|
|
209
211
|
const oldSettings = await getWorkerSettings(api, props.workerName);
|
|
210
|
-
const oldTags: string[] | undefined =
|
|
211
|
-
|
|
212
|
+
const oldTags: string[] | undefined = Array.from(
|
|
213
|
+
new Set([
|
|
214
|
+
...(oldSettings?.default_environment?.script?.tags ?? []),
|
|
215
|
+
...(oldSettings?.tags ?? []),
|
|
216
|
+
]),
|
|
217
|
+
);
|
|
212
218
|
const oldBindings = oldSettings?.bindings;
|
|
213
219
|
// we use Cloudflare Worker tags to store a mapping between Alchemy's stable identifier and the binding name
|
|
214
220
|
// e.g.
|
|
@@ -254,8 +260,9 @@ export async function prepareWorkerMetadata(
|
|
|
254
260
|
|
|
255
261
|
// try and find the DO binding by stable id
|
|
256
262
|
const object = Object.values(props.bindings).find(
|
|
257
|
-
(binding): binding is DurableObjectNamespace
|
|
258
|
-
isDurableObjectNamespace(binding)
|
|
263
|
+
(binding): binding is DurableObjectNamespace | Container =>
|
|
264
|
+
(isDurableObjectNamespace(binding) || isContainer(binding)) &&
|
|
265
|
+
binding.id === stableId,
|
|
259
266
|
);
|
|
260
267
|
if (object) {
|
|
261
268
|
// we found the corresponding object, it should not be deleted
|
|
@@ -302,7 +309,7 @@ export async function prepareWorkerMetadata(
|
|
|
302
309
|
// we use this to reliably compute class migrations based on server-side state
|
|
303
310
|
...Object.entries(props.bindings ?? {}).flatMap(
|
|
304
311
|
([bindingName, binding]) =>
|
|
305
|
-
isDurableObjectNamespace(binding)
|
|
312
|
+
isDurableObjectNamespace(binding) || isContainer(binding)
|
|
306
313
|
? // TODO(sam): base64 encode if contains `:`?
|
|
307
314
|
[`alchemy:do:${binding.id}:${bindingName}`]
|
|
308
315
|
: [],
|
|
@@ -512,6 +519,21 @@ export async function prepareWorkerMetadata(
|
|
|
512
519
|
key_base64: binding.key_base64?.unencrypted,
|
|
513
520
|
key_jwk: binding.key_jwk?.unencrypted,
|
|
514
521
|
});
|
|
522
|
+
} else if (binding.type === "container") {
|
|
523
|
+
meta.bindings.push({
|
|
524
|
+
type: "durable_object_namespace",
|
|
525
|
+
class_name: binding.className,
|
|
526
|
+
name: bindingName,
|
|
527
|
+
});
|
|
528
|
+
if (
|
|
529
|
+
binding.scriptName === undefined ||
|
|
530
|
+
// TODO(sam): not sure if Cloudflare Api would like us using `this` worker name here
|
|
531
|
+
binding.scriptName === props.workerName
|
|
532
|
+
) {
|
|
533
|
+
// we do not need configure class migrations for cross-script bindings
|
|
534
|
+
configureClassMigration(bindingName, binding);
|
|
535
|
+
}
|
|
536
|
+
(meta.containers ??= []).push({ class_name: binding.className });
|
|
515
537
|
} else {
|
|
516
538
|
assertNever(
|
|
517
539
|
binding,
|
|
@@ -525,7 +547,7 @@ export async function prepareWorkerMetadata(
|
|
|
525
547
|
|
|
526
548
|
function configureClassMigration(
|
|
527
549
|
bindingName: string,
|
|
528
|
-
newBinding: DurableObjectNamespace<any
|
|
550
|
+
newBinding: DurableObjectNamespace<any> | Container,
|
|
529
551
|
) {
|
|
530
552
|
let prevBinding: WorkerBindingDurableObjectNamespace | undefined;
|
|
531
553
|
if (oldBindings) {
|
package/src/cloudflare/worker.ts
CHANGED
|
@@ -33,7 +33,9 @@ import {
|
|
|
33
33
|
type Binding,
|
|
34
34
|
type Bindings,
|
|
35
35
|
Json,
|
|
36
|
+
type WorkerBindingDurableObjectNamespace,
|
|
36
37
|
type WorkerBindingService,
|
|
38
|
+
type WorkerBindingSpec,
|
|
37
39
|
} from "./bindings.ts";
|
|
38
40
|
import type { Bound } from "./bound.ts";
|
|
39
41
|
import { isBucket } from "./bucket.ts";
|
|
@@ -42,6 +44,7 @@ import {
|
|
|
42
44
|
type NoBundleResult,
|
|
43
45
|
bundleWorkerScript,
|
|
44
46
|
} from "./bundle/bundle-worker.ts";
|
|
47
|
+
import { type Container, ContainerApplication } from "./container.ts";
|
|
45
48
|
import { CustomDomain } from "./custom-domain.ts";
|
|
46
49
|
import { isD1Database } from "./d1-database.ts";
|
|
47
50
|
import type { DispatchNamespaceResource } from "./dispatch-namespace.ts";
|
|
@@ -1176,6 +1179,7 @@ export const _Worker = Resource(
|
|
|
1176
1179
|
// Find any assets bindings
|
|
1177
1180
|
const assetsBindings: { name: string; assets: Assets }[] = [];
|
|
1178
1181
|
const workflowsBindings: Workflow[] = [];
|
|
1182
|
+
const containersBindings: Container[] = [];
|
|
1179
1183
|
|
|
1180
1184
|
if (props.bindings) {
|
|
1181
1185
|
for (const [bindingName, binding] of Object.entries(props.bindings)) {
|
|
@@ -1184,6 +1188,8 @@ export const _Worker = Resource(
|
|
|
1184
1188
|
assetsBindings.push({ name: bindingName, assets: binding });
|
|
1185
1189
|
} else if (binding.type === "workflow") {
|
|
1186
1190
|
workflowsBindings.push(binding);
|
|
1191
|
+
} else if (binding.type === "container") {
|
|
1192
|
+
containersBindings.push(binding);
|
|
1187
1193
|
}
|
|
1188
1194
|
}
|
|
1189
1195
|
}
|
|
@@ -1223,6 +1229,7 @@ export const _Worker = Resource(
|
|
|
1223
1229
|
compatibilityFlags,
|
|
1224
1230
|
assetUploadResult,
|
|
1225
1231
|
});
|
|
1232
|
+
versionResult.versionId;
|
|
1226
1233
|
|
|
1227
1234
|
for (const workflow of workflowsBindings) {
|
|
1228
1235
|
if (
|
|
@@ -1237,6 +1244,47 @@ export const _Worker = Resource(
|
|
|
1237
1244
|
}
|
|
1238
1245
|
}
|
|
1239
1246
|
|
|
1247
|
+
for (const container of containersBindings) {
|
|
1248
|
+
async function findNamespaceId() {
|
|
1249
|
+
const response = await api.get(
|
|
1250
|
+
`/accounts/${api.accountId}/workers/scripts/${workerName}/versions/${versionResult.deploymentId}`,
|
|
1251
|
+
);
|
|
1252
|
+
const result = (await response.json()) as {
|
|
1253
|
+
result: {
|
|
1254
|
+
resources: {
|
|
1255
|
+
bindings: WorkerBindingSpec[];
|
|
1256
|
+
};
|
|
1257
|
+
};
|
|
1258
|
+
};
|
|
1259
|
+
const namespaceId = result.result.resources.bindings.find(
|
|
1260
|
+
(binding): binding is WorkerBindingDurableObjectNamespace =>
|
|
1261
|
+
binding.type === "durable_object_namespace" &&
|
|
1262
|
+
binding.class_name === container.className,
|
|
1263
|
+
)?.namespace_id;
|
|
1264
|
+
if (!namespaceId) {
|
|
1265
|
+
throw new Error(
|
|
1266
|
+
`Namespace ID not found for container ${container.id}`,
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
return namespaceId;
|
|
1270
|
+
}
|
|
1271
|
+
await ContainerApplication(container.id, {
|
|
1272
|
+
accountId: props.accountId,
|
|
1273
|
+
apiKey: props.apiKey,
|
|
1274
|
+
apiToken: props.apiToken,
|
|
1275
|
+
baseUrl: props.baseUrl,
|
|
1276
|
+
email: props.email,
|
|
1277
|
+
image: container.image,
|
|
1278
|
+
name: container.id,
|
|
1279
|
+
instanceType: container.instanceType,
|
|
1280
|
+
observability: container.observability,
|
|
1281
|
+
durableObjects: {
|
|
1282
|
+
namespaceId: await findNamespaceId(),
|
|
1283
|
+
},
|
|
1284
|
+
schedulingPolicy: container.schedulingPolicy,
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1240
1288
|
await Promise.all(
|
|
1241
1289
|
props.eventSources?.map((eventSource) => {
|
|
1242
1290
|
if (isQueue(eventSource) || isQueueEventSource(eventSource)) {
|
|
@@ -1582,7 +1630,7 @@ type PutWorkerOptions = WorkerProps & {
|
|
|
1582
1630
|
export async function putWorker(
|
|
1583
1631
|
api: CloudflareApi,
|
|
1584
1632
|
props: PutWorkerOptions,
|
|
1585
|
-
): Promise<{ versionId?: string; previewUrl?: string }> {
|
|
1633
|
+
): Promise<{ versionId?: string; previewUrl?: string; deploymentId: string }> {
|
|
1586
1634
|
const {
|
|
1587
1635
|
//
|
|
1588
1636
|
dispatchNamespace,
|
|
@@ -1727,23 +1775,23 @@ export async function putWorker(
|
|
|
1727
1775
|
}
|
|
1728
1776
|
}
|
|
1729
1777
|
}
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
number: number;
|
|
1737
|
-
metadata: {
|
|
1738
|
-
has_preview: boolean;
|
|
1739
|
-
};
|
|
1740
|
-
annotations?: {
|
|
1741
|
-
"workers/tag"?: string;
|
|
1742
|
-
};
|
|
1778
|
+
const responseData = (await uploadResponse.json()) as {
|
|
1779
|
+
result: {
|
|
1780
|
+
id: string;
|
|
1781
|
+
number: number;
|
|
1782
|
+
metadata: {
|
|
1783
|
+
has_preview: boolean;
|
|
1743
1784
|
};
|
|
1785
|
+
annotations?: {
|
|
1786
|
+
"workers/tag"?: string;
|
|
1787
|
+
};
|
|
1788
|
+
deployment_id: string;
|
|
1744
1789
|
};
|
|
1745
|
-
|
|
1790
|
+
};
|
|
1791
|
+
const result = responseData.result;
|
|
1746
1792
|
|
|
1793
|
+
// Handle version response
|
|
1794
|
+
if (props.version) {
|
|
1747
1795
|
// Get the account's workers.dev subdomain to construct preview URL
|
|
1748
1796
|
let previewUrl: string | undefined;
|
|
1749
1797
|
if (result.metadata?.has_preview) {
|
|
@@ -1770,10 +1818,13 @@ export async function putWorker(
|
|
|
1770
1818
|
return {
|
|
1771
1819
|
versionId: result.id,
|
|
1772
1820
|
previewUrl,
|
|
1821
|
+
deploymentId: result.deployment_id,
|
|
1773
1822
|
};
|
|
1774
1823
|
}
|
|
1775
1824
|
|
|
1776
|
-
return {
|
|
1825
|
+
return {
|
|
1826
|
+
deploymentId: result.deployment_id,
|
|
1827
|
+
};
|
|
1777
1828
|
},
|
|
1778
1829
|
(err) =>
|
|
1779
1830
|
err.status === 404 ||
|
|
@@ -447,6 +447,9 @@ function processBindings(
|
|
|
447
447
|
binding: string;
|
|
448
448
|
namespace: string;
|
|
449
449
|
}[] = [];
|
|
450
|
+
const containers: {
|
|
451
|
+
class_name: string;
|
|
452
|
+
}[] = [];
|
|
450
453
|
|
|
451
454
|
for (const eventSource of eventSources ?? []) {
|
|
452
455
|
if (isQueueEventSource(eventSource)) {
|
|
@@ -619,6 +622,15 @@ function processBindings(
|
|
|
619
622
|
});
|
|
620
623
|
} else if (binding.type === "secret_key") {
|
|
621
624
|
// no-op
|
|
625
|
+
} else if (binding.type === "container") {
|
|
626
|
+
durableObjects.push({
|
|
627
|
+
name: bindingName,
|
|
628
|
+
class_name: binding.className,
|
|
629
|
+
script_name: binding.scriptName,
|
|
630
|
+
});
|
|
631
|
+
containers.push({
|
|
632
|
+
class_name: binding.className,
|
|
633
|
+
});
|
|
622
634
|
} else {
|
|
623
635
|
// biome-ignore lint/correctness/noVoidTypeReturn: it returns never
|
|
624
636
|
return assertNever(binding);
|
package/src/docker/api.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { exec } from "../os/exec.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -362,4 +363,77 @@ export class DockerApi {
|
|
|
362
363
|
return false;
|
|
363
364
|
}
|
|
364
365
|
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Login to a Docker registry
|
|
369
|
+
*
|
|
370
|
+
* @param registry Registry URL
|
|
371
|
+
* @param username Username for authentication
|
|
372
|
+
* @param password Password for authentication
|
|
373
|
+
* @returns Promise that resolves when login is successful
|
|
374
|
+
*/
|
|
375
|
+
async login(
|
|
376
|
+
registry: string,
|
|
377
|
+
username: string,
|
|
378
|
+
password: string,
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
return new Promise((resolve, reject) => {
|
|
381
|
+
const args = [
|
|
382
|
+
"login",
|
|
383
|
+
registry,
|
|
384
|
+
"--username",
|
|
385
|
+
username,
|
|
386
|
+
"--password-stdin",
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
const child = spawn(this.dockerPath, args, {
|
|
390
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
let stdout = "";
|
|
394
|
+
let stderr = "";
|
|
395
|
+
|
|
396
|
+
child.stdout.on("data", (data) => {
|
|
397
|
+
stdout += data.toString();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
child.stderr.on("data", (data) => {
|
|
401
|
+
stderr += data.toString();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
child.on("close", (code) => {
|
|
405
|
+
if (code === 0) {
|
|
406
|
+
resolve();
|
|
407
|
+
} else {
|
|
408
|
+
reject(
|
|
409
|
+
new Error(
|
|
410
|
+
`Docker login failed with exit code ${code}: ${stderr || stdout}`,
|
|
411
|
+
),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
child.on("error", (err) => {
|
|
417
|
+
reject(new Error(`Docker login failed: ${err.message}`));
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Write password to stdin and close the stream
|
|
421
|
+
child.stdin.write(password);
|
|
422
|
+
child.stdin.end();
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Logout from a Docker registry
|
|
428
|
+
*
|
|
429
|
+
* @param registry Registry URL
|
|
430
|
+
*/
|
|
431
|
+
async logout(registry: string): Promise<void> {
|
|
432
|
+
try {
|
|
433
|
+
await this.exec(["logout", registry]);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
// Ignore logout errors as they're not critical
|
|
436
|
+
console.warn(`Docker logout failed: ${error}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
365
439
|
}
|
package/src/docker/image.ts
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { Context } from "../context.ts";
|
|
4
4
|
import { Resource } from "../resource.ts";
|
|
5
|
+
import type { Secret } from "../secret.ts";
|
|
5
6
|
import { DockerApi } from "./api.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -10,11 +11,15 @@ import { DockerApi } from "./api.ts";
|
|
|
10
11
|
export interface DockerBuildOptions {
|
|
11
12
|
/**
|
|
12
13
|
* Path to the build context directory
|
|
14
|
+
*
|
|
15
|
+
* @default - the `dirname(dockerfile)` if provided or otherwise `process.cwd()`
|
|
13
16
|
*/
|
|
14
|
-
context
|
|
17
|
+
context?: string;
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* Path to the Dockerfile, relative to context
|
|
21
|
+
*
|
|
22
|
+
* @default - `Dockerfile`
|
|
18
23
|
*/
|
|
19
24
|
dockerfile?: string;
|
|
20
25
|
|
|
@@ -39,6 +44,12 @@ export interface DockerBuildOptions {
|
|
|
39
44
|
cacheFrom?: string[];
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
export interface ImageRegistry {
|
|
48
|
+
username: string;
|
|
49
|
+
password: Secret;
|
|
50
|
+
server: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
/**
|
|
43
54
|
* Properties for creating a Docker image
|
|
44
55
|
*/
|
|
@@ -46,7 +57,7 @@ export interface ImageProps {
|
|
|
46
57
|
/**
|
|
47
58
|
* Repository name for the image (e.g., "username/image")
|
|
48
59
|
*/
|
|
49
|
-
name
|
|
60
|
+
name?: string;
|
|
50
61
|
|
|
51
62
|
/**
|
|
52
63
|
* Tag for the image (e.g., "latest")
|
|
@@ -56,7 +67,12 @@ export interface ImageProps {
|
|
|
56
67
|
/**
|
|
57
68
|
* Build configuration
|
|
58
69
|
*/
|
|
59
|
-
build
|
|
70
|
+
build?: DockerBuildOptions;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Registry credentials
|
|
74
|
+
*/
|
|
75
|
+
registry?: ImageRegistry;
|
|
60
76
|
|
|
61
77
|
/**
|
|
62
78
|
* Whether to skip pushing the image to registry
|
|
@@ -68,6 +84,11 @@ export interface ImageProps {
|
|
|
68
84
|
* Docker Image resource
|
|
69
85
|
*/
|
|
70
86
|
export interface Image extends Resource<"docker::Image">, ImageProps {
|
|
87
|
+
/**
|
|
88
|
+
* Image name
|
|
89
|
+
*/
|
|
90
|
+
name: string;
|
|
91
|
+
|
|
71
92
|
/**
|
|
72
93
|
* Full image reference (name:tag)
|
|
73
94
|
*/
|
|
@@ -110,7 +131,7 @@ export const Image = Resource(
|
|
|
110
131
|
"docker::Image",
|
|
111
132
|
async function (
|
|
112
133
|
this: Context<Image>,
|
|
113
|
-
|
|
134
|
+
id: string,
|
|
114
135
|
props: ImageProps,
|
|
115
136
|
): Promise<Image> {
|
|
116
137
|
// Initialize Docker API client
|
|
@@ -123,34 +144,44 @@ export const Image = Resource(
|
|
|
123
144
|
} else {
|
|
124
145
|
// Normalize properties
|
|
125
146
|
const tag = props.tag || "latest";
|
|
126
|
-
const
|
|
147
|
+
const name = props.name || id;
|
|
148
|
+
const imageRef = `${name}:${tag}`;
|
|
127
149
|
|
|
128
|
-
|
|
129
|
-
|
|
150
|
+
let context: string;
|
|
151
|
+
let dockerfile: string;
|
|
152
|
+
if (props.build?.dockerfile && props.build?.context) {
|
|
153
|
+
context = path.resolve(props.build.context);
|
|
154
|
+
dockerfile = path.resolve(context, props.build.dockerfile);
|
|
155
|
+
} else if (props.build?.dockerfile) {
|
|
156
|
+
context = process.cwd();
|
|
157
|
+
dockerfile = path.resolve(context, props.build.dockerfile);
|
|
158
|
+
} else if (props.build?.context) {
|
|
159
|
+
context = path.resolve(props.build.context);
|
|
160
|
+
dockerfile = path.resolve(context, "Dockerfile");
|
|
161
|
+
} else {
|
|
162
|
+
context = process.cwd();
|
|
163
|
+
dockerfile = path.resolve(context, "Dockerfile");
|
|
164
|
+
}
|
|
130
165
|
await fs.access(context);
|
|
131
|
-
|
|
132
|
-
// Determine Dockerfile path
|
|
133
|
-
const dockerfile = props.build.dockerfile || "Dockerfile";
|
|
134
|
-
const dockerfilePath = path.join(context, dockerfile);
|
|
135
|
-
await fs.access(dockerfilePath);
|
|
166
|
+
await fs.access(dockerfile);
|
|
136
167
|
|
|
137
168
|
// Prepare build options
|
|
138
|
-
const buildOptions: Record<string, string> = props.build
|
|
169
|
+
const buildOptions: Record<string, string> = props.build?.buildArgs || {};
|
|
139
170
|
|
|
140
171
|
// Add platform if specified
|
|
141
172
|
let buildArgs = ["build", "-t", imageRef];
|
|
142
173
|
|
|
143
|
-
if (props.build
|
|
174
|
+
if (props.build?.platform) {
|
|
144
175
|
buildArgs.push("--platform", props.build.platform);
|
|
145
176
|
}
|
|
146
177
|
|
|
147
178
|
// Add target if specified
|
|
148
|
-
if (props.build
|
|
179
|
+
if (props.build?.target) {
|
|
149
180
|
buildArgs.push("--target", props.build.target);
|
|
150
181
|
}
|
|
151
182
|
|
|
152
183
|
// Add cache sources if specified
|
|
153
|
-
if (props.build
|
|
184
|
+
if (props.build?.cacheFrom && props.build.cacheFrom.length > 0) {
|
|
154
185
|
for (const cacheSource of props.build.cacheFrom) {
|
|
155
186
|
buildArgs.push("--cache-from", cacheSource);
|
|
156
187
|
}
|
|
@@ -161,36 +192,75 @@ export const Image = Resource(
|
|
|
161
192
|
buildArgs.push("--build-arg", `${key}="${value}"`);
|
|
162
193
|
}
|
|
163
194
|
|
|
164
|
-
|
|
165
|
-
if (props.build.dockerfile && props.build.dockerfile !== "Dockerfile") {
|
|
166
|
-
buildArgs.push("-f", props.build.dockerfile);
|
|
167
|
-
}
|
|
195
|
+
buildArgs.push("-f", dockerfile);
|
|
168
196
|
|
|
169
197
|
// Add context path
|
|
170
|
-
buildArgs.push(
|
|
198
|
+
buildArgs.push(context);
|
|
171
199
|
|
|
172
200
|
// Execute build command
|
|
173
|
-
console.log(`Building Docker image: ${imageRef}`);
|
|
174
201
|
const { stdout } = await api.exec(buildArgs);
|
|
175
202
|
|
|
176
203
|
// Extract image ID from build output if available
|
|
177
204
|
const imageIdMatch = /Successfully built ([a-f0-9]+)/.exec(stdout);
|
|
178
205
|
const imageId = imageIdMatch ? imageIdMatch[1] : undefined;
|
|
179
206
|
|
|
180
|
-
console.log(`Successfully built Docker image: ${imageRef}`);
|
|
181
|
-
|
|
182
207
|
// Handle push if required
|
|
183
208
|
let repoDigest: string | undefined;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
209
|
+
let finalImageRef = imageRef;
|
|
210
|
+
if (props.registry && !props.skipPush) {
|
|
211
|
+
const { server, username, password } = props.registry;
|
|
212
|
+
|
|
213
|
+
// Ensure the registry server does not have trailing slash
|
|
214
|
+
const registryHost = server.replace(/\/$/, "");
|
|
215
|
+
|
|
216
|
+
// Determine if the built image already includes a registry host (e.g. ghcr.io/user/repo)
|
|
217
|
+
const firstSegment = imageRef.split("/")[0];
|
|
218
|
+
const hasRegistryPrefix = firstSegment.includes(".");
|
|
219
|
+
|
|
220
|
+
// Compose the target image reference that will be pushed
|
|
221
|
+
const targetImage = hasRegistryPrefix
|
|
222
|
+
? imageRef // already fully-qualified
|
|
223
|
+
: `${registryHost}/${imageRef}`;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
// Authenticate to registry
|
|
227
|
+
await api.login(registryHost, username, password.unencrypted);
|
|
228
|
+
|
|
229
|
+
// Tag local image with fully qualified name if necessary
|
|
230
|
+
if (targetImage !== imageRef) {
|
|
231
|
+
await api.exec(["tag", imageRef, targetImage]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Push the image
|
|
235
|
+
const { stdout: pushOut } = await api.exec(["push", targetImage]);
|
|
236
|
+
|
|
237
|
+
// Attempt to extract the repo digest from push output
|
|
238
|
+
const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(
|
|
239
|
+
pushOut,
|
|
240
|
+
);
|
|
241
|
+
if (digestMatch) {
|
|
242
|
+
const digestHash = digestMatch[1];
|
|
243
|
+
// Strip tag (anything after last :) to build image@digest reference
|
|
244
|
+
const [repoWithoutTag] =
|
|
245
|
+
targetImage.split(":").length > 2
|
|
246
|
+
? [targetImage] // unlikely but safety
|
|
247
|
+
: [targetImage.substring(0, targetImage.lastIndexOf(":"))];
|
|
248
|
+
repoDigest = `${repoWithoutTag}@${digestHash}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Update the final image reference to point at the pushed image
|
|
252
|
+
finalImageRef = targetImage;
|
|
253
|
+
} finally {
|
|
254
|
+
// Always try to logout – failures are non-fatal
|
|
255
|
+
await api.logout(registryHost);
|
|
256
|
+
}
|
|
188
257
|
}
|
|
189
258
|
|
|
190
259
|
// Return the resource using this() to construct output
|
|
191
260
|
return this({
|
|
192
261
|
...props,
|
|
193
|
-
|
|
262
|
+
name,
|
|
263
|
+
imageRef: finalImageRef,
|
|
194
264
|
imageId,
|
|
195
265
|
repoDigest,
|
|
196
266
|
builtAt: Date.now(),
|
package/src/github/index.ts
CHANGED