@alchemy.run/effect-cluster 0.0.0 → 2.0.0-beta.87

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 (233) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +101 -4
  3. package/lib/AWS/Database.d.ts +54 -0
  4. package/lib/AWS/Database.d.ts.map +1 -0
  5. package/lib/AWS/Database.js +96 -0
  6. package/lib/AWS/Database.js.map +1 -0
  7. package/lib/AWS/EcsPool.d.ts +56 -0
  8. package/lib/AWS/EcsPool.d.ts.map +1 -0
  9. package/lib/AWS/EcsPool.js +87 -0
  10. package/lib/AWS/EcsPool.js.map +1 -0
  11. package/lib/AWS/index.d.ts +7 -0
  12. package/lib/AWS/index.d.ts.map +1 -0
  13. package/lib/AWS/index.js +7 -0
  14. package/lib/AWS/index.js.map +1 -0
  15. package/lib/AWS/internal/Gateway.d.ts +23 -0
  16. package/lib/AWS/internal/Gateway.d.ts.map +1 -0
  17. package/lib/AWS/internal/Gateway.js +70 -0
  18. package/lib/AWS/internal/Gateway.js.map +1 -0
  19. package/lib/Cloudflare/DurableObjectPool.d.ts +42 -0
  20. package/lib/Cloudflare/DurableObjectPool.d.ts.map +1 -0
  21. package/lib/Cloudflare/DurableObjectPool.js +136 -0
  22. package/lib/Cloudflare/DurableObjectPool.js.map +1 -0
  23. package/lib/Cloudflare/PoolWorker.d.ts +186 -0
  24. package/lib/Cloudflare/PoolWorker.d.ts.map +1 -0
  25. package/lib/Cloudflare/PoolWorker.js +75 -0
  26. package/lib/Cloudflare/PoolWorker.js.map +1 -0
  27. package/lib/Cloudflare/index.d.ts +7 -0
  28. package/lib/Cloudflare/index.d.ts.map +1 -0
  29. package/lib/Cloudflare/index.js +7 -0
  30. package/lib/Cloudflare/index.js.map +1 -0
  31. package/lib/Cloudflare/internal/Objects.d.ts +38 -0
  32. package/lib/Cloudflare/internal/Objects.d.ts.map +1 -0
  33. package/lib/Cloudflare/internal/Objects.js +102 -0
  34. package/lib/Cloudflare/internal/Objects.js.map +1 -0
  35. package/lib/Cron.d.ts +143 -0
  36. package/lib/Cron.d.ts.map +1 -0
  37. package/lib/Cron.js +83 -0
  38. package/lib/Cron.js.map +1 -0
  39. package/lib/Database.d.ts +95 -0
  40. package/lib/Database.d.ts.map +1 -0
  41. package/lib/Database.js +38 -0
  42. package/lib/Database.js.map +1 -0
  43. package/lib/Docker/Database.d.ts +42 -0
  44. package/lib/Docker/Database.d.ts.map +1 -0
  45. package/lib/Docker/Database.js +53 -0
  46. package/lib/Docker/Database.js.map +1 -0
  47. package/lib/Docker/SwarmPool.d.ts +44 -0
  48. package/lib/Docker/SwarmPool.d.ts.map +1 -0
  49. package/lib/Docker/SwarmPool.js +56 -0
  50. package/lib/Docker/SwarmPool.js.map +1 -0
  51. package/lib/Docker/index.d.ts +7 -0
  52. package/lib/Docker/index.d.ts.map +1 -0
  53. package/lib/Docker/index.js +7 -0
  54. package/lib/Docker/index.js.map +1 -0
  55. package/lib/Entity.d.ts +348 -0
  56. package/lib/Entity.d.ts.map +1 -0
  57. package/lib/Entity.js +308 -0
  58. package/lib/Entity.js.map +1 -0
  59. package/lib/Fly/Database.d.ts +50 -0
  60. package/lib/Fly/Database.d.ts.map +1 -0
  61. package/lib/Fly/Database.js +56 -0
  62. package/lib/Fly/Database.js.map +1 -0
  63. package/lib/Fly/MachinePool.d.ts +39 -0
  64. package/lib/Fly/MachinePool.d.ts.map +1 -0
  65. package/lib/Fly/MachinePool.js +51 -0
  66. package/lib/Fly/MachinePool.js.map +1 -0
  67. package/lib/Fly/index.d.ts +7 -0
  68. package/lib/Fly/index.d.ts.map +1 -0
  69. package/lib/Fly/index.js +7 -0
  70. package/lib/Fly/index.js.map +1 -0
  71. package/lib/GCP/ComputeEnginePool.d.ts +41 -0
  72. package/lib/GCP/ComputeEnginePool.d.ts.map +1 -0
  73. package/lib/GCP/ComputeEnginePool.js +172 -0
  74. package/lib/GCP/ComputeEnginePool.js.map +1 -0
  75. package/lib/GCP/Database.d.ts +48 -0
  76. package/lib/GCP/Database.d.ts.map +1 -0
  77. package/lib/GCP/Database.js +115 -0
  78. package/lib/GCP/Database.js.map +1 -0
  79. package/lib/GCP/index.d.ts +7 -0
  80. package/lib/GCP/index.d.ts.map +1 -0
  81. package/lib/GCP/index.js +7 -0
  82. package/lib/GCP/index.js.map +1 -0
  83. package/lib/Hetzner/Database.d.ts +48 -0
  84. package/lib/Hetzner/Database.d.ts.map +1 -0
  85. package/lib/Hetzner/Database.js +91 -0
  86. package/lib/Hetzner/Database.js.map +1 -0
  87. package/lib/Hetzner/ServerPool.d.ts +35 -0
  88. package/lib/Hetzner/ServerPool.d.ts.map +1 -0
  89. package/lib/Hetzner/ServerPool.js +98 -0
  90. package/lib/Hetzner/ServerPool.js.map +1 -0
  91. package/lib/Hetzner/index.d.ts +7 -0
  92. package/lib/Hetzner/index.d.ts.map +1 -0
  93. package/lib/Hetzner/index.js +7 -0
  94. package/lib/Hetzner/index.js.map +1 -0
  95. package/lib/Host.d.ts +60 -0
  96. package/lib/Host.d.ts.map +1 -0
  97. package/lib/Host.js +31 -0
  98. package/lib/Host.js.map +1 -0
  99. package/lib/Kubernetes/Database.d.ts +40 -0
  100. package/lib/Kubernetes/Database.d.ts.map +1 -0
  101. package/lib/Kubernetes/Database.js +56 -0
  102. package/lib/Kubernetes/Database.js.map +1 -0
  103. package/lib/Kubernetes/DeploymentPool.d.ts +54 -0
  104. package/lib/Kubernetes/DeploymentPool.d.ts.map +1 -0
  105. package/lib/Kubernetes/DeploymentPool.js +49 -0
  106. package/lib/Kubernetes/DeploymentPool.js.map +1 -0
  107. package/lib/Kubernetes/index.d.ts +7 -0
  108. package/lib/Kubernetes/index.d.ts.map +1 -0
  109. package/lib/Kubernetes/index.js +7 -0
  110. package/lib/Kubernetes/index.js.map +1 -0
  111. package/lib/Pool.d.ts +66 -0
  112. package/lib/Pool.d.ts.map +1 -0
  113. package/lib/Pool.js +47 -0
  114. package/lib/Pool.js.map +1 -0
  115. package/lib/Providers.d.ts +19 -0
  116. package/lib/Providers.d.ts.map +1 -0
  117. package/lib/Providers.js +31 -0
  118. package/lib/Providers.js.map +1 -0
  119. package/lib/ShardGroups.d.ts +24 -0
  120. package/lib/ShardGroups.d.ts.map +1 -0
  121. package/lib/ShardGroups.js +19 -0
  122. package/lib/ShardGroups.js.map +1 -0
  123. package/lib/Singleton.d.ts +110 -0
  124. package/lib/Singleton.d.ts.map +1 -0
  125. package/lib/Singleton.js +60 -0
  126. package/lib/Singleton.js.map +1 -0
  127. package/lib/Workflow.d.ts +251 -0
  128. package/lib/Workflow.d.ts.map +1 -0
  129. package/lib/Workflow.js +288 -0
  130. package/lib/Workflow.js.map +1 -0
  131. package/lib/index.d.ts +12 -0
  132. package/lib/index.d.ts.map +1 -0
  133. package/lib/index.js +12 -0
  134. package/lib/index.js.map +1 -0
  135. package/lib/internal/Bootstrap.d.ts +28 -0
  136. package/lib/internal/Bootstrap.d.ts.map +1 -0
  137. package/lib/internal/Bootstrap.js +257 -0
  138. package/lib/internal/Bootstrap.js.map +1 -0
  139. package/lib/internal/EntityRuntime.d.ts +40 -0
  140. package/lib/internal/EntityRuntime.d.ts.map +1 -0
  141. package/lib/internal/EntityRuntime.js +698 -0
  142. package/lib/internal/EntityRuntime.js.map +1 -0
  143. package/lib/internal/Gateway.d.ts +82 -0
  144. package/lib/internal/Gateway.d.ts.map +1 -0
  145. package/lib/internal/Gateway.js +219 -0
  146. package/lib/internal/Gateway.js.map +1 -0
  147. package/lib/internal/HostedPlatform.d.ts +36 -0
  148. package/lib/internal/HostedPlatform.d.ts.map +1 -0
  149. package/lib/internal/HostedPlatform.js +36 -0
  150. package/lib/internal/HostedPlatform.js.map +1 -0
  151. package/lib/internal/HostedRuntime.d.ts +58 -0
  152. package/lib/internal/HostedRuntime.d.ts.map +1 -0
  153. package/lib/internal/HostedRuntime.js +72 -0
  154. package/lib/internal/HostedRuntime.js.map +1 -0
  155. package/lib/internal/Naming.d.ts +12 -0
  156. package/lib/internal/Naming.d.ts.map +1 -0
  157. package/lib/internal/Naming.js +40 -0
  158. package/lib/internal/Naming.js.map +1 -0
  159. package/lib/internal/ObjectSharding.d.ts +141 -0
  160. package/lib/internal/ObjectSharding.d.ts.map +1 -0
  161. package/lib/internal/ObjectSharding.js +337 -0
  162. package/lib/internal/ObjectSharding.js.map +1 -0
  163. package/lib/internal/PoolHost.d.ts +80 -0
  164. package/lib/internal/PoolHost.d.ts.map +1 -0
  165. package/lib/internal/PoolHost.js +203 -0
  166. package/lib/internal/PoolHost.js.map +1 -0
  167. package/lib/internal/PoolHostService.d.ts +25 -0
  168. package/lib/internal/PoolHostService.d.ts.map +1 -0
  169. package/lib/internal/PoolHostService.js +10 -0
  170. package/lib/internal/PoolHostService.js.map +1 -0
  171. package/lib/internal/Wire.d.ts +60 -0
  172. package/lib/internal/Wire.d.ts.map +1 -0
  173. package/lib/internal/Wire.js +36 -0
  174. package/lib/internal/Wire.js.map +1 -0
  175. package/lib/internal/WorkflowGateway.d.ts +36 -0
  176. package/lib/internal/WorkflowGateway.d.ts.map +1 -0
  177. package/lib/internal/WorkflowGateway.js +124 -0
  178. package/lib/internal/WorkflowGateway.js.map +1 -0
  179. package/lib/internal/external-runner.d.ts +2 -0
  180. package/lib/internal/external-runner.d.ts.map +1 -0
  181. package/lib/internal/external-runner.js +7 -0
  182. package/lib/internal/external-runner.js.map +1 -0
  183. package/lib/internal/runner.d.ts +2 -0
  184. package/lib/internal/runner.d.ts.map +1 -0
  185. package/lib/internal/runner.js +5 -0
  186. package/lib/internal/runner.js.map +1 -0
  187. package/package.json +128 -3
  188. package/src/AWS/Database.ts +139 -0
  189. package/src/AWS/EcsPool.ts +118 -0
  190. package/src/AWS/index.ts +6 -0
  191. package/src/AWS/internal/Gateway.ts +82 -0
  192. package/src/Cloudflare/DurableObjectPool.ts +198 -0
  193. package/src/Cloudflare/PoolWorker.ts +124 -0
  194. package/src/Cloudflare/index.ts +6 -0
  195. package/src/Cloudflare/internal/Objects.ts +160 -0
  196. package/src/Cron.ts +202 -0
  197. package/src/Database.ts +116 -0
  198. package/src/Docker/Database.ts +94 -0
  199. package/src/Docker/SwarmPool.ts +83 -0
  200. package/src/Docker/index.ts +6 -0
  201. package/src/Entity.ts +621 -0
  202. package/src/Fly/Database.ts +102 -0
  203. package/src/Fly/MachinePool.ts +72 -0
  204. package/src/Fly/index.ts +6 -0
  205. package/src/GCP/ComputeEnginePool.ts +203 -0
  206. package/src/GCP/Database.ts +168 -0
  207. package/src/GCP/index.ts +6 -0
  208. package/src/Hetzner/Database.ts +142 -0
  209. package/src/Hetzner/ServerPool.ts +116 -0
  210. package/src/Hetzner/index.ts +6 -0
  211. package/src/Host.ts +68 -0
  212. package/src/Kubernetes/Database.ts +89 -0
  213. package/src/Kubernetes/DeploymentPool.ts +77 -0
  214. package/src/Kubernetes/index.ts +6 -0
  215. package/src/Pool.ts +104 -0
  216. package/src/Providers.ts +49 -0
  217. package/src/ShardGroups.ts +41 -0
  218. package/src/Singleton.ts +153 -0
  219. package/src/Workflow.ts +556 -0
  220. package/src/index.ts +11 -0
  221. package/src/internal/Bootstrap.ts +368 -0
  222. package/src/internal/EntityRuntime.ts +996 -0
  223. package/src/internal/Gateway.ts +367 -0
  224. package/src/internal/HostedPlatform.ts +83 -0
  225. package/src/internal/HostedRuntime.ts +118 -0
  226. package/src/internal/Naming.ts +44 -0
  227. package/src/internal/ObjectSharding.ts +578 -0
  228. package/src/internal/PoolHost.ts +330 -0
  229. package/src/internal/PoolHostService.ts +25 -0
  230. package/src/internal/Wire.ts +83 -0
  231. package/src/internal/WorkflowGateway.ts +188 -0
  232. package/src/internal/external-runner.ts +10 -0
  233. package/src/internal/runner.ts +5 -0
