1import type { FlyMachineConfig } from "@distilled.cloud/fly-io/machines";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4import { alchemyMetadataKeys as keys } from "./Metadata.ts";
5
6const ImageSet = Schema.Array(
7 Schema.Struct({ name: Schema.String, image: Schema.String }),
8);
9export type ContainerImagePin = { name: string; image: string };
10
11/** Blue/green needs immutable image references that survive a crash. */
12export const isImmutableImage = (image: string): boolean =>
13 /^[a-zA-Z0-9][a-zA-Z0-9._:/-]*@sha256:[a-fA-F0-9]{64}$/.test(image);
14
15const canonical = (pins: readonly ContainerImagePin[]): ContainerImagePin[] =>
16 [...pins].sort((a, b) => a.name.localeCompare(b.name));
17
18const valid = (pins: readonly ContainerImagePin[]): boolean =>
19 pins.length > 0 &&
20 new Set(pins.map(({ name }) => name)).size === pins.length &&
21 pins.every(
22 ({ name, image }) => name.trim().length > 0 && isImmutableImage(image),
23 );
24
25export const pinsFromConfig = (
26 config: FlyMachineConfig | undefined,
27): ContainerImagePin[] | undefined => {
28 const containers = config?.containers;
29 if (!containers?.length) return undefined;
30 const pins = containers.map(({ name, image }) => ({
31 name: name ?? "",
32 image: image ?? "",
33 }));
34 return valid(pins) ? canonical(pins) : undefined;
35};
36
37export const encodeImageSet = (
38 pins: readonly ContainerImagePin[],
39): string | undefined =>
40 valid(pins) ? JSON.stringify(canonical(pins)) : undefined;
41
42export const decodeImageSet = (
43 raw: string | undefined,
44): ContainerImagePin[] | undefined => {
45 if (raw === undefined) return undefined;
46 const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(ImageSet))(
47 raw,
48 );
49 if (!Option.isSome(decoded)) return undefined;
50 const pins = decoded.value;
51 return valid(pins) ? canonical(pins) : undefined;
52};
53
54export const sameImageSet = (
55 left: readonly ContainerImagePin[] | undefined,
56 right: readonly ContainerImagePin[] | undefined,
57): boolean => {
58 const a = left === undefined ? undefined : encodeImageSet(left);
59 const b = right === undefined ? undefined : encodeImageSet(right);
60 return a !== undefined && b !== undefined && a === b;
61};
62
63/** Protocol-2 metadata and the authoritative readback config must describe exactly the same set. */
64export const validObservedImageSet = (machine: {
65 config?: FlyMachineConfig;
66}): boolean => {
67 const metadata = machine.config?.metadata;
68 if (metadata?.[keys.protocol] !== "2" || metadata[keys.image] !== undefined)
69 return false;
70 return sameImageSet(
71 decodeImageSet(metadata[keys.containerImageSet]),
72 pinsFromConfig(machine.config),
73 );
74};
75
76/** Keep all named pins when changing service or idle policy on a candidate. */
77export const applyImageSet = (
78 config: FlyMachineConfig,
79 pins: readonly ContainerImagePin[],
80): FlyMachineConfig | undefined => {
81 if (!valid(pins) || !config.containers) return undefined;
82 const byName = new Map(pins.map((pin) => [pin.name, pin.image]));
83 if (
84 config.containers.length !== byName.size ||
85 new Set(config.containers.map(({ name }) => name)).size !==
86 config.containers.length ||
87 config.containers.some(
88 ({ name }) => name === undefined || !byName.has(name),
89 )
90 )
91 return undefined;
92 return {
93 ...config,
94 image: undefined,
95 containers: config.containers.map((container) => ({
96 ...container,
97 image: byName.get(container.name!),
98 })),
99 };
100};