1import { Credentials } from "@distilled.cloud/gcp/Credentials";
2import { createHash } from "node:crypto";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as HttpClient from "effect/http/HttpClient";
7import * as HttpClientRequest from "effect/http/HttpClientRequest";
8import type * as HttpClientResponse from "effect/http/HttpClientResponse";
9
10/** Image name {@link pushDockerVersion} pushes into a repository. */
11export const IMAGE = "hello";
12
13const sha256 = (bytes: Uint8Array) =>
14 Effect.sync(
15 () => `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
16 );
17
18const header = (
19 response: HttpClientResponse.HttpClientResponse,
20 name: string,
21) => {
22 const headers = response.headers as {
23 get?: (key: string) => string | undefined;
24 } & Record<string, string | undefined>;
25 return (
26 headers.get?.(name) ?? headers[name] ?? headers[name.toLowerCase()] ?? ""
27 );
28};
29
30const failHttp = (
31 label: string,
32 response: HttpClientResponse.HttpClientResponse,
33) =>
34 Effect.gen(function* () {
35 const body = yield* response.text.pipe(
36 Effect.catch(() => Effect.succeed("")),
37 );
38 return yield* Effect.fail(
39 new Error(`${label} failed: ${response.status} ${body}`),
40 );
41 });
42
43/**
44 * Push a minimal (layerless) Docker image tagged `marker` into `repository`
45 * over the registry v2 API, as the deploying principal. Returns the
46 * version's full resource name.
47 */
48export const pushDockerVersion = (
49 repository: {
50 name: string;
51 repositoryId: string;
52 project: string;
53 location: string;
54 },
55 marker: string,
56) =>
57 Effect.gen(function* () {
58 const creds = yield* yield* Credentials;
59 const client = yield* HttpClient.HttpClient;
60 const token = Redacted.value(creds.accessToken);
61 const host = `https://${repository.location}-docker.pkg.dev`;
62 const imagePath = `${repository.project}/${repository.repositoryId}/${IMAGE}`;
63 const auth = (request: HttpClientRequest.HttpClientRequest) =>
64 request.pipe(
65 HttpClientRequest.setHeader("Authorization", `Bearer ${token}`),
66 );
67
68 const config = yield* Effect.sync(() =>
69 new TextEncoder().encode(
70 JSON.stringify({
71 architecture: "amd64",
72 os: "linux",
73 rootfs: { type: "layers", diff_ids: [] },
74 config: { Env: [`MARKER=${marker}`] },
75 }),
76 ),
77 );
78 const configDigest = yield* sha256(config);
79
80 const start = yield* client.execute(
81 auth(HttpClientRequest.post(`${host}/v2/${imagePath}/blobs/uploads/`)),
82 );
83 if (start.status !== 202) {
84 return yield* failHttp("blob upload start", start);
85 }
86 let location = header(start, "location");
87 if (location.startsWith("/")) location = `${host}${location}`;
88 const sep = location.includes("?") ? "&" : "?";
89 const uploaded = yield* client.execute(
90 auth(
91 HttpClientRequest.put(`${location}${sep}digest=${configDigest}`).pipe(
92 HttpClientRequest.bodyUint8Array(config, "application/octet-stream"),
93 ),
94 ),
95 );
96 if (uploaded.status < 200 || uploaded.status >= 300) {
97 return yield* failHttp("blob upload", uploaded);
98 }
99
100 const manifest = yield* Effect.sync(() =>
101 new TextEncoder().encode(
102 JSON.stringify({
103 schemaVersion: 2,
104 mediaType: "application/vnd.docker.distribution.manifest.v2+json",
105 config: {
106 mediaType: "application/vnd.docker.container.image.v1+json",
107 size: config.byteLength,
108 digest: configDigest,
109 },
110 layers: [],
111 }),
112 ),
113 );
114 const manifestDigest = yield* sha256(manifest);
115 const published = yield* client.execute(
116 auth(
117 HttpClientRequest.put(
118 `${host}/v2/${imagePath}/manifests/${marker}`,
119 ).pipe(
120 HttpClientRequest.bodyUint8Array(
121 manifest,
122 "application/vnd.docker.distribution.manifest.v2+json",
123 ),
124 ),
125 ),
126 );
127 if (published.status < 200 || published.status >= 300) {
128 return yield* failHttp("manifest put", published);
129 }
130
131 return `${repository.name}/packages/${IMAGE}/versions/${manifestDigest}`;
132 }).pipe(
133 Effect.retry({
134 times: 5,
135 schedule: Schedule.spaced("2 seconds"),
136 }),
137 );