@usine/executor 0.3.0 → 0.4.1-next.240.g158c84b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/LICENSE +661 -21
  2. package/README.md +116 -19
  3. package/dist/child-command.d.ts +1 -1
  4. package/dist/child-command.d.ts.map +1 -1
  5. package/dist/child-command.js +6 -4
  6. package/dist/child-command.js.map +1 -1
  7. package/dist/child-environment.d.ts +19 -0
  8. package/dist/child-environment.d.ts.map +1 -0
  9. package/dist/child-environment.js +18 -0
  10. package/dist/child-environment.js.map +1 -0
  11. package/dist/claude-login-lock.d.ts +3 -2
  12. package/dist/claude-login-lock.d.ts.map +1 -1
  13. package/dist/claude-login-lock.js +10 -4
  14. package/dist/claude-login-lock.js.map +1 -1
  15. package/dist/cloudflare/client.d.ts +50 -0
  16. package/dist/cloudflare/client.d.ts.map +1 -0
  17. package/dist/cloudflare/client.js +108 -0
  18. package/dist/cloudflare/client.js.map +1 -0
  19. package/dist/cloudflare/cloudflare.d.ts +17 -0
  20. package/dist/cloudflare/cloudflare.d.ts.map +1 -0
  21. package/dist/cloudflare/cloudflare.js +704 -0
  22. package/dist/cloudflare/cloudflare.js.map +1 -0
  23. package/dist/cloudflare/model.d.ts +42 -0
  24. package/dist/cloudflare/model.d.ts.map +1 -0
  25. package/dist/cloudflare/model.js +2 -0
  26. package/dist/cloudflare/model.js.map +1 -0
  27. package/dist/cloudflare/shell/environment.d.ts +12 -0
  28. package/dist/cloudflare/shell/environment.d.ts.map +1 -0
  29. package/dist/cloudflare/shell/environment.js +12 -0
  30. package/dist/cloudflare/shell/environment.js.map +1 -0
  31. package/dist/cloudflare/shell/shell.d.ts +2 -0
  32. package/dist/cloudflare/shell/shell.d.ts.map +1 -0
  33. package/dist/cloudflare/shell/shell.js +155 -0
  34. package/dist/cloudflare/shell/shell.js.map +1 -0
  35. package/dist/durable-session.d.ts +21 -3
  36. package/dist/durable-session.d.ts.map +1 -1
  37. package/dist/durable-session.js +69 -10
  38. package/dist/durable-session.js.map +1 -1
  39. package/dist/exe-dev/commands.d.ts +1 -1
  40. package/dist/exe-dev/commands.d.ts.map +1 -1
  41. package/dist/exe-dev/exe-dev.d.ts +1 -1
  42. package/dist/exe-dev/exe-dev.d.ts.map +1 -1
  43. package/dist/exe-dev/exe-dev.js +9 -1
  44. package/dist/exe-dev/exe-dev.js.map +1 -1
  45. package/dist/exe-dev/preflight.d.ts +2 -2
  46. package/dist/exe-dev/preflight.d.ts.map +1 -1
  47. package/dist/exe-dev/preflight.js +5 -4
  48. package/dist/exe-dev/preflight.js.map +1 -1
  49. package/dist/executor.d.ts +164 -15
  50. package/dist/executor.d.ts.map +1 -1
  51. package/dist/executor.js +100 -3
  52. package/dist/executor.js.map +1 -1
  53. package/dist/guard.d.ts.map +1 -1
  54. package/dist/guard.js +1 -0
  55. package/dist/guard.js.map +1 -1
  56. package/dist/harness-preflight.d.ts +9 -8
  57. package/dist/harness-preflight.d.ts.map +1 -1
  58. package/dist/harness-preflight.js +15 -10
  59. package/dist/harness-preflight.js.map +1 -1
  60. package/dist/local/local.d.ts +1 -1
  61. package/dist/local/local.d.ts.map +1 -1
  62. package/dist/local/local.js +76 -37
  63. package/dist/local/local.js.map +1 -1
  64. package/dist/memory-preflight.d.ts +3 -1
  65. package/dist/memory-preflight.d.ts.map +1 -1
  66. package/dist/memory-preflight.js +1 -1
  67. package/dist/memory-preflight.js.map +1 -1
  68. package/dist/pre-push-hook.d.ts +1 -1
  69. package/dist/pre-push-hook.js +1 -1
  70. package/dist/prepared-attempt.d.ts +8 -0
  71. package/dist/prepared-attempt.d.ts.map +1 -1
  72. package/dist/prepared-attempt.js.map +1 -1
  73. package/dist/provision.d.ts +64 -13
  74. package/dist/provision.d.ts.map +1 -1
  75. package/dist/provision.js +99 -42
  76. package/dist/provision.js.map +1 -1
  77. package/dist/remote/commands.d.ts +1 -1
  78. package/dist/remote/commands.d.ts.map +1 -1
  79. package/dist/remote/commands.js +1 -1
  80. package/dist/remote/commands.js.map +1 -1
  81. package/dist/remote/container.d.ts +90 -0
  82. package/dist/remote/container.d.ts.map +1 -0
  83. package/dist/remote/container.js +480 -0
  84. package/dist/remote/container.js.map +1 -0
  85. package/dist/remote/liveness.d.ts +1 -1
  86. package/dist/remote/liveness.d.ts.map +1 -1
  87. package/dist/remote/liveness.js +6 -4
  88. package/dist/remote/liveness.js.map +1 -1
  89. package/dist/remote/machine.d.ts +52 -6
  90. package/dist/remote/machine.d.ts.map +1 -1
  91. package/dist/remote/remote.d.ts +11 -7
  92. package/dist/remote/remote.d.ts.map +1 -1
  93. package/dist/remote/remote.js +340 -92
  94. package/dist/remote/remote.js.map +1 -1
  95. package/dist/remote/sweep.d.ts +3 -1
  96. package/dist/remote/sweep.d.ts.map +1 -1
  97. package/dist/remote/sweep.js +2 -1
  98. package/dist/remote/sweep.js.map +1 -1
  99. package/dist/salvage.d.ts +18 -10
  100. package/dist/salvage.d.ts.map +1 -1
  101. package/dist/salvage.js +123 -29
  102. package/dist/salvage.js.map +1 -1
  103. package/dist/ssh/api-keys.d.ts +36 -0
  104. package/dist/ssh/api-keys.d.ts.map +1 -0
  105. package/dist/ssh/api-keys.js +72 -0
  106. package/dist/ssh/api-keys.js.map +1 -0
  107. package/dist/ssh/codex-homes.d.ts +21 -0
  108. package/dist/ssh/codex-homes.d.ts.map +1 -0
  109. package/dist/ssh/codex-homes.js +78 -0
  110. package/dist/ssh/codex-homes.js.map +1 -0
  111. package/dist/ssh/container.d.ts +32 -0
  112. package/dist/ssh/container.d.ts.map +1 -0
  113. package/dist/ssh/container.js +282 -0
  114. package/dist/ssh/container.js.map +1 -0
  115. package/dist/ssh/credentials.d.ts +3 -0
  116. package/dist/ssh/credentials.d.ts.map +1 -1
  117. package/dist/ssh/credentials.js +3 -0
  118. package/dist/ssh/credentials.js.map +1 -1
  119. package/dist/ssh/gateway/admin.d.ts +1 -1
  120. package/dist/ssh/gateway/admin.d.ts.map +1 -1
  121. package/dist/ssh/gateway/admin.js +2 -2
  122. package/dist/ssh/gateway/admin.js.map +1 -1
  123. package/dist/ssh/gateway/attachments.d.ts +15 -6
  124. package/dist/ssh/gateway/attachments.d.ts.map +1 -1
  125. package/dist/ssh/gateway/attachments.js +39 -25
  126. package/dist/ssh/gateway/attachments.js.map +1 -1
  127. package/dist/ssh/gateway/environment.d.ts +21 -2
  128. package/dist/ssh/gateway/environment.d.ts.map +1 -1
  129. package/dist/ssh/gateway/environment.js +76 -6
  130. package/dist/ssh/gateway/environment.js.map +1 -1
  131. package/dist/ssh/gateway/forward.d.ts +1 -1
  132. package/dist/ssh/gateway/forward.d.ts.map +1 -1
  133. package/dist/ssh/gateway/forward.js +1 -1
  134. package/dist/ssh/gateway/forward.js.map +1 -1
  135. package/dist/ssh/gateway/preflight.d.ts +51 -12
  136. package/dist/ssh/gateway/preflight.d.ts.map +1 -1
  137. package/dist/ssh/gateway/preflight.js +155 -69
  138. package/dist/ssh/gateway/preflight.js.map +1 -1
  139. package/dist/ssh/limits.d.ts +60 -0
  140. package/dist/ssh/limits.d.ts.map +1 -0
  141. package/dist/ssh/limits.js +84 -0
  142. package/dist/ssh/limits.js.map +1 -0
  143. package/dist/ssh/model.d.ts +97 -9
  144. package/dist/ssh/model.d.ts.map +1 -1
  145. package/dist/ssh/preflight.d.ts +26 -7
  146. package/dist/ssh/preflight.d.ts.map +1 -1
  147. package/dist/ssh/preflight.js +50 -10
  148. package/dist/ssh/preflight.js.map +1 -1
  149. package/dist/ssh/provisioner/boat.d.ts +33 -0
  150. package/dist/ssh/provisioner/boat.d.ts.map +1 -0
  151. package/dist/ssh/provisioner/boat.js +247 -0
  152. package/dist/ssh/provisioner/boat.js.map +1 -0
  153. package/dist/ssh/provisioner/hetzner.d.ts +32 -0
  154. package/dist/ssh/provisioner/hetzner.d.ts.map +1 -0
  155. package/dist/ssh/provisioner/hetzner.js +337 -0
  156. package/dist/ssh/provisioner/hetzner.js.map +1 -0
  157. package/dist/ssh/provisioner/host-key.d.ts +11 -0
  158. package/dist/ssh/provisioner/host-key.d.ts.map +1 -0
  159. package/dist/ssh/provisioner/host-key.js +57 -0
  160. package/dist/ssh/provisioner/host-key.js.map +1 -0
  161. package/dist/ssh/provisioner/http.d.ts +45 -0
  162. package/dist/ssh/provisioner/http.d.ts.map +1 -0
  163. package/dist/ssh/provisioner/http.js +72 -0
  164. package/dist/ssh/provisioner/http.js.map +1 -0
  165. package/dist/ssh/provisioner/machines.d.ts +48 -0
  166. package/dist/ssh/provisioner/machines.d.ts.map +1 -0
  167. package/dist/ssh/provisioner/machines.js +686 -0
  168. package/dist/ssh/provisioner/machines.js.map +1 -0
  169. package/dist/ssh/provisioner/model.d.ts +92 -0
  170. package/dist/ssh/provisioner/model.d.ts.map +1 -0
  171. package/dist/ssh/provisioner/model.js +37 -0
  172. package/dist/ssh/provisioner/model.js.map +1 -0
  173. package/dist/ssh/ssh.d.ts +14 -11
  174. package/dist/ssh/ssh.d.ts.map +1 -1
  175. package/dist/ssh/ssh.js +94 -27
  176. package/dist/ssh/ssh.js.map +1 -1
  177. package/dist/ssh/worker-records.d.ts +18 -0
  178. package/dist/ssh/worker-records.d.ts.map +1 -0
  179. package/dist/ssh/worker-records.js +100 -0
  180. package/dist/ssh/worker-records.js.map +1 -0
  181. package/dist/ssh/workers.d.ts +34 -7
  182. package/dist/ssh/workers.d.ts.map +1 -1
  183. package/dist/ssh/workers.js +92 -18
  184. package/dist/ssh/workers.js.map +1 -1
  185. package/package.json +19 -11
