@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.
- package/LICENSE +201 -0
- package/README.md +101 -4
- package/lib/AWS/Database.d.ts +54 -0
- package/lib/AWS/Database.d.ts.map +1 -0
- package/lib/AWS/Database.js +96 -0
- package/lib/AWS/Database.js.map +1 -0
- package/lib/AWS/EcsPool.d.ts +56 -0
- package/lib/AWS/EcsPool.d.ts.map +1 -0
- package/lib/AWS/EcsPool.js +87 -0
- package/lib/AWS/EcsPool.js.map +1 -0
- package/lib/AWS/index.d.ts +7 -0
- package/lib/AWS/index.d.ts.map +1 -0
- package/lib/AWS/index.js +7 -0
- package/lib/AWS/index.js.map +1 -0
- package/lib/AWS/internal/Gateway.d.ts +23 -0
- package/lib/AWS/internal/Gateway.d.ts.map +1 -0
- package/lib/AWS/internal/Gateway.js +70 -0
- package/lib/AWS/internal/Gateway.js.map +1 -0
- package/lib/Cloudflare/DurableObjectPool.d.ts +42 -0
- package/lib/Cloudflare/DurableObjectPool.d.ts.map +1 -0
- package/lib/Cloudflare/DurableObjectPool.js +136 -0
- package/lib/Cloudflare/DurableObjectPool.js.map +1 -0
- package/lib/Cloudflare/PoolWorker.d.ts +186 -0
- package/lib/Cloudflare/PoolWorker.d.ts.map +1 -0
- package/lib/Cloudflare/PoolWorker.js +75 -0
- package/lib/Cloudflare/PoolWorker.js.map +1 -0
- package/lib/Cloudflare/index.d.ts +7 -0
- package/lib/Cloudflare/index.d.ts.map +1 -0
- package/lib/Cloudflare/index.js +7 -0
- package/lib/Cloudflare/index.js.map +1 -0
- package/lib/Cloudflare/internal/Objects.d.ts +38 -0
- package/lib/Cloudflare/internal/Objects.d.ts.map +1 -0
- package/lib/Cloudflare/internal/Objects.js +102 -0
- package/lib/Cloudflare/internal/Objects.js.map +1 -0
- package/lib/Cron.d.ts +143 -0
- package/lib/Cron.d.ts.map +1 -0
- package/lib/Cron.js +83 -0
- package/lib/Cron.js.map +1 -0
- package/lib/Database.d.ts +95 -0
- package/lib/Database.d.ts.map +1 -0
- package/lib/Database.js +38 -0
- package/lib/Database.js.map +1 -0
- package/lib/Docker/Database.d.ts +42 -0
- package/lib/Docker/Database.d.ts.map +1 -0
- package/lib/Docker/Database.js +53 -0
- package/lib/Docker/Database.js.map +1 -0
- package/lib/Docker/SwarmPool.d.ts +44 -0
- package/lib/Docker/SwarmPool.d.ts.map +1 -0
- package/lib/Docker/SwarmPool.js +56 -0
- package/lib/Docker/SwarmPool.js.map +1 -0
- package/lib/Docker/index.d.ts +7 -0
- package/lib/Docker/index.d.ts.map +1 -0
- package/lib/Docker/index.js +7 -0
- package/lib/Docker/index.js.map +1 -0
- package/lib/Entity.d.ts +348 -0
- package/lib/Entity.d.ts.map +1 -0
- package/lib/Entity.js +308 -0
- package/lib/Entity.js.map +1 -0
- package/lib/Fly/Database.d.ts +50 -0
- package/lib/Fly/Database.d.ts.map +1 -0
- package/lib/Fly/Database.js +56 -0
- package/lib/Fly/Database.js.map +1 -0
- package/lib/Fly/MachinePool.d.ts +39 -0
- package/lib/Fly/MachinePool.d.ts.map +1 -0
- package/lib/Fly/MachinePool.js +51 -0
- package/lib/Fly/MachinePool.js.map +1 -0
- package/lib/Fly/index.d.ts +7 -0
- package/lib/Fly/index.d.ts.map +1 -0
- package/lib/Fly/index.js +7 -0
- package/lib/Fly/index.js.map +1 -0
- package/lib/GCP/ComputeEnginePool.d.ts +41 -0
- package/lib/GCP/ComputeEnginePool.d.ts.map +1 -0
- package/lib/GCP/ComputeEnginePool.js +172 -0
- package/lib/GCP/ComputeEnginePool.js.map +1 -0
- package/lib/GCP/Database.d.ts +48 -0
- package/lib/GCP/Database.d.ts.map +1 -0
- package/lib/GCP/Database.js +115 -0
- package/lib/GCP/Database.js.map +1 -0
- package/lib/GCP/index.d.ts +7 -0
- package/lib/GCP/index.d.ts.map +1 -0
- package/lib/GCP/index.js +7 -0
- package/lib/GCP/index.js.map +1 -0
- package/lib/Hetzner/Database.d.ts +48 -0
- package/lib/Hetzner/Database.d.ts.map +1 -0
- package/lib/Hetzner/Database.js +91 -0
- package/lib/Hetzner/Database.js.map +1 -0
- package/lib/Hetzner/ServerPool.d.ts +35 -0
- package/lib/Hetzner/ServerPool.d.ts.map +1 -0
- package/lib/Hetzner/ServerPool.js +98 -0
- package/lib/Hetzner/ServerPool.js.map +1 -0
- package/lib/Hetzner/index.d.ts +7 -0
- package/lib/Hetzner/index.d.ts.map +1 -0
- package/lib/Hetzner/index.js +7 -0
- package/lib/Hetzner/index.js.map +1 -0
- package/lib/Host.d.ts +60 -0
- package/lib/Host.d.ts.map +1 -0
- package/lib/Host.js +31 -0
- package/lib/Host.js.map +1 -0
- package/lib/Kubernetes/Database.d.ts +40 -0
- package/lib/Kubernetes/Database.d.ts.map +1 -0
- package/lib/Kubernetes/Database.js +56 -0
- package/lib/Kubernetes/Database.js.map +1 -0
- package/lib/Kubernetes/DeploymentPool.d.ts +54 -0
- package/lib/Kubernetes/DeploymentPool.d.ts.map +1 -0
- package/lib/Kubernetes/DeploymentPool.js +49 -0
- package/lib/Kubernetes/DeploymentPool.js.map +1 -0
- package/lib/Kubernetes/index.d.ts +7 -0
- package/lib/Kubernetes/index.d.ts.map +1 -0
- package/lib/Kubernetes/index.js +7 -0
- package/lib/Kubernetes/index.js.map +1 -0
- package/lib/Pool.d.ts +66 -0
- package/lib/Pool.d.ts.map +1 -0
- package/lib/Pool.js +47 -0
- package/lib/Pool.js.map +1 -0
- package/lib/Providers.d.ts +19 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +31 -0
- package/lib/Providers.js.map +1 -0
- package/lib/ShardGroups.d.ts +24 -0
- package/lib/ShardGroups.d.ts.map +1 -0
- package/lib/ShardGroups.js +19 -0
- package/lib/ShardGroups.js.map +1 -0
- package/lib/Singleton.d.ts +110 -0
- package/lib/Singleton.d.ts.map +1 -0
- package/lib/Singleton.js +60 -0
- package/lib/Singleton.js.map +1 -0
- package/lib/Workflow.d.ts +251 -0
- package/lib/Workflow.d.ts.map +1 -0
- package/lib/Workflow.js +288 -0
- package/lib/Workflow.js.map +1 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/lib/internal/Bootstrap.d.ts +28 -0
- package/lib/internal/Bootstrap.d.ts.map +1 -0
- package/lib/internal/Bootstrap.js +257 -0
- package/lib/internal/Bootstrap.js.map +1 -0
- package/lib/internal/EntityRuntime.d.ts +40 -0
- package/lib/internal/EntityRuntime.d.ts.map +1 -0
- package/lib/internal/EntityRuntime.js +698 -0
- package/lib/internal/EntityRuntime.js.map +1 -0
- package/lib/internal/Gateway.d.ts +82 -0
- package/lib/internal/Gateway.d.ts.map +1 -0
- package/lib/internal/Gateway.js +219 -0
- package/lib/internal/Gateway.js.map +1 -0
- package/lib/internal/HostedPlatform.d.ts +36 -0
- package/lib/internal/HostedPlatform.d.ts.map +1 -0
- package/lib/internal/HostedPlatform.js +36 -0
- package/lib/internal/HostedPlatform.js.map +1 -0
- package/lib/internal/HostedRuntime.d.ts +58 -0
- package/lib/internal/HostedRuntime.d.ts.map +1 -0
- package/lib/internal/HostedRuntime.js +72 -0
- package/lib/internal/HostedRuntime.js.map +1 -0
- package/lib/internal/Naming.d.ts +12 -0
- package/lib/internal/Naming.d.ts.map +1 -0
- package/lib/internal/Naming.js +40 -0
- package/lib/internal/Naming.js.map +1 -0
- package/lib/internal/ObjectSharding.d.ts +141 -0
- package/lib/internal/ObjectSharding.d.ts.map +1 -0
- package/lib/internal/ObjectSharding.js +337 -0
- package/lib/internal/ObjectSharding.js.map +1 -0
- package/lib/internal/PoolHost.d.ts +80 -0
- package/lib/internal/PoolHost.d.ts.map +1 -0
- package/lib/internal/PoolHost.js +203 -0
- package/lib/internal/PoolHost.js.map +1 -0
- package/lib/internal/PoolHostService.d.ts +25 -0
- package/lib/internal/PoolHostService.d.ts.map +1 -0
- package/lib/internal/PoolHostService.js +10 -0
- package/lib/internal/PoolHostService.js.map +1 -0
- package/lib/internal/Wire.d.ts +60 -0
- package/lib/internal/Wire.d.ts.map +1 -0
- package/lib/internal/Wire.js +36 -0
- package/lib/internal/Wire.js.map +1 -0
- package/lib/internal/WorkflowGateway.d.ts +36 -0
- package/lib/internal/WorkflowGateway.d.ts.map +1 -0
- package/lib/internal/WorkflowGateway.js +124 -0
- package/lib/internal/WorkflowGateway.js.map +1 -0
- package/lib/internal/external-runner.d.ts +2 -0
- package/lib/internal/external-runner.d.ts.map +1 -0
- package/lib/internal/external-runner.js +7 -0
- package/lib/internal/external-runner.js.map +1 -0
- package/lib/internal/runner.d.ts +2 -0
- package/lib/internal/runner.d.ts.map +1 -0
- package/lib/internal/runner.js +5 -0
- package/lib/internal/runner.js.map +1 -0
- package/package.json +128 -3
- package/src/AWS/Database.ts +139 -0
- package/src/AWS/EcsPool.ts +118 -0
- package/src/AWS/index.ts +6 -0
- package/src/AWS/internal/Gateway.ts +82 -0
- package/src/Cloudflare/DurableObjectPool.ts +198 -0
- package/src/Cloudflare/PoolWorker.ts +124 -0
- package/src/Cloudflare/index.ts +6 -0
- package/src/Cloudflare/internal/Objects.ts +160 -0
- package/src/Cron.ts +202 -0
- package/src/Database.ts +116 -0
- package/src/Docker/Database.ts +94 -0
- package/src/Docker/SwarmPool.ts +83 -0
- package/src/Docker/index.ts +6 -0
- package/src/Entity.ts +621 -0
- package/src/Fly/Database.ts +102 -0
- package/src/Fly/MachinePool.ts +72 -0
- package/src/Fly/index.ts +6 -0
- package/src/GCP/ComputeEnginePool.ts +203 -0
- package/src/GCP/Database.ts +168 -0
- package/src/GCP/index.ts +6 -0
- package/src/Hetzner/Database.ts +142 -0
- package/src/Hetzner/ServerPool.ts +116 -0
- package/src/Hetzner/index.ts +6 -0
- package/src/Host.ts +68 -0
- package/src/Kubernetes/Database.ts +89 -0
- package/src/Kubernetes/DeploymentPool.ts +77 -0
- package/src/Kubernetes/index.ts +6 -0
- package/src/Pool.ts +104 -0
- package/src/Providers.ts +49 -0
- package/src/ShardGroups.ts +41 -0
- package/src/Singleton.ts +153 -0
- package/src/Workflow.ts +556 -0
- package/src/index.ts +11 -0
- package/src/internal/Bootstrap.ts +368 -0
- package/src/internal/EntityRuntime.ts +996 -0
- package/src/internal/Gateway.ts +367 -0
- package/src/internal/HostedPlatform.ts +83 -0
- package/src/internal/HostedRuntime.ts +118 -0
- package/src/internal/Naming.ts +44 -0
- package/src/internal/ObjectSharding.ts +578 -0
- package/src/internal/PoolHost.ts +330 -0
- package/src/internal/PoolHostService.ts +25 -0
- package/src/internal/Wire.ts +83 -0
- package/src/internal/WorkflowGateway.ts +188 -0
- package/src/internal/external-runner.ts +10 -0
- 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
|
+
});
|
package/src/Fly/index.ts
ADDED
|
@@ -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
|
+
);
|