| 1 | import { Credentials } from "@distilled.cloud/gcp/Credentials"; |
| 2 | import { createHash } from "node:crypto"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Redacted from "effect/Redacted"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import * as HttpClient from "effect/http/HttpClient"; |
| 7 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 8 | import type * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 9 | |
| 10 | /** Image name {@link pushDockerVersion} pushes into a repository. */ |
| 11 | export const IMAGE = "hello"; |
| 12 | |
| 13 | const sha256 = (bytes: Uint8Array) => |
| 14 | Effect.sync( |
| 15 | () => `sha256:${createHash("sha256").update(bytes).digest("hex")}`, |
| 16 | ); |
| 17 | |
| 18 | const 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 | |
| 30 | const 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 | */ |
| 48 | export 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 | ); |