| 1 | import type { FlyMachineConfig } from "@distilled.cloud/fly-io/machines"; |
| 2 | import { |
| 3 | pinsFromConfig, |
| 4 | sameImageSet, |
| 5 | validObservedImageSet, |
| 6 | } from "./DeploymentImages.ts"; |
| 7 | import { alchemyMetadataKeys as keys } from "./Metadata.ts"; |
| 8 | |
| 9 | type DeploymentState = |
| 10 | | { protocol: "legacy" | "1" | "2" } |
| 11 | | { protocol: "invalid"; reason: string }; |
| 12 | |
| 13 | /** Observation and reconciliation must agree before treating a Machine as legacy. */ |
| 14 | export 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. */ |
| 55 | const 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. */ |
| 88 | export 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 | }; |