import { createContainerApplication } from "@distilled.cloud/cloudflare/containers"; import * as Effect from "effect/Effect"; import type { Artifact } from "./artifact-types.ts"; import { CloudflareAccount } from "./cloudflare-account.ts"; import { RecoveryRequired, ResourceConflict, TemporarilyUnavailable, } from "./deployment-errors.ts"; import { eq, fingerprint, id, object } from "./deployment-values.ts"; import type { Installation } from "./installation-metadata.ts"; export interface Application extends Record { id: string; name: string; configuration: Record & { image: string; instance_type: string; }; durable_objects: Record & { namespace_id: string | null }; } function normalizedConfiguration(value: unknown) { if (!object(value) || typeof value.image !== "string") throw new ResourceConflict(); const config = { ...value }; const disk = config.disk; const lite = config.vcpu === 0.0625 && config.memory_mib === 256 && object(disk) && disk.size_mb === 2000; if (lite) { config.instance_type = "lite"; for (const key of ["vcpu", "memory", "memory_mib"]) delete config[key]; if (Object.keys(disk).every((key) => key === "size_mb")) delete config.disk; } if (typeof config.instance_type !== "string") throw new ResourceConflict(); return { ...config, image: value.image, instance_type: config.instance_type }; } export class ContainerAPI { constructor( private readonly client: CloudflareAccount, private readonly record: Installation, ) {} private application(value: unknown) { return Effect.try({ try: (): Application => { if ( !object(value) || !id(value.id) || value.name !== this.record.resources.sandboxApplicationName || !object(value.durable_objects) || value.durable_objects.namespace_id !== this.record.resources.sandboxNamespaceId || !object(value.configuration) || typeof value.configuration.image !== "string" ) throw new ResourceConflict(); return { ...value, id: value.id, name: this.record.resources.sandboxApplicationName, configuration: normalizedConfiguration(value.configuration), durable_objects: { ...value.durable_objects, namespace_id: this.record.resources.sandboxNamespaceId, }, }; }, catch: () => new ResourceConflict(), }); } findApplication() { return Effect.gen({ self: this }, function* () { const known = this.record.resources.sandboxApplicationId; if (known) return yield* this.application( yield* this.client.request(`containers/applications/${known}`), ); const list = yield* this.client.request( `containers/applications?name=${this.record.resources.sandboxApplicationName}`, ); if (!Array.isArray(list) || list.length > 100) return yield* new TemporarilyUnavailable(); const matches = list.filter( (app) => app?.name === this.record.resources.sandboxApplicationName, ); if (matches.length > 1) return yield* new ResourceConflict(); return matches.length ? yield* this.application(matches[0]) : null; }); } desiredContainer(artifact: Artifact) { const c = artifact.deployment.containers[0]; return { configuration: { image: c.image, instance_type: c.instance_type }, max_instances: c.max_instances, scheduling_policy: "default", }; } matchesContainer(app: Application, artifact: Artifact) { const d = this.desiredContainer(artifact); return ( app.configuration.image === d.configuration.image && app.configuration.instance_type === d.configuration.instance_type && app.max_instances === d.max_instances && app.scheduling_policy === d.scheduling_policy ); } createApplication(artifact: Artifact) { return Effect.gen({ self: this }, function* () { const container = artifact.deployment.containers[0]; if (!this.record.resources.sandboxNamespaceId) return yield* new ResourceConflict(); const created = yield* this.client.sdk( createContainerApplication({ accountId: this.record.accountId, name: this.record.resources.sandboxApplicationName, instances: 0, maxInstances: container.max_instances, schedulingPolicy: "default", configuration: { image: container.image, instanceType: container.instance_type, }, durableObjects: { namespaceId: this.record.resources.sandboxNamespaceId, }, }), ); if (!id(created.id)) return yield* new TemporarilyUnavailable(); return yield* this.application( yield* this.client.request(`containers/applications/${created.id}`), ); }); } patchApplication(app: Application, artifact: Artifact) { return Effect.gen({ self: this }, function* () { yield* this.client.request(`containers/applications/${app.id}`, { method: "PATCH", body: { ...this.desiredContainer(artifact), configuration: { ...app.configuration, ...this.desiredContainer(artifact).configuration, }, }, }); }); } rollout( app: Application, artifact: Artifact, operationId: string, allowCreate: boolean, knownId: string | null = null, remember: (id: string) => Effect.Effect = () => Effect.void, ) { return Effect.gen({ self: this }, function* () { const description = `Flarebot ${operationId}`; const target = { ...app.configuration, ...this.desiredContainer(artifact).configuration, }; const list: unknown[] = []; if (knownId) list.push( yield* this.client.request( `containers/applications/${app.id}/rollouts/${knownId}`, ), ); else { let last: string | null = null; for (let page = 0; page < 10; page++) { const batch: unknown = yield* this.client.request( `containers/applications/${app.id}/rollouts?limit=100${last ? `&last=${last}` : ""}`, ); if (!Array.isArray(batch) || batch.length > 100) return yield* new TemporarilyUnavailable(); list.push(...batch); if (batch.length < 100) break; const tail: unknown = batch.at(-1); const next: unknown = object(tail) ? tail.id : undefined; if (!id(next) || next === last || page === 9) return yield* new TemporarilyUnavailable(); last = next; } } const matches = list.filter( (r): r is Record => object(r) && r.description === description, ); if (matches.length > 1) return yield* new ResourceConflict(); if (matches.length) { if ( !eq( yield* Effect.try({ try: () => normalizedConfiguration(matches[0].target_configuration), catch: () => new ResourceConflict(), }), yield* Effect.try({ try: () => normalizedConfiguration(target), catch: () => new ResourceConflict(), }), ) || !id(matches[0].id) ) return yield* new ResourceConflict(); if (!knownId) yield* remember(matches[0].id); if ( matches[0].status === "pending" || matches[0].status === "progressing" ) return yield* new TemporarilyUnavailable(); if (matches[0].status !== "completed") return yield* new ResourceConflict(); return; } if (!allowCreate) return yield* new RecoveryRequired(); const created = yield* this.client.request( `containers/applications/${app.id}/rollouts`, { method: "POST", body: { description, strategy: "rolling", kind: "full_auto", step_percentage: 100, target_configuration: target, }, }, ); if (object(created) && id(created.id)) yield* remember(created.id); if ( !object(created) || !id(created.id) || created?.status !== "completed" || created?.description !== description || !eq( yield* Effect.try({ try: () => normalizedConfiguration(created.target_configuration), catch: () => new ResourceConflict(), }), yield* Effect.try({ try: () => normalizedConfiguration(target), catch: () => new ResourceConflict(), }), ) ) return yield* new TemporarilyUnavailable(); }); } containerFingerprint(app: Application) { return fingerprint({ id: app.id, name: app.name, namespaceId: app.durable_objects.namespace_id, configuration: app.configuration, max_instances: app.max_instances, scheduling_policy: app.scheduling_policy, }); } matchesSupportedContainer(app: Application) { let configuration: Record; try { configuration = normalizedConfiguration(app.configuration); } catch { return false; } return ( app.id === this.record.resources.sandboxApplicationId && app.name === this.record.resources.sandboxApplicationName && app.durable_objects.namespace_id === this.record.resources.sandboxNamespaceId && typeof configuration.image === "string" && /^docker\.io\/cloudflare\/sandbox:[A-Za-z0-9._-]+@sha256:[a-f0-9]{64}$/.test( configuration.image, ) && configuration.instance_type === "lite" && app.max_instances === 4 && app.scheduling_policy === "default" ); } }