@@ -0,0 +1,686 @@
1
+ import { HarnessUnavailableError } from "@usine/core/agent/capability";
2
+ import { RemoteShell, SSH_FAILURE } from "@usine/agents/child-host";
3
+ import { Array as Arr, Cache, DateTime, Duration, Effect, Exit, FileSystem, Option, Path, Random, Ref, Result, } from "effect";
4
+ import { homedir } from "node:os";
5
+ import { ExecutorOperationError, MachineCheck, MachineGrant, MachineHold, MachineStatus, MachineUnavailableError, WorkerCapabilityError, WorkerMachines, } from "../../executor.js";
6
+ import { gatewayHolds, provisionedApiKey } from "../api-keys.js";
7
+ import { hostKeyAlias, knownHostsLine, } from "../workers.js";
8
+ import { BOAT_API, boatDriverFor } from "./boat.js";
9
+ import { HETZNER_API, hetznerDriverFor } from "./hetzner.js";
10
+ import { attemptTag, tagValue, machineName, } from "./model.js";
11
+ /**
12
+ * A script that waits for cloud-init to finish, if the machine has it, creates the Worker root
13
+ * `$1`, and waits up to a minute for Docker to answer, if the machine has it.
14
+ */
15
+ const READY_SCRIPT = String.raw `
16
+ if command -v cloud-init >/dev/null 2>&1; then cloud-init status --wait >/dev/null 2>&1 || true; fi
17
+ mkdir -p "$1" || exit 1
18
+ if command -v docker >/dev/null 2>&1; then
19
+ tries=0
20
+ until docker version >/dev/null 2>&1; do
21
+ tries=$((tries + 1))
22
+ [ "$tries" -ge 30 ] && break
23
+ sleep 2
24
+ done
25
+ fi
26
+ `.trim();
27
+ /**
28
+ * Returns the error for the first harness in `brains` that can't authenticate on a machine
29
+ * created for one Attempt, which keeps no login: one whose provider credential is neither at
30
+ * the Gateway nor in the provisioner's `api_keys`.
31
+ */
32
+ const harnessCredentialGap = (entry, provisioner, brains) => {
33
+ const worker = entry.address.name;
34
+ const noGateway = provisioner.provider === "boat"
35
+ ? "A subscription login needs a gateway, which a Boat worker can't have."
36
+ : "A subscription login needs a gateway, which this worker doesn't have.";
37
+ const missing = (label, provider, login) => {
38
+ const head = `${label} on provisioned worker "${worker}" has no credential, because a machine created for one attempt keeps no login.`;
39
+ return Option.isSome(entry.gateway)
40
+ ? `${head} Add ${provider} to the gateway's llm_providers, with ${login} or an API key on the gateway, or set provisioner.api_keys.${provider}.`
41
+ : `${head} Set provisioner.api_keys.${provider}. ${noGateway}`;
42
+ };
43
+ for (const harness of Arr.dedupe(brains.map((brain) => brain.harness))) {
44
+ if (gatewayHolds(entry.gateway, harness) || Option.isSome(provisionedApiKey(entry, harness)))
45
+ continue;
46
+ const message = (() => {
47
+ switch (harness) {
48
+ case "claude":
49
+ return missing("Claude Code", "anthropic", "a subscription token");
50
+ case "codex":
51
+ return missing("Codex", "openai", "a ChatGPT login");
52
+ case "pi":
53
+ return `Pi children run only on local workers, not on provisioned worker "${worker}".`;
54
+ }
55
+ })();
56
+ return Option.some(new WorkerCapabilityError({
57
+ worker,
58
+ capability: "harness-login",
59
+ message,
60
+ cause: new HarnessUnavailableError({ harness, capability: "login", message }),
61
+ }));
62
+ }
63
+ return Option.none();
64
+ };
65
+ const provisionerOf = (entry) => entry.address.machine._tag === "Provisioned"
66
+ ? Option.some(entry.address.machine.provisioner)
67
+ : Option.none();
68
+ /** Returns `length` random hex digits. */
69
+ const randomHex = (length) => Effect.forEach(Array.from({ length }), () => Random.nextIntBetween(0, 16)).pipe(Effect.map((digits) => digits.map((digit) => digit.toString(16)).join("")));
70
+ /** Returns the absolute path of a key file given as absolute or `~/`. */
71
+ const expandHome = (file) => file.startsWith("~/") ? `${homedir()}/${file.slice(2)}` : file;
72
+ const operationError = (operation) => (error) => new ExecutorOperationError({ operation, message: error.message, cause: error });
73
+ /**
74
+ * Returns the machines of the SSH executor's provisioned Workers: the `WorkerMachines` port for
75
+ * the scheduler, the machine of each Attempt for preparation and recovery, and the provisioned
76
+ * Workers' preflight. Requires Node for the key and host key files.
77
+ */
78
+ export const provisionedMachinesFor = Effect.fn("SshExecutor.provisionedMachinesFor")(function* (options) {
79
+ const { instance, commands, settings } = options;
80
+ const fs = yield* FileSystem.FileSystem;
81
+ const path = yield* Path.Path;
82
+ const held = yield* Ref.make(new Map());
83
+ const nextHold = yield* Ref.make(0);
84
+ const checkedOn = yield* Ref.make(new Map());
85
+ const earlierMachines = yield* Ref.make(new Map());
86
+ const process = yield* randomHex(12);
87
+ const readyTimeout = Duration.fromInputUnsafe(settings.machineReadyTimeout ?? "10 minutes");
88
+ const interval = Effect.sleep(settings.machinePollInterval ?? "3 seconds");
89
+ const knownHostsDirectory = settings.knownHostsDirectory === undefined
90
+ ? yield* fs.makeTempDirectoryScoped({ prefix: "usine-known-hosts-" })
91
+ : settings.knownHostsDirectory;
92
+ yield* fs.makeDirectory(knownHostsDirectory, { recursive: true });
93
+ const knownHostsFile = (provider, id) => path.join(knownHostsDirectory, `${provider}-${id}`);
94
+ const readPublicKey = Effect.fn("SshExecutor.readPublicKey")(function* (entry) {
95
+ const worker = entry.address.name;
96
+ const identity = Option.getOrUndefined(entry.address.identityFile);
97
+ const file = identity === undefined ? undefined : `${expandHome(identity)}.pub`;
98
+ const text = file === undefined
99
+ ? Option.none()
100
+ : yield* fs.readFileString(file).pipe(Effect.option);
101
+ const key = Option.filter(Option.map(text, (content) => content.trim()), (content) => content.length > 0);
102
+ if (Option.isSome(key))
103
+ return key.value;
104
+ return yield* new WorkerCapabilityError({
105
+ worker,
106
+ capability: "credentials",
107
+ message: `Provisioned worker "${worker}" needs the public key of identity_file at ${file ?? "<identity_file>.pub"}, which it adds to each Boat sandbox`,
108
+ cause: new Error(`no public key at ${file ?? "identity_file"}`),
109
+ });
110
+ });
111
+ const buildDriver = Effect.fn("SshExecutor.machineDriver")(function* (worker) {
112
+ const entry = yield* options.workerFor(worker);
113
+ const provisioner = provisionerOf(entry);
114
+ if (Option.isNone(provisioner))
115
+ return yield* new WorkerCapabilityError({
116
+ worker,
117
+ capability: "machine",
118
+ message: `SSH worker "${worker}" has a machine of its own and creates none`,
119
+ cause: new Error("long-lived SSH worker"),
120
+ });
121
+ const value = provisioner.value;
122
+ switch (value.provider) {
123
+ case "hetzner":
124
+ return yield* hetznerDriverFor(worker, value, Option.isSome(entry.gateway), settings.providerEndpoints?.hetzner ?? HETZNER_API);
125
+ case "boat":
126
+ return yield* boatDriverFor(worker, value, yield* readPublicKey(entry), settings.providerEndpoints?.boat ?? BOAT_API);
127
+ }
128
+ });
129
+ const drivers = yield* Cache.make({ capacity: Number.POSITIVE_INFINITY, lookup: buildDriver });
130
+ const driverFor = (worker) => Cache.get(drivers, worker);
131
+ const register = (fields) => Ref.getAndUpdate(nextHold, (count) => count + 1).pipe(Effect.map((count) => ({
132
+ ...fields,
133
+ hold: MachineHold.make(`machine-${String(count)}`),
134
+ })), Effect.tap((entry) => Ref.update(held, (current) => new Map(current).set(entry.hold, entry))));
135
+ const update = (hold, change) => Ref.update(held, (current) => {
136
+ const entry = current.get(hold);
137
+ return entry === undefined ? current : new Map(current).set(hold, change(entry));
138
+ });
139
+ const unregister = (hold) => Ref.update(held, (current) => {
140
+ const next = new Map(current);
141
+ next.delete(hold);
142
+ return next;
143
+ });
144
+ const entryOf = (hold) => Ref.get(held).pipe(Effect.map((current) => Option.fromNullishOr(current.get(hold))));
145
+ const byAttempt = (attemptId) => Ref.get(held).pipe(Effect.map((current) => Option.fromNullishOr([...current.values()].find((entry) => entry.state !== "deleting" && Option.contains(entry.attemptId, attemptId)))));
146
+ /** Deletes the machine `id` and its host key file. Fails if the provider doesn't delete it. */
147
+ const destroy = Effect.fn("SshExecutor.deleteMachine")(function* (driver, worker, id) {
148
+ yield* Effect.annotateCurrentSpan({ worker, machine: id, provider: driver.provider });
149
+ yield* driver.remove(id);
150
+ yield* fs.remove(knownHostsFile(driver.provider, id), { force: true }).pipe(Effect.tapError((error) => Effect.logWarning("Could not remove the host key file of a deleted machine", {
151
+ worker,
152
+ machine: id,
153
+ message: error.message,
154
+ })), Effect.ignore);
155
+ });
156
+ /** Lists `worker`'s machines once per process, for the Attempts of earlier processes. */
157
+ const earlierMachinesOf = Effect.fn("SshExecutor.listEarlierMachines")(function* (worker, driver) {
158
+ const cached = (yield* Ref.get(earlierMachines)).get(worker);
159
+ if (cached !== undefined)
160
+ return cached;
161
+ const listed = yield* driver.list(instance, worker);
162
+ yield* Ref.update(earlierMachines, (current) => new Map(current).set(worker, listed));
163
+ yield* Effect.logInfo("Listed the machines of a provisioned Worker for its earlier Attempts", {
164
+ worker,
165
+ provider: driver.provider,
166
+ machines: listed.length,
167
+ });
168
+ return listed;
169
+ });
170
+ /**
171
+ * Deletes the held machine, or keeps it as `deleting` for the next sweep if that fails. An
172
+ * unlocated machine is looked up by its Attempt first.
173
+ */
174
+ const discard = Effect.fn("SshExecutor.discardMachine")(function* (hold, reason) {
175
+ const entry = yield* entryOf(hold);
176
+ if (Option.isNone(entry))
177
+ return;
178
+ const found = entry.value;
179
+ const fields = {
180
+ worker: found.worker,
181
+ provider: found.provider,
182
+ runId: Option.getOrElse(found.runId, () => "none"),
183
+ attemptId: Option.getOrElse(found.attemptId, () => "none"),
184
+ reason,
185
+ };
186
+ yield* update(hold, (current) => ({ ...current, state: "deleting" }));
187
+ const driver = yield* driverFor(found.worker).pipe(Effect.option);
188
+ if (Option.isNone(driver)) {
189
+ yield* unregister(hold);
190
+ return yield* Effect.logWarning("The Worker of the machine is gone, so the machine can't be deleted; delete it at the provider", { ...fields, machine: Option.getOrElse(found.id, () => "unknown") });
191
+ }
192
+ const id = Option.isSome(found.id)
193
+ ? Option.some(found.id.value)
194
+ : yield* Option.match(found.attemptId, {
195
+ onNone: () => Effect.succeedNone,
196
+ onSome: (attemptId) => earlierMachinesOf(found.worker, driver.value).pipe(Effect.map((listed) => Option.map(Option.fromNullishOr(listed.find(({ tags }) => Option.contains(tags.attempt, attemptTag(attemptId)))), ({ id: listedId }) => listedId)), Effect.tapError((error) => Effect.logWarning("Could not look up the machine to delete; the next sweep tries again", {
197
+ ...fields,
198
+ message: error.message,
199
+ })), Effect.orElseSucceed(() => Option.none())),
200
+ });
201
+ if (Option.isNone(id)) {
202
+ if (Option.isSome(found.attemptId) && found.state === "unlocated")
203
+ return;
204
+ yield* unregister(hold);
205
+ return yield* Effect.logDebug("The machine doesn't exist at the provider; nothing to delete", fields);
206
+ }
207
+ const deleted = yield* destroy(driver.value, found.worker, id.value).pipe(Effect.as(true), Effect.tapError((error) => Effect.logWarning("Could not delete the machine; the next sweep tries again", {
208
+ ...fields,
209
+ machine: id.value,
210
+ message: error.message,
211
+ })), Effect.orElseSucceed(() => false));
212
+ if (!deleted) {
213
+ yield* update(hold, (current) => ({ ...current, id }));
214
+ return;
215
+ }
216
+ yield* unregister(hold);
217
+ yield* Effect.logInfo("Deleted the machine", { ...fields, machine: id.value });
218
+ });
219
+ /** Writes the `known_hosts` file that pins `key` as the host key of machine `id`. */
220
+ const pinHostKey = Effect.fn("SshExecutor.pinHostKey")(function* (worker, id, pinned, key) {
221
+ yield* fs.writeFileString(pinned.file, knownHostsLine(pinned.alias, key)).pipe(Effect.mapError((error) => new WorkerCapabilityError({
222
+ worker,
223
+ capability: "machine",
224
+ message: `Can't write the host key file of machine ${id} of worker "${worker}": ${error.message}`,
225
+ cause: error,
226
+ })));
227
+ });
228
+ /**
229
+ * Returns the machine at `id` once the provider reports its SSH endpoint, with its host key
230
+ * pinned: `created`, the key the driver gave the machine, else the key the provider reports,
231
+ * else the key an earlier process recorded. Fails if there's none, since a first connection's
232
+ * key isn't trusted.
233
+ */
234
+ const machineOf = Effect.fn("SshExecutor.machineAtEndpoint")(function* (entry, driver, id, created) {
235
+ const worker = entry.address.name;
236
+ const pinned = {
237
+ file: knownHostsFile(driver.provider, id),
238
+ alias: hostKeyAlias(driver.provider, id),
239
+ };
240
+ if (Option.isSome(created))
241
+ yield* pinHostKey(worker, id, pinned, created.value);
242
+ const endpoint = yield* driver.endpoint(id, interval);
243
+ if (Option.isSome(endpoint.hostKey))
244
+ yield* pinHostKey(worker, id, pinned, endpoint.hostKey.value);
245
+ else if (Option.isNone(created) &&
246
+ !(yield* fs.exists(pinned.file).pipe(Effect.orElseSucceed(() => false))))
247
+ return yield* new MachineUnavailableError({
248
+ worker,
249
+ provider: driver.provider,
250
+ reason: "provider",
251
+ message: `usine has no SSH host key for ${driver.provider} machine ${id} of worker "${worker}": the provider reported none, and ${pinned.file} doesn't exist. usine doesn't trust the host key of a first connection.`,
252
+ });
253
+ return options.machineAt(entry, id, endpoint, pinned);
254
+ });
255
+ /** Waits until the machine answers over SSH and runs `READY_SCRIPT`. */
256
+ const awaitSsh = Effect.fn("SshExecutor.awaitMachineSsh")(function* (machine) {
257
+ const worker = machine.address.name;
258
+ const host = machine.shell.destination;
259
+ yield* Effect.annotateCurrentSpan({ worker, host });
260
+ const shell = new RemoteShell({
261
+ ssh: {
262
+ command: machine.shell.ssh.command,
263
+ args: [...machine.shell.ssh.args, "-o", "ConnectTimeout=10"],
264
+ },
265
+ sshEnvironment: machine.shell.sshEnvironment,
266
+ destination: machine.shell.destination,
267
+ });
268
+ let tries = 0;
269
+ while (true) {
270
+ tries += 1;
271
+ const result = yield* commands.inspect("prepare", shell, "sh", [
272
+ "-c",
273
+ READY_SCRIPT,
274
+ "sh",
275
+ machine.address.root,
276
+ ]);
277
+ if (result.exitCode === 0) {
278
+ yield* Effect.logInfo("The new machine answers over SSH", { worker, host, tries });
279
+ return;
280
+ }
281
+ if (result.exitCode !== SSH_FAILURE)
282
+ return yield* new WorkerCapabilityError({
283
+ worker,
284
+ capability: "machine",
285
+ message: `The new machine of worker "${worker}" at ${host} can't create the root ${machine.address.root}`,
286
+ cause: new Error(result.stderr.trim() || `exit code ${String(result.exitCode)}`),
287
+ });
288
+ yield* Effect.logDebug("The new machine doesn't answer over SSH yet", {
289
+ worker,
290
+ host,
291
+ tries,
292
+ message: result.stderr.trim(),
293
+ });
294
+ yield* interval;
295
+ }
296
+ });
297
+ /**
298
+ * Runs the machine preflight on the first machine of `worker` each day, which a provisioned
299
+ * Worker's preflight at `usine up` can't. Each machine runs one Attempt, so the container
300
+ * limits it derives are for one Attempt; the Worker's machines get them until the next check.
301
+ */
302
+ const checkDaily = Effect.fn("SshExecutor.checkMachineOfTheDay")(function* (machine, brains) {
303
+ const worker = machine.address.name;
304
+ const today = DateTime.formatIsoDateUtc(yield* DateTime.now);
305
+ if ((yield* Ref.get(checkedOn)).get(worker) === today)
306
+ return Option.none();
307
+ yield* Effect.logInfo("Checking the first machine of the day on a provisioned Worker: Git, memory, the Gateway, Docker, the container limits, and the harnesses", { worker, host: machine.shell.destination, brains: brains.length });
308
+ const report = yield* options.verifyMachine(machine, brains, 1).pipe(Effect.catchTags({
309
+ ExecutorOperationError: (error) => Effect.fail(new WorkerCapabilityError({
310
+ worker,
311
+ capability: "machine",
312
+ message: `The check of the new machine of worker "${worker}" couldn't run: ${error.message}`,
313
+ cause: error,
314
+ })),
315
+ CredentialUnavailableError: (error) => Effect.fail(new WorkerCapabilityError({
316
+ worker,
317
+ capability: "credentials",
318
+ message: error.message,
319
+ cause: error,
320
+ })),
321
+ }));
322
+ yield* Ref.update(checkedOn, (current) => new Map(current).set(worker, today));
323
+ return Option.some(new MachineCheck({ brains, containerLimits: report.containerLimits }));
324
+ });
325
+ const timedOut = (worker, provider) => new MachineUnavailableError({
326
+ worker,
327
+ provider,
328
+ reason: "timeout",
329
+ message: `The new ${provider} machine of worker "${worker}" didn't answer over SSH within ${Duration.format(readyTimeout)}`,
330
+ });
331
+ const create = Effect.fn("SshExecutor.createMachine")(function* (worker, runId, brains) {
332
+ yield* Effect.annotateCurrentSpan({ worker, runId });
333
+ const defect = (error) => Effect.die(error);
334
+ const entry = yield* options
335
+ .workerFor(worker)
336
+ .pipe(Effect.catchTags({ ExecutorOperationError: defect, CredentialUnavailableError: defect }));
337
+ const driver = yield* driverFor(worker).pipe(Effect.catchTags({ ExecutorOperationError: defect, CredentialUnavailableError: defect }));
338
+ const since = yield* DateTime.now;
339
+ const tags = { instance, worker, run: runId, process, attempt: Option.none() };
340
+ const { hold } = yield* register({
341
+ worker,
342
+ provider: driver.provider,
343
+ state: "creating",
344
+ since,
345
+ id: Option.none(),
346
+ runId: Option.some(runId),
347
+ attemptId: Option.none(),
348
+ machine: Option.none(),
349
+ tags: Option.some(tags),
350
+ holder: "lease",
351
+ checkout: false,
352
+ });
353
+ yield* Effect.logInfo("Creating a machine for the Run's next Attempt", {
354
+ worker,
355
+ runId,
356
+ provider: driver.provider,
357
+ });
358
+ const created = yield* driver
359
+ .create(machineName(worker, yield* randomHex(8)), tags)
360
+ .pipe(Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : unregister(hold))));
361
+ const id = created.id;
362
+ yield* update(hold, (current) => ({ ...current, id: Option.some(id) }));
363
+ const ready = machineOf(entry, driver, id, created.hostKey).pipe(Effect.tap((machine) => awaitSsh(machine).pipe(Effect.catchTag("ExecutorOperationError", (error) => Effect.fail(new MachineUnavailableError({
364
+ worker,
365
+ provider: driver.provider,
366
+ reason: "unreachable",
367
+ message: error.message,
368
+ }))))), Effect.timeoutOrElse({
369
+ duration: readyTimeout,
370
+ orElse: () => Effect.fail(timedOut(worker, driver.provider)),
371
+ }));
372
+ const prepared = yield* ready.pipe(Effect.flatMap((machine) => checkDaily(machine, brains).pipe(Effect.map((checked) => ({ machine, checked })))), Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : discard(hold, "the machine didn't become ready")));
373
+ yield* update(hold, (current) => ({
374
+ ...current,
375
+ state: "located",
376
+ machine: Option.some(prepared.machine),
377
+ }));
378
+ yield* Effect.logInfo("The machine for the Run's next Attempt is ready", {
379
+ worker,
380
+ runId,
381
+ provider: driver.provider,
382
+ machine: id,
383
+ host: prepared.machine.shell.destination,
384
+ seconds: Math.round(Duration.toSeconds(DateTime.distance(since, yield* DateTime.now))),
385
+ checked: Option.isSome(prepared.checked),
386
+ });
387
+ return new MachineGrant({
388
+ hold,
389
+ id,
390
+ worker,
391
+ provider: driver.provider,
392
+ checked: prepared.checked,
393
+ });
394
+ });
395
+ const assign = Effect.fn("SshExecutor.assignMachine")(function* (hold, attemptId) {
396
+ yield* Effect.annotateCurrentSpan({ hold, attemptId });
397
+ const entry = yield* entryOf(hold);
398
+ const holding = Option.flatMap(entry, (found) => Option.flatMap(found.id, (id) => Option.map(found.tags, (tags) => ({ found, id, tags }))));
399
+ if (Option.isNone(holding))
400
+ return yield* new ExecutorOperationError({
401
+ operation: "prepare",
402
+ message: "The attempt's machine is no longer held",
403
+ cause: new Error(`no machine for hold ${hold}`),
404
+ });
405
+ const { found, id } = holding.value;
406
+ const tags = { ...holding.value.tags, attempt: Option.some(attemptId) };
407
+ const driver = yield* driverFor(found.worker).pipe(Effect.mapError(operationError("prepare")));
408
+ yield* driver.tag(id, tags).pipe(Effect.mapError(operationError("prepare")));
409
+ yield* update(hold, (current) => ({
410
+ ...current,
411
+ attemptId: Option.some(attemptId),
412
+ tags: Option.some(tags),
413
+ }));
414
+ yield* Effect.logInfo("The machine is the Attempt's", {
415
+ worker: found.worker,
416
+ machine: id,
417
+ attemptId,
418
+ runId: Option.getOrElse(found.runId, () => "none"),
419
+ });
420
+ });
421
+ /**
422
+ * Looks up the machine that an earlier process created for `entry`'s Attempt and records it
423
+ * in `entry`. Returns `false` if the provider doesn't list one.
424
+ */
425
+ const locateEarlier = Effect.fn("SshExecutor.locateEarlierMachine")(function* (entry) {
426
+ const worker = entry.worker;
427
+ const attemptId = Option.getOrUndefined(entry.attemptId);
428
+ if (attemptId === undefined)
429
+ return false;
430
+ const workerEntry = yield* options.workerFor(worker);
431
+ const driver = yield* driverFor(worker);
432
+ const listed = (yield* earlierMachinesOf(worker, driver)).find(({ tags }) => Option.contains(tags.attempt, attemptTag(attemptId)));
433
+ if (listed === undefined)
434
+ return false;
435
+ const machine = yield* machineOf(workerEntry, driver, listed.id, Option.none()).pipe(Effect.timeoutOrElse({
436
+ duration: readyTimeout,
437
+ orElse: () => Effect.fail(timedOut(worker, driver.provider)),
438
+ }));
439
+ yield* update(entry.hold, (current) => ({
440
+ ...current,
441
+ state: "located",
442
+ id: Option.some(listed.id),
443
+ since: listed.createdAt,
444
+ machine: Option.some(machine),
445
+ }));
446
+ yield* Effect.logInfo("Found the machine an earlier process created for the Attempt", {
447
+ worker,
448
+ attemptId,
449
+ machine: listed.id,
450
+ host: machine.shell.destination,
451
+ holder: entry.holder,
452
+ });
453
+ return true;
454
+ });
455
+ /**
456
+ * Returns the machine of `attemptId`: the one this process holds, or else one an earlier
457
+ * process created, which it then holds as `holder`. `None` if the provider lists none.
458
+ */
459
+ const locate = Effect.fn("SshExecutor.locateMachine")(function* (worker, attemptId, holder) {
460
+ const existing = yield* byAttempt(attemptId);
461
+ const entry = Option.isSome(existing)
462
+ ? existing.value
463
+ : yield* register({
464
+ worker,
465
+ provider: (yield* driverFor(worker)).provider,
466
+ state: "unlocated",
467
+ since: yield* DateTime.now,
468
+ id: Option.none(),
469
+ runId: Option.none(),
470
+ attemptId: Option.some(attemptId),
471
+ machine: Option.none(),
472
+ tags: Option.none(),
473
+ holder,
474
+ checkout: false,
475
+ });
476
+ if (holder === "lease" && entry.holder !== "lease")
477
+ yield* update(entry.hold, (current) => ({ ...current, holder }));
478
+ if (entry.state === "located")
479
+ return Option.some({ hold: entry.hold, machine: entry.machine });
480
+ if (!(yield* locateEarlier(entry))) {
481
+ yield* unregister(entry.hold);
482
+ return Option.none();
483
+ }
484
+ const located = yield* entryOf(entry.hold);
485
+ return Option.map(located, ({ hold, machine }) => ({ hold, machine }));
486
+ });
487
+ const adopt = Effect.fn("SshExecutor.adoptMachine")(function* (worker, attemptId) {
488
+ yield* Effect.annotateCurrentSpan({ worker, attemptId });
489
+ const entry = yield* options.workerFor(worker).pipe(Effect.option);
490
+ if (Option.isNone(entry) || Option.isNone(provisionerOf(entry.value)))
491
+ return Option.none();
492
+ const located = yield* locate(worker, attemptId, "lease").pipe(Effect.result);
493
+ if (Result.isSuccess(located)) {
494
+ if (Option.isNone(located.success))
495
+ yield* Effect.logInfo("The Attempt's machine is gone; its slot holds no machine", {
496
+ worker,
497
+ attemptId,
498
+ });
499
+ return Option.map(located.success, ({ hold }) => hold);
500
+ }
501
+ yield* Effect.logWarning("Could not look up the machine of an Attempt from before the restart; it stays held, so no sweep deletes it, and its next use looks it up again", { worker, attemptId, message: located.failure.message });
502
+ return Option.map(yield* byAttempt(attemptId), ({ hold }) => hold);
503
+ });
504
+ const release = Effect.fn("SshExecutor.releaseMachine")(function* (hold) {
505
+ yield* Effect.annotateCurrentSpan({ hold });
506
+ const entry = yield* entryOf(hold);
507
+ // A machine never looked up may have the Attempt's checkout too.
508
+ if (Option.isSome(entry) && (entry.value.checkout || entry.value.state === "unlocated")) {
509
+ yield* update(hold, (current) => ({ ...current, holder: "retained" }));
510
+ return yield* Effect.logWarning("The released slot's machine keeps its Attempt's checkout, or couldn't be looked up, so it stays for the next process to reattach or recover", {
511
+ worker: entry.value.worker,
512
+ attemptId: Option.getOrElse(entry.value.attemptId, () => "none"),
513
+ machine: Option.getOrElse(entry.value.id, () => "none"),
514
+ });
515
+ }
516
+ yield* discard(hold, "its slot was released");
517
+ });
518
+ const checkoutHeld = Effect.fn("SshExecutor.machineCheckoutHeld")(function* (attemptId, held) {
519
+ const entry = yield* byAttempt(attemptId);
520
+ if (Option.isNone(entry) || entry.value.checkout === held)
521
+ return;
522
+ yield* update(entry.value.hold, (current) => ({ ...current, checkout: held }));
523
+ yield* Effect.logDebug(held
524
+ ? "The machine has the Attempt's checkout"
525
+ : "The Attempt's checkout is removed from its machine", { worker: entry.value.worker, attemptId });
526
+ // Its slot was released while the checkout stayed, so nothing else deletes it.
527
+ if (!held && entry.value.holder === "retained")
528
+ yield* discard(entry.value.hold, "its kept checkout was removed");
529
+ });
530
+ /** Lists `entry`'s machines and deletes those no Attempt or creation in this process holds. */
531
+ const sweepWorker = Effect.fn("SshExecutor.sweepMachines")(function* (entry) {
532
+ const worker = entry.address.name;
533
+ yield* Effect.annotateCurrentSpan({ worker });
534
+ const driver = yield* driverFor(worker);
535
+ const listed = yield* driver.list(instance, worker);
536
+ const current = [...(yield* Ref.get(held)).values()].filter((machine) => machine.worker === worker);
537
+ const listedIds = new Set(listed.map(({ id }) => id));
538
+ const creatingRuns = new Set(current.flatMap((entry) => entry.state === "creating" && Option.isNone(entry.id)
539
+ ? Option.match(entry.runId, {
540
+ onNone: () => [],
541
+ onSome: (runId) => [tagValue(runId), tagValue(runId, 32)],
542
+ })
543
+ : []));
544
+ const holding = (machine) => current.find((entry) => Option.contains(entry.id, machine.id) ||
545
+ Option.exists(entry.attemptId, (attemptId) => Option.contains(machine.tags.attempt, attemptTag(attemptId))));
546
+ let deleted = 0;
547
+ for (const machine of listed) {
548
+ const holder = holding(machine);
549
+ if (holder !== undefined) {
550
+ if (holder.state === "deleting") {
551
+ if (Option.isNone(holder.id))
552
+ yield* update(holder.hold, (found) => ({ ...found, id: Option.some(machine.id) }));
553
+ yield* discard(holder.hold, "an earlier deletion failed");
554
+ }
555
+ continue;
556
+ }
557
+ // A machine of this process that no entry holds yet is still being created, unless its
558
+ // creation ended without the provider's answer.
559
+ if (machine.tags.process === process && creatingRuns.has(machine.tags.run))
560
+ continue;
561
+ const removed = yield* destroy(driver, worker, machine.id).pipe(Effect.as(true), Effect.tapError((error) => Effect.logWarning("Could not delete a machine no Attempt holds; the next sweep tries again", { worker, machine: machine.id, message: error.message })), Effect.orElseSucceed(() => false));
562
+ if (!removed)
563
+ continue;
564
+ deleted += 1;
565
+ yield* Effect.logInfo("Deleted a machine that no Attempt holds", {
566
+ worker,
567
+ provider: driver.provider,
568
+ machine: machine.id,
569
+ attemptTag: Option.getOrElse(machine.tags.attempt, () => "none"),
570
+ runTag: machine.tags.run,
571
+ createdAt: DateTime.formatIso(machine.createdAt),
572
+ });
573
+ }
574
+ // Drops the machines whose deletion failed but which the provider no longer lists.
575
+ for (const machine of current)
576
+ if (machine.state === "deleting" && Option.exists(machine.id, (id) => !listedIds.has(id)))
577
+ yield* unregister(machine.hold);
578
+ yield* Effect.logDebug("Swept the machines of a provisioned Worker", {
579
+ worker,
580
+ listed: listed.length,
581
+ deleted,
582
+ held: current.length,
583
+ });
584
+ });
585
+ const sweep = Effect.fn("SshExecutor.sweepProvisionedMachines")(function* () {
586
+ const provisioned = (yield* options.entries).filter((entry) => Option.isSome(provisionerOf(entry)));
587
+ yield* Effect.forEach(provisioned, (entry) => sweepWorker(entry).pipe(Effect.tapError((error) => Effect.logWarning("Could not sweep the machines of a provisioned Worker", {
588
+ worker: entry.address.name,
589
+ message: error.message,
590
+ })), Effect.ignore), { discard: true });
591
+ });
592
+ const machines = Ref.get(held).pipe(Effect.map((current) => [...current.values()]
593
+ .filter((entry) => entry.state !== "unlocated")
594
+ .sort((left, right) => DateTime.toEpochMillis(left.since) - DateTime.toEpochMillis(right.since))
595
+ .map((entry) => new MachineStatus({
596
+ worker: entry.worker,
597
+ provider: entry.provider,
598
+ state: entry.state === "creating" || entry.state === "deleting" ? entry.state : "ready",
599
+ id: entry.id,
600
+ runId: entry.runId,
601
+ attemptId: entry.attemptId,
602
+ since: entry.since,
603
+ }))));
604
+ /** Returns the machine of an Attempt for its preparation. Fails if it has none. */
605
+ const attemptMachine = Effect.fn("SshExecutor.attemptMachine")(function* (worker, attemptId) {
606
+ const located = yield* locate(worker, attemptId, "lease").pipe(Effect.mapError(operationError("prepare")));
607
+ const machine = Option.flatMap(located, ({ machine }) => machine);
608
+ if (Option.isSome(machine))
609
+ return machine.value;
610
+ return yield* new ExecutorOperationError({
611
+ operation: "prepare",
612
+ message: `Worker "${worker}" has no machine for the attempt`,
613
+ cause: new Error(`no machine for attempt ${attemptId}`),
614
+ });
615
+ });
616
+ /**
617
+ * Returns the machine of an Attempt that startup recovery reconciles, or `None` if it has
618
+ * none. `recovered` deletes a machine that no lease holds.
619
+ */
620
+ const recoveryMachine = (worker, attemptId) => locate(worker, attemptId, "recovery").pipe(Effect.map((located) => Option.flatMap(located, ({ machine }) => machine)), Effect.mapError(operationError("cleanup")));
621
+ /**
622
+ * Deletes the machine of `attemptId` after its recovery succeeded, unless a lease holds it.
623
+ * After a failed recovery, keeps it for the next process.
624
+ */
625
+ const recovered = Effect.fn("SshExecutor.machineRecovered")(function* (attemptId, succeeded) {
626
+ const entry = yield* byAttempt(attemptId);
627
+ if (Option.isNone(entry) || entry.value.holder !== "recovery")
628
+ return;
629
+ if (succeeded && !entry.value.checkout)
630
+ return yield* discard(entry.value.hold, "its Attempt's checkout was reconciled");
631
+ yield* update(entry.value.hold, (current) => ({ ...current, holder: "retained" }));
632
+ yield* Effect.logWarning("Kept the machine of an Attempt whose recovery failed or left its checkout; the next process tries again", {
633
+ worker: entry.value.worker,
634
+ attemptId,
635
+ machine: Option.getOrElse(entry.value.id, () => "none"),
636
+ });
637
+ });
638
+ /**
639
+ * Verifies a provisioned Worker without a machine: the provider token, image, key, and limits,
640
+ * the Gateway's admin API, and that every harness has a credential no machine has to keep.
641
+ * The machine checks run on the Worker's first machine each day.
642
+ */
643
+ const verifyProvisioned = Effect.fn("SshExecutor.verifyProvisioned")(function* (entry, brains, maxConcurrentAttempts) {
644
+ const worker = entry.address.name;
645
+ const provisioner = provisionerOf(entry);
646
+ if (Option.isNone(provisioner))
647
+ return yield* Effect.die(new Error(`SSH worker "${worker}" isn't provisioned`));
648
+ const driver = yield* driverFor(worker);
649
+ const report = yield* driver.verify(worker, maxConcurrentAttempts);
650
+ yield* Effect.forEach(report.warnings, (warning) => Effect.logWarning(warning, { worker, provider: driver.provider }), { discard: true });
651
+ if (Option.isSome(entry.gateway))
652
+ yield* options.verifyGatewayAdmin(worker, entry.gateway.value);
653
+ const gap = harnessCredentialGap(entry, provisioner.value, brains);
654
+ if (Option.isSome(gap))
655
+ return yield* gap.value;
656
+ yield* Effect.logInfo("Provisioned SSH Worker preflight verified; its first machine each day checks the harnesses", {
657
+ worker,
658
+ provider: driver.provider,
659
+ harnesses: Arr.dedupe(brains.map(({ harness }) => harness)).join(","),
660
+ });
661
+ return {
662
+ degraded: Option.none(),
663
+ deferred: Option.some("Checked on the worker's first machine each day, since no machine exists before an attempt."),
664
+ containerLimits: Option.none(),
665
+ };
666
+ });
667
+ const provisions = (worker) => options.workerFor(worker).pipe(Effect.map((entry) => Option.isSome(provisionerOf(entry))), Effect.orElseSucceed(() => false));
668
+ return {
669
+ port: WorkerMachines.of({
670
+ provisions,
671
+ create,
672
+ assign,
673
+ adopt,
674
+ release,
675
+ sweep: sweep(),
676
+ machines,
677
+ }),
678
+ attemptMachine,
679
+ recoveryMachine,
680
+ recovered,
681
+ checkoutHeld,
682
+ verifyProvisioned,
683
+ provisions,
684
+ };
685
+ });
686
+ //# sourceMappingURL=machines.js.map