@@ -0,0 +1,102 @@
1
+ import * as Fly from "@alchemy.run/fly";
2
+ import * as Output from "alchemy/Output";
3
+ import { Random } from "alchemy/Random";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Layer from "effect/Layer";
6
+ import * as Redacted from "effect/Redacted";
7
+ import { clusterShardGroups, Database as ClusterDatabase } from "../Database.ts";
8
+ import type { Providers } from "../Providers.ts";
9
+
10
+ export interface DatabaseProps {
11
+ /**
12
+ * Fly region of the database.
13
+ * @default "iad"
14
+ */
15
+ region?: string;
16
+ /**
17
+ * Postgres image.
18
+ * @default "postgres:17-alpine"
19
+ */
20
+ image?: string;
21
+ /**
22
+ * Size of the database's volume, in GB.
23
+ * @default 10
24
+ */
25
+ diskSize?: number;
26
+ /**
27
+ * Machine size of the database.
28
+ * @default { cpus: 1, memoryMb: 1024 }
29
+ */
30
+ guest?: Fly.MachineGuest;
31
+ /**
32
+ * The Fly private network the database and runners join.
33
+ * @default a network for the stack and stage
34
+ */
35
+ network?: string;
36
+ }
37
+
38
+ /** Where Postgres keeps its data, below the volume's mount point. */
39
+ const DATA = "/var/lib/postgresql/data";
40
+
41
+ /**
42
+ * The database an Effect Cluster coordinates through, on Fly.io, as a
43
+ * `Cluster.Database` Layer: Postgres on a Fly Machine with a volume, in an
44
+ * App on a private network that the pool's runners join. Nothing outside the
45
+ * network reaches it.
46
+ *
47
+ * One Postgres on one volume is meant for development and small clusters;
48
+ * for Fly's Managed Postgres, pass its URL to `Cluster.Database.fromUrl`.
49
+ *
50
+ * ### Running the database on Fly
51
+ * **Example:** Postgres in Ashburn
52
+ * ```typescript
53
+ * export const Database = ClusterFly.Database("Cluster");
54
+ * ```
55
+ *
56
+ * @primitive
57
+ */
58
+ export const Database = (id: string, props: DatabaseProps = {}) =>
59
+ Layer.effect(
60
+ ClusterDatabase,
61
+ Effect.gen(function* () {
62
+ if (globalThis.__ALCHEMY_RUNTIME__) {
63
+ // Runners read their database from their own environment at runtime.
64
+ return { url: Redacted.make(""), shardGroups: undefined as never };
65
+ }
66
+ const network = props.network ?? (yield* Fly.stackNetwork);
67
+ const password = yield* Random(`${id}Password`, { bytes: 24 }) as unknown as Effect.Effect<
68
+ Random,
69
+ never,
70
+ Providers
71
+ >;
72
+ const app = yield* Fly.App(`${id}App`, { network });
73
+ const machine = yield* Fly.Machine(`${id}Postgres`, {
74
+ app,
75
+ region: props.region ?? "iad",
76
+ image: props.image ?? "postgres:17-alpine",
77
+ guest: props.guest ?? { cpus: 1, memoryMb: 1024 },
78
+ mounts: [{ path: DATA, sizeGb: props.diskSize ?? 10 }],
79
+ env: password.text.pipe(
80
+ Output.map((text) => ({
81
+ POSTGRES_PASSWORD: Redacted.value(text),
82
+ POSTGRES_DB: "cluster",
83
+ // A fresh volume holds `lost+found`: keep the data below it.
84
+ PGDATA: `${DATA}/pgdata`,
85
+ })),
86
+ ) as unknown as Record<string, string>,
87
+ restart: { policy: "always" },
88
+ });
89
+ return {
90
+ // Ordered after the Machine: runners connect once it exists.
91
+ url: Output.all(app.appName, machine.machineId, password.text).pipe(
92
+ Output.map(([appName, , text]) =>
93
+ Redacted.make(
94
+ `postgres://postgres:${encodeURIComponent(text.pipe(Redacted.value))}@${appName}.internal:5432/cluster`,
95
+ ),
96
+ ),
97
+ ),
98
+ network: { kind: "fly" as const, network },
99
+ shardGroups: yield* clusterShardGroups(id),
100
+ };
101
+ }),
102
+ );
@@ -0,0 +1,72 @@
1
+ import * as Fly from "@alchemy.run/fly";
2
+ import * as Output from "alchemy/Output";
3
+ import * as Effect from "effect/Effect";
4
+ import { GATEWAY_PORT } from "../internal/Gateway.ts";
5
+ import { makePoolLayer, type PoolSettings } from "../internal/PoolHost.ts";
6
+
7
+ export interface MachinePoolOptions extends PoolSettings {
8
+ /**
9
+ * Number of runners.
10
+ * @default 2
11
+ */
12
+ replicas?: number;
13
+ /**
14
+ * Fly region of the pool.
15
+ * @default "iad"
16
+ */
17
+ region?: string;
18
+ /**
19
+ * Machine size of each runner.
20
+ * @default { cpus: 1, memoryMb: 1024 }
21
+ */
22
+ guest?: Fly.MachineGuest;
23
+ }
24
+
25
+ /**
26
+ * Run a pool of Effect Cluster runners on Fly.io: a Fly Service whose Machines
27
+ * host every class given this pool, on the database's private
28
+ * network. Runners talk to each other and the database over the network;
29
+ * the Service serves their gateway at `https://{name}.fly.dev` to callers
30
+ * anywhere. Requires a database on a Fly network — `ClusterFly.Database`.
31
+ *
32
+ * ### Running on Fly
33
+ * **Example:** Two runners in Ashburn
34
+ * ```typescript
35
+ * export const Database = ClusterFly.Database("Cluster");
36
+ * export const Default = ClusterFly.MachinePool("Default", { replicas: 2 }).pipe(
37
+ * Layer.provide(Database),
38
+ * );
39
+ * ```
40
+ *
41
+ * @primitive
42
+ */
43
+ export const MachinePool = (id: string, options: MachinePoolOptions = {}) =>
44
+ makePoolLayer({
45
+ label: "ClusterFly.MachinePool",
46
+ id,
47
+ settings: options,
48
+ expected: "a Fly private network (ClusterFly.Database)",
49
+ // Fly App names are at most 30 characters.
50
+ nameLength: 30,
51
+ network: (database) => (database.network?.kind === "fly" ? database.network : undefined),
52
+ // The Service's App is named after the pool.
53
+ gateway: (name) => Effect.succeed({ url: `https://${name}.fly.dev` }),
54
+ nodes: ({ network, name, program, environment }) =>
55
+ Fly.Service(`${id}Pool`, {
56
+ name,
57
+ network: network.network as string,
58
+ main: program.pipe(Output.map((p) => `${p.dir}/runner.mjs`)) as unknown as string,
59
+ region: options.region ?? "iad",
60
+ count: options.replicas ?? 2,
61
+ guest: options.guest ?? { cpus: 1, memoryMb: 1024 },
62
+ port: GATEWAY_PORT,
63
+ public: true,
64
+ env: environment.pipe(
65
+ // Runners listen on the private network, which is IPv6.
66
+ Output.map((env) => ({
67
+ ...env,
68
+ CLUSTER_LISTEN_HOST: "::",
69
+ })),
70
+ ) as unknown as Record<string, string>,
71
+ }).pipe(Effect.asVoid),
72
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @alchemy.run/effect-cluster/Fly — run an Effect Cluster's database and runners
3
+ * on Fly.io. Requires `@alchemy.run/fly`.
4
+ */
5
+ export * from "./Database.ts";
6
+ export * from "./MachinePool.ts";
@@ -0,0 +1,203 @@
1
+ import * as Compute from "@alchemy.run/gcp/Compute";
2
+ import * as IAM from "@alchemy.run/gcp/IAM";
3
+ import * as Storage from "@alchemy.run/gcp/Storage";
4
+ import * as Output from "alchemy/Output";
5
+ import * as Effect from "effect/Effect";
6
+ import { GATEWAY_PORT, RUNNER_PORT } from "../internal/Gateway.ts";
7
+ import { makePoolLayer, type PoolSettings } from "../internal/PoolHost.ts";
8
+ import { COS_IMAGE } from "./Database.ts";
9
+
10
+ const NODE_IMAGE = "node:26-slim";
11
+ const APP_PATH = "/var/lib/cluster-runner";
12
+ const ENV_KEY = "cluster-runner-env";
13
+ const HASH_KEY = "cluster-runner-hash";
14
+
15
+ export interface ComputeEnginePoolOptions extends PoolSettings {
16
+ /**
17
+ * Number of runners, one instance each, spread across zones.
18
+ * @default 2
19
+ */
20
+ replicas?: number;
21
+ /**
22
+ * Machine type of each runner.
23
+ * @default "e2-small"
24
+ */
25
+ machineType?: string;
26
+ /**
27
+ * Zones the pool is spread across, in the database's region.
28
+ * @default the region's `-a`, `-b` and `-c` zones
29
+ */
30
+ zones?: string[];
31
+ }
32
+
33
+ /**
34
+ * Fetch the runner program from the bucket and run it on the host network.
35
+ * When the instance's metadata changes (a new program, new environment), run
36
+ * this script again, so every runner picks the change up.
37
+ */
38
+ const startupScript = (options: { readonly bucket: string }) => `#!/bin/bash
39
+ set -uo pipefail
40
+ MD=http://metadata.google.internal/computeMetadata/v1
41
+ mkdir -p ${APP_PATH}
42
+ # Container-Optimized OS drops inbound traffic except SSH: open the runner's ports.
43
+ for PORT in ${RUNNER_PORT} ${GATEWAY_PORT}; do
44
+ iptables -C INPUT -p tcp --dport $PORT -j ACCEPT 2>/dev/null || iptables -A INPUT -p tcp --dport $PORT -j ACCEPT
45
+ done
46
+ fetch() {
47
+ TOKEN="$(curl -sf -H 'Metadata-Flavor: Google' "$MD/instance/service-accounts/default/token" | sed -E 's/.*"access_token":"([^"]+)".*/\\1/')"
48
+ curl -sf -H "Authorization: Bearer $TOKEN" -o ${APP_PATH}/$1.next \\
49
+ "https://storage.googleapis.com/storage/v1/b/${options.bucket}/o/$1?alt=media" \\
50
+ && mv ${APP_PATH}/$1.next ${APP_PATH}/$1
51
+ }
52
+ start() {
53
+ fetch runner.mjs
54
+ curl -sf -H 'Metadata-Flavor: Google' "$MD/instance/attributes/${ENV_KEY}" > ${APP_PATH}/env.list
55
+ docker rm -f cluster-runner >/dev/null 2>&1 || true
56
+ docker run -d --name cluster-runner --restart always --network host \\
57
+ --env-file ${APP_PATH}/env.list -v ${APP_PATH}:/app:ro -w /app ${NODE_IMAGE} node /app/runner.mjs
58
+ }
59
+ etag() {
60
+ curl -sf -H 'Metadata-Flavor: Google' -D - -o /dev/null "$MD/instance/attributes/?recursive=true$1" \\
61
+ | grep -i '^etag:' | awk '{print $2}' | tr -d '\\r'
62
+ }
63
+ start
64
+ (
65
+ ETAG="$(etag "")"
66
+ while true; do
67
+ NEXT="$(etag "&wait_for_change=true&timeout_sec=3600&last_etag=$ETAG")"
68
+ if [ -n "$NEXT" ] && [ "$NEXT" != "$ETAG" ]; then
69
+ curl -sf -H 'Metadata-Flavor: Google' "$MD/instance/attributes/startup-script" > ${APP_PATH}/startup.sh \\
70
+ && exec setsid bash ${APP_PATH}/startup.sh
71
+ fi
72
+ sleep 5
73
+ done
74
+ ) >/dev/null 2>&1 < /dev/null &
75
+ # The runner's logs on the serial console (\`gcloud compute instances get-serial-port-output\`).
76
+ pgrep -f "docker logs -f cluster-runner" >/dev/null || setsid sh -c 'while true; do docker logs -f cluster-runner 2>&1 | sed -u "s/^/cluster-runner: /"; sleep 2; done > /dev/ttyS0' >/dev/null 2>&1 < /dev/null &
77
+ `;
78
+
79
+ /**
80
+ * Run a pool of Effect Cluster runners on Compute Engine: instances in the
81
+ * database's VPC, spread across zones, each running every class given these
82
+ * runners from a Cloud Storage bucket, behind a regional load balancer on a
83
+ * static address that serves their gateway to callers anywhere. Runners
84
+ * reach each other and the database through the cluster's network tag.
85
+ * Requires a database on a GCP network — `ClusterGCP.Database`.
86
+ *
87
+ * Cloud Run can't host runners: runners take connections from each other.
88
+ *
89
+ * ### Running on Compute Engine
90
+ * **Example:** Two runners
91
+ * ```typescript
92
+ * export const Database = ClusterGCP.Database("Cluster");
93
+ * export const Default = ClusterGCP.ComputeEnginePool("Default", { replicas: 2 }).pipe(
94
+ * Layer.provide(Database),
95
+ * );
96
+ * ```
97
+ *
98
+ * @primitive
99
+ */
100
+ export const ComputeEnginePool = (id: string, options: ComputeEnginePoolOptions = {}) =>
101
+ makePoolLayer({
102
+ label: "ClusterGCP.ComputeEnginePool",
103
+ id,
104
+ settings: options,
105
+ expected: "a GCP network (ClusterGCP.Database)",
106
+ network: (database) => (database.network?.kind === "gcp" ? database.network : undefined),
107
+ // A static address, before the instances, so the URL never depends on it.
108
+ gateway: (_name, network) =>
109
+ Effect.gen(function* () {
110
+ const address = yield* Compute.Address(`${id}GatewayAddress`, { region: network.region });
111
+ return {
112
+ url: Output.interpolate`http://${address.address}:${GATEWAY_PORT}`,
113
+ address: address.address as Output.Output<string>,
114
+ };
115
+ }),
116
+ nodes: ({ network, gateway, program, environment }) =>
117
+ Effect.gen(function* () {
118
+ const zones = options.zones ?? [
119
+ `${network.region}-a`,
120
+ `${network.region}-b`,
121
+ `${network.region}-c`,
122
+ ];
123
+ // The gateway checks every call's token; the passthrough load
124
+ // balancer keeps the caller's address.
125
+ yield* Compute.Firewall(`${id}GatewayFirewall`, {
126
+ network: network.network,
127
+ direction: "INGRESS",
128
+ allowed: [{ protocol: "tcp", ports: [String(GATEWAY_PORT)] }],
129
+ sourceRanges: ["0.0.0.0/0"],
130
+ targetTags: [network.tag],
131
+ });
132
+ const bucket = yield* Storage.Bucket(`${id}Program`, {
133
+ location: network.region,
134
+ forceDestroy: true,
135
+ uniformBucketLevelAccess: true,
136
+ });
137
+ const runner = yield* Storage.Object(`${id}RunnerObject`, {
138
+ bucketName: bucket.bucketName,
139
+ key: "runner.mjs",
140
+ file: program.pipe(Output.map((p) => `${p.dir}/runner.mjs`)) as unknown as string,
141
+ contentType: "text/javascript",
142
+ });
143
+ const account = yield* IAM.ServiceAccount(`${id}RunnerAccount`, {
144
+ displayName: "Effect Cluster runners",
145
+ });
146
+ const grant = yield* IAM.Member(`${id}ProgramGrant`, {
147
+ kind: "storage.bucket",
148
+ name: bucket.bucketName,
149
+ role: "roles/storage.objectViewer",
150
+ member: Output.interpolate`serviceAccount:${account.email}`,
151
+ });
152
+ // A Docker env file: one `NAME=value` per line. Each runner finds its
153
+ // own address in the subnetwork's range.
154
+ const envFile = environment.pipe(
155
+ Output.map((env) =>
156
+ Object.entries({ ...env, CLUSTER_RUNNER_NETWORK: network.cidr })
157
+ .map(([name, value]) => `${name}=${String(value).replaceAll("\n", " ")}`)
158
+ .join("\n"),
159
+ ),
160
+ );
161
+ const metadata = Output.all(bucket.bucketName, runner.md5Hash, envFile, grant.role).pipe(
162
+ Output.map(([bucketName, hash, env]) => ({
163
+ "startup-script": startupScript({ bucket: bucketName }),
164
+ [HASH_KEY]: hash ?? "",
165
+ [ENV_KEY]: env,
166
+ })),
167
+ );
168
+ const instances = [];
169
+ for (let i = 0; i < (options.replicas ?? 2); i++) {
170
+ instances.push(
171
+ yield* Compute.Instance(`${id}Runner${i}`, {
172
+ zone: zones[i % zones.length]!,
173
+ machineType: options.machineType ?? "e2-small",
174
+ sourceImage: COS_IMAGE,
175
+ network: Output.interpolate`global/networks/${network.network}`,
176
+ subnetwork: Output.interpolate`regions/${network.region}/subnetworks/${network.subnetwork}`,
177
+ associatePublicIp: true,
178
+ tags: [network.tag],
179
+ serviceAccount: account.email,
180
+ oauthScopes: ["https://www.googleapis.com/auth/cloud-platform"],
181
+ metadata: metadata as unknown as Record<string, string>,
182
+ }),
183
+ );
184
+ }
185
+ const health = yield* Compute.HttpHealthCheck(`${id}GatewayHealth`, {
186
+ port: GATEWAY_PORT,
187
+ requestPath: "/health",
188
+ });
189
+ const pool = yield* Compute.TargetPool(`${id}Gateway`, {
190
+ region: network.region,
191
+ healthChecks: [health.selfLink as Output.Output<string>],
192
+ instances: instances.map((instance) => instance.selfLink as Output.Output<string>),
193
+ });
194
+ yield* Compute.ForwardingRule(`${id}GatewayRule`, {
195
+ region: network.region,
196
+ ipAddress: gateway.address,
197
+ ipProtocol: "TCP",
198
+ portRange: String(GATEWAY_PORT),
199
+ target: pool.selfLink as Output.Output<string>,
200
+ loadBalancingScheme: "EXTERNAL",
201
+ });
202
+ }),
203
+ });
@@ -0,0 +1,168 @@
1
+ import * as Compute from "@alchemy.run/gcp/Compute";
2
+ import { GcpEnvironment } from "@alchemy.run/gcp/Environment";
3
+ import * as Output from "alchemy/Output";
4
+ import { Random } from "alchemy/Random";
5
+ import * as Effect from "effect/Effect";
6
+ import * as Layer from "effect/Layer";
7
+ import * as Redacted from "effect/Redacted";
8
+ import { clusterShardGroups, Database as ClusterDatabase } from "../Database.ts";
9
+ import { RUNNER_PORT } from "../internal/Gateway.ts";
10
+ import { stackDnsLabel } from "../internal/Naming.ts";
11
+ import type { Providers } from "../Providers.ts";
12
+
13
+ export const COS_IMAGE = "projects/cos-cloud/global/images/family/cos-stable";
14
+ const DISK_DEVICE = "cluster-data";
15
+ const MOUNT_PATH = "/mnt/disks/postgres";
16
+
17
+ export interface DatabaseProps {
18
+ /**
19
+ * Zone of the database instance.
20
+ * @default the region's `-a` zone
21
+ */
22
+ zone?: string;
23
+ /**
24
+ * Machine type of the database instance.
25
+ * @default "e2-small"
26
+ */
27
+ machineType?: string;
28
+ /**
29
+ * Size of the database's disk, in GB.
30
+ * @default 20
31
+ */
32
+ diskSize?: number;
33
+ /**
34
+ * Postgres image.
35
+ * @default "postgres:17-alpine"
36
+ */
37
+ image?: string;
38
+ /**
39
+ * IPv4 range of the cluster's subnetwork.
40
+ * @default "10.47.0.0/20"
41
+ */
42
+ cidrBlock?: string;
43
+ }
44
+
45
+ const shellQuote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
46
+
47
+ /** Mount the data disk, then run Postgres on the host network. */
48
+ const startupScript = (options: { image: string; password: string }) => `#!/bin/bash
49
+ set -euo pipefail
50
+ DEVICE=/dev/disk/by-id/google-${DISK_DEVICE}
51
+ blkid "$DEVICE" >/dev/null || mkfs.ext4 -F "$DEVICE"
52
+ mkdir -p ${MOUNT_PATH}
53
+ mountpoint -q ${MOUNT_PATH} || mount "$DEVICE" ${MOUNT_PATH}
54
+ # Container-Optimized OS drops inbound traffic except SSH: open Postgres.
55
+ iptables -C INPUT -p tcp --dport 5432 -j ACCEPT 2>/dev/null || iptables -A INPUT -p tcp --dport 5432 -j ACCEPT
56
+ docker rm -f postgres >/dev/null 2>&1 || true
57
+ docker run -d --name postgres --restart always --network host \\
58
+ -v ${MOUNT_PATH}:/var/lib/postgresql/data \\
59
+ -e PGDATA=/var/lib/postgresql/data/pgdata \\
60
+ -e POSTGRES_DB=cluster \\
61
+ -e POSTGRES_PASSWORD=${shellQuote(options.password)} \\
62
+ ${shellQuote(options.image)}
63
+ `;
64
+
65
+ /**
66
+ * The database an Effect Cluster coordinates through, on Compute Engine,
67
+ * as a `Cluster.Database` Layer: Postgres on an instance with a persistent
68
+ * disk, in a VPC of its own that the pool's runners join. Only the runners reach
69
+ * it; runners reach each other on the runner port.
70
+ *
71
+ * ### Running the database on GCP
72
+ * **Example:** Postgres on Compute Engine
73
+ * ```typescript
74
+ * export const Database = ClusterGCP.Database("Cluster");
75
+ * ```
76
+ *
77
+ * @primitive
78
+ */
79
+ export const Database = (id: string, props: DatabaseProps = {}) =>
80
+ Layer.effect(
81
+ ClusterDatabase,
82
+ Effect.gen(function* () {
83
+ if (globalThis.__ALCHEMY_RUNTIME__) {
84
+ // Runners read their database from their own environment at runtime.
85
+ return { url: Redacted.make(""), shardGroups: undefined as never };
86
+ }
87
+ const region = yield* GcpEnvironment.current.pipe(
88
+ Effect.map((env) => env.region),
89
+ Effect.orDie,
90
+ );
91
+ const zone = props.zone ?? `${region}-a`;
92
+ const cidr = props.cidrBlock ?? "10.47.0.0/20";
93
+ const poolTag = yield* stackDnsLabel(`${id}-pool`, 63);
94
+ const databaseTag = yield* stackDnsLabel(`${id}-db`, 63);
95
+ const network = yield* Compute.Network(`${id}Network`, { autoCreateSubnetworks: false });
96
+ const subnet = yield* Compute.Subnetwork(`${id}Subnet`, {
97
+ network: network.networkName,
98
+ region,
99
+ ipCidrRange: cidr,
100
+ });
101
+ yield* Compute.Firewall(`${id}DatabaseFirewall`, {
102
+ network: network.networkName,
103
+ direction: "INGRESS",
104
+ allowed: [{ protocol: "tcp", ports: ["5432"] }],
105
+ sourceTags: [poolTag],
106
+ targetTags: [databaseTag],
107
+ });
108
+ // Runners send each other messages for the shards they own.
109
+ yield* Compute.Firewall(`${id}RunnerFirewall`, {
110
+ network: network.networkName,
111
+ direction: "INGRESS",
112
+ allowed: [{ protocol: "tcp", ports: [String(RUNNER_PORT)] }],
113
+ sourceTags: [poolTag],
114
+ targetTags: [poolTag],
115
+ });
116
+ const password = yield* Random(`${id}Password`, { bytes: 24 }) as unknown as Effect.Effect<
117
+ Random,
118
+ never,
119
+ Providers
120
+ >;
121
+ const disk = yield* Compute.Disk(`${id}Disk`, {
122
+ zone,
123
+ type: "pd-balanced",
124
+ sizeGb: props.diskSize ?? 20,
125
+ });
126
+ const instance = yield* Compute.Instance(`${id}Postgres`, {
127
+ zone,
128
+ machineType: props.machineType ?? "e2-small",
129
+ sourceImage: COS_IMAGE,
130
+ network: Output.interpolate`global/networks/${network.networkName}`,
131
+ subnetwork: Output.interpolate`regions/${region}/subnetworks/${subnet.subnetworkName}`,
132
+ // Pulls the Postgres image.
133
+ associatePublicIp: true,
134
+ tags: [databaseTag],
135
+ attachedDisks: [
136
+ { source: disk.selfLink as Output.Output<string>, deviceName: DISK_DEVICE },
137
+ ],
138
+ metadata: {
139
+ "startup-script": password.text.pipe(
140
+ Output.map((text) =>
141
+ startupScript({
142
+ image: props.image ?? "postgres:17-alpine",
143
+ password: Redacted.value(text),
144
+ }),
145
+ ),
146
+ ) as unknown as string,
147
+ },
148
+ });
149
+ return {
150
+ url: Output.all(instance.networkIP, password.text).pipe(
151
+ Output.map(([ip, text]) =>
152
+ Redacted.make(
153
+ `postgres://postgres:${encodeURIComponent(text.pipe(Redacted.value))}@${ip}:5432/cluster`,
154
+ ),
155
+ ),
156
+ ),
157
+ network: {
158
+ kind: "gcp" as const,
159
+ network: network.networkName,
160
+ subnetwork: subnet.subnetworkName,
161
+ region,
162
+ cidr,
163
+ tag: poolTag,
164
+ },
165
+ shardGroups: yield* clusterShardGroups(id),
166
+ };
167
+ }),
168
+ );
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @alchemy.run/effect-cluster/GCP — run an Effect Cluster's database and runners
3
+ * on Compute Engine. Requires `@alchemy.run/gcp`.
4
+ */
5
+ export * from "./ComputeEnginePool.ts";
6
+ export * from "./Database.ts";