1import type { FlyMachineConfig } from "@distilled.cloud/fly-io/machines";
2import {
3 pinsFromConfig,
4 sameImageSet,
5 validObservedImageSet,
6} from "./DeploymentImages.ts";
7import { alchemyMetadataKeys as keys } from "./Metadata.ts";
8
9type DeploymentState =
10 | { protocol: "legacy" | "1" | "2" }
11 | { protocol: "invalid"; reason: string };
12
13/** Observation and reconciliation must agree before treating a Machine as legacy. */
14export const classifyDeploymentState = (machine: {
15 config?: FlyMachineConfig;
16}): DeploymentState => {
17 const metadata = machine.config?.metadata;
18 const protocol = metadata?.[keys.protocol];
19 if (protocol !== undefined && protocol !== "1" && protocol !== "2")
20 return {
21 protocol: "invalid",
22 reason: `Unknown deployment protocol ${protocol}.`,
23 };
24 if (protocol !== "2" && metadata?.[keys.containerImageSet] !== undefined)
25 return {
26 protocol: "invalid",
27 reason: "Container image metadata requires deployment protocol 2.",
28 };
29 if (protocol === "2")
30 return validProtocol2RecoveryMetadata(machine)
31 ? { protocol }
32 : {
33 protocol: "invalid",
34 reason: "Invalid protocol-2 image set or recovery metadata.",
35 };
36 if (protocol === "1") {
37 const replica = metadata?.[keys.replica] ?? "";
38 if (
39 !/^(0|[1-9]\d*)$/.test(replica) ||
40 !Number.isSafeInteger(Number(replica)) ||
41 !["candidate", "promoting", "validating", "active", "retiring"].includes(
42 metadata?.[keys.phase] ?? "",
43 )
44 )
45 return {
46 protocol: "invalid",
47 reason: "Invalid protocol-1 replica index or deployment phase.",
48 };
49 return { protocol };
50 }
51 return { protocol: "legacy" };
52};
53
54/** Required recovery fields for a complete protocol-2 generation member. */
55const validProtocol2RecoveryMetadata = (machine: {
56 config?: FlyMachineConfig;
57}): boolean => {
58 if (!validObservedImageSet(machine)) return false;
59 const recorded = machine.config?.metadata;
60 const countText = recorded?.[keys.count] ?? "";
61 const sequenceText = recorded?.[keys.sequence] ?? "";
62 const replicaText = recorded?.[keys.replica] ?? "";
63 const count = Number(countText);
64 const index = Number(replicaText);
65 const roles = (recorded?.[keys.roles] ?? "").split(",");
66 return (
67 Boolean(recorded?.[keys.generation]?.trim()) &&
68 Boolean(recorded?.[keys.workload]?.trim()) &&
69 /^[1-9]\d*$/.test(countText) &&
70 Number.isSafeInteger(count) &&
71 /^[1-9]\d*$/.test(sequenceText) &&
72 Number.isSafeInteger(Number(sequenceText)) &&
73 /^(0|[1-9]\d*)$/.test(replicaText) &&
74 Number.isSafeInteger(index) &&
75 index < count &&
76 roles.length === count &&
77 roles.includes("run") &&
78 roles.every((role) => role === "run" || role === "idle") &&
79 recorded?.[keys.role] === roles[index] &&
80 ["candidate", "promoting", "validating", "active", "retiring"].includes(
81 recorded?.[keys.phase] ?? "",
82 ) &&
83 ["true", "false"].includes(recorded?.[keys.restored] ?? "")
84 );
85};
86
87/** A partial protocol-2 generation is valid only when every member agrees on its identity. */
88export const validProtocol2Generation = (
89 group: readonly { config?: FlyMachineConfig }[],
90): boolean => {
91 const first = group[0]?.config?.metadata;
92 if (!first || group.length === 0) return false;
93 const indices = new Set<string>();
94 for (const machine of group) {
95 const metadata = machine.config?.metadata;
96 const replica = metadata?.[keys.replica];
97 if (
98 !validProtocol2RecoveryMetadata(machine) ||
99 metadata?.[keys.generation] !== first[keys.generation] ||
100 metadata?.[keys.count] !== first[keys.count] ||
101 metadata?.[keys.sequence] !== first[keys.sequence] ||
102 metadata?.[keys.workload] !== first[keys.workload] ||
103 metadata?.[keys.roles] !== first[keys.roles] ||
104 replica === undefined ||
105 indices.has(replica) ||
106 !sameImageSet(
107 pinsFromConfig(machine.config),
108 pinsFromConfig(group[0]?.config),
109 )
110 )
111 return false;
112 indices.add(replica);
113 }
114 return true;
115};