anbaric-cloud-hosting 1.0.0
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/package.json +35 -0
- package/src/app-management/BaseBuildLayer.ts +178 -0
- package/src/app-management/BuildLayer.ts +19 -0
- package/src/app-management/DockerBuildLayer.ts +84 -0
- package/src/app-management/FargateBuildLayer.ts +232 -0
- package/src/auth/Authenticator.ts +18 -0
- package/src/auth/AuthenticatorLoader.ts +13 -0
- package/src/auth/CliAuthorizer.ts +54 -0
- package/src/auth/CliKey.ts +20 -0
- package/src/auth/CliKeyStore.ts +12 -0
- package/src/auth/InMemoryCliKeyStore.ts +27 -0
- package/src/auth/KeyPair.ts +13 -0
- package/src/auth/Role.ts +11 -0
- package/src/auth/Tenant.ts +11 -0
- package/src/auth/TokenAuthenticator.ts +62 -0
- package/src/auth/User.ts +22 -0
- package/src/data-store/PostgresCliKeyStore.ts +44 -0
- package/src/data-store/PostgresJobPersistence.ts +77 -0
- package/src/data-store/PostgresJsonStore.ts +41 -0
- package/src/data-store/Schema.ts +55 -0
- package/src/data-store/SecretsManagerSecretStore.ts +57 -0
- package/src/hosting/HostingServer.ts +130 -0
- package/src/hosting/Router.ts +306 -0
- package/src/hosting/pages/platform-ui.html +59 -0
- package/src/index.ts +24 -0
- package/src/main.ts +72 -0
- package/src/queuing/ConfirmableQueue.ts +9 -0
- package/src/queuing/ConsumerRegistry.ts +19 -0
- package/src/queuing/Dispatcher.ts +64 -0
- package/src/queuing/PostgresQueue.ts +54 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "anbaric-cloud-hosting",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "chris@anbaric.ai",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"types": "src/index.ts",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "tsx src/main.ts",
|
|
12
|
+
"test": "vitest run"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@aws-sdk/client-codebuild": "^3.1110.0",
|
|
16
|
+
"@aws-sdk/client-ecs": "^3.1110.0",
|
|
17
|
+
"@aws-sdk/client-s3": "^3.1110.0",
|
|
18
|
+
"@aws-sdk/client-secrets-manager": "^3.700.0",
|
|
19
|
+
"@aws-sdk/client-servicediscovery": "^3.1110.0",
|
|
20
|
+
"anbaric-data-store": "^1.0.0",
|
|
21
|
+
"anbaric-tsapi": "^1.0.0",
|
|
22
|
+
"pg": "^8.16.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^26.2.0",
|
|
26
|
+
"@types/pg": "^8.15.0",
|
|
27
|
+
"anbaric-cloud": "^1.0.0",
|
|
28
|
+
"anbaric-state-machine": "^1.0.0",
|
|
29
|
+
"tsx": "^4.20.0",
|
|
30
|
+
"typescript": "^7.0.2"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {ChildProcess, spawn} from "node:child_process";
|
|
2
|
+
import {mkdir, readFile, rm, symlink, writeFile} from "node:fs/promises";
|
|
3
|
+
import {join} from "node:path";
|
|
4
|
+
import {BuildLayer, DeploymentStatus, DeploymentSummary} from "./BuildLayer";
|
|
5
|
+
|
|
6
|
+
type Deployment = {
|
|
7
|
+
appName : string,
|
|
8
|
+
status : DeploymentStatus,
|
|
9
|
+
appPort : number,
|
|
10
|
+
appHost : string,
|
|
11
|
+
consumerPort : number,
|
|
12
|
+
log : Array<string>,
|
|
13
|
+
process? : ChildProcess,
|
|
14
|
+
replaces? : Deployment,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Probe = (host : string, port : number) => Promise<boolean>;
|
|
18
|
+
|
|
19
|
+
const LOG_LIMIT = 200;
|
|
20
|
+
const LIVENESS_TIMEOUT_MS = 30_000;
|
|
21
|
+
const LIVENESS_PROBE_INTERVAL_MS = 250;
|
|
22
|
+
|
|
23
|
+
const httpProbe : Probe = async (host, port) => {
|
|
24
|
+
try {
|
|
25
|
+
await fetch(`http://${host}:${port}/`, { signal: AbortSignal.timeout(LIVENESS_PROBE_INTERVAL_MS) });
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
abstract class BaseBuildLayer implements BuildLayer {
|
|
33
|
+
|
|
34
|
+
protected deployments = new Map<string, Deployment>();
|
|
35
|
+
private nextAppIndex = 0;
|
|
36
|
+
|
|
37
|
+
constructor(protected appsDir : string, private consumerPortBase : number = 8800,
|
|
38
|
+
private probe : Probe = httpProbe,
|
|
39
|
+
private livenessTimeoutMs : number = LIVENESS_TIMEOUT_MS) {}
|
|
40
|
+
|
|
41
|
+
deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary {
|
|
42
|
+
const existing = this.deployments.get(appName);
|
|
43
|
+
|
|
44
|
+
const deployment : Deployment = {
|
|
45
|
+
appName,
|
|
46
|
+
status: "building",
|
|
47
|
+
appPort,
|
|
48
|
+
appHost: this.appHostFor(appName),
|
|
49
|
+
consumerPort: existing?.consumerPort ?? this.consumerPortBase + this.nextAppIndex++,
|
|
50
|
+
log: [],
|
|
51
|
+
replaces: existing,
|
|
52
|
+
};
|
|
53
|
+
this.deployments.set(appName, deployment);
|
|
54
|
+
|
|
55
|
+
void this.buildAndStart(deployment, tarball).catch(error => {
|
|
56
|
+
deployment.status = "failed";
|
|
57
|
+
this.log(deployment, `build failed: ${error instanceof Error ? error.message : error}`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return this.summarize(deployment);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
status(appName : string) : (DeploymentSummary & { log : Array<string> }) | undefined {
|
|
64
|
+
const deployment = this.deployments.get(appName);
|
|
65
|
+
return deployment && { ...this.summarize(deployment), log: deployment.log.slice(-20) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
list() : Array<DeploymentSummary> {
|
|
69
|
+
return Array.from(this.deployments.values(), deployment => this.summarize(deployment));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async cleanUp() : Promise<void> {
|
|
73
|
+
await Promise.all(Array.from(this.deployments.values(), deployment => {
|
|
74
|
+
deployment.status = "stopped";
|
|
75
|
+
return this.stop(deployment);
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
protected abstract appHostFor(appName : string) : string;
|
|
80
|
+
protected abstract start(deployment : Deployment, appDir : string, entryPoint : string) : Promise<void>;
|
|
81
|
+
protected abstract stop(deployment : Deployment) : Promise<void>;
|
|
82
|
+
|
|
83
|
+
private async buildAndStart(deployment : Deployment, tarball : Buffer) : Promise<void> {
|
|
84
|
+
const appDir = join(this.appsDir, deployment.appName);
|
|
85
|
+
await rm(appDir, { recursive: true, force: true });
|
|
86
|
+
await mkdir(appDir, { recursive: true });
|
|
87
|
+
|
|
88
|
+
const tarballPath = join(this.appsDir, `${deployment.appName}.tar.gz`);
|
|
89
|
+
await writeFile(tarballPath, tarball);
|
|
90
|
+
this.log(deployment, "extracting application bundle");
|
|
91
|
+
await this.run(deployment, "tar", ["-xzf", tarballPath, "-C", appDir]);
|
|
92
|
+
|
|
93
|
+
this.log(deployment, "linking anbaric workspace packages");
|
|
94
|
+
const manifest = JSON.parse(await readFile(join(appDir, "package.json"), "utf8"));
|
|
95
|
+
await this.linkWorkspacePackages(appDir, manifest);
|
|
96
|
+
|
|
97
|
+
if (deployment.replaces) await this.stop(deployment.replaces);
|
|
98
|
+
|
|
99
|
+
if (!this.isCurrent(deployment)) return;
|
|
100
|
+
await this.start(deployment, appDir, manifest.main);
|
|
101
|
+
await this.awaitLive(deployment);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
protected isCurrent(deployment : Deployment) : boolean {
|
|
105
|
+
return this.deployments.get(deployment.appName) === deployment && deployment.status === "building";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async awaitLive(deployment : Deployment) : Promise<void> {
|
|
109
|
+
const deadline = Date.now() + this.livenessTimeoutMs;
|
|
110
|
+
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
if (deployment.status !== "building") return;
|
|
113
|
+
if (await this.probe(deployment.appHost, deployment.appPort)) {
|
|
114
|
+
deployment.status = "running";
|
|
115
|
+
this.log(deployment, `app is live at ${deployment.appHost}:${deployment.appPort}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await new Promise(resolve => setTimeout(resolve, LIVENESS_PROBE_INTERVAL_MS));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await this.stop(deployment);
|
|
122
|
+
deployment.status = "failed";
|
|
123
|
+
this.log(deployment, `app did not respond at ${deployment.appHost}:${deployment.appPort} within ${this.livenessTimeoutMs / 1000}s`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async linkWorkspacePackages(appDir : string, manifest : { dependencies? : Record<string, string> }) : Promise<void> {
|
|
127
|
+
const workspacePackages = await this.workspacePackages();
|
|
128
|
+
await mkdir(join(appDir, "node_modules"), { recursive: true });
|
|
129
|
+
|
|
130
|
+
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
|
|
131
|
+
const workspaceDir = workspacePackages.get(dependency);
|
|
132
|
+
if (workspaceDir) await symlink(workspaceDir, join(appDir, "node_modules", dependency));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async workspacePackages() : Promise<Map<string, string>> {
|
|
137
|
+
const root = process.cwd();
|
|
138
|
+
const rootManifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
139
|
+
const packages = new Map<string, string>();
|
|
140
|
+
|
|
141
|
+
for (const workspace of rootManifest.workspaces ?? []) {
|
|
142
|
+
const manifest = JSON.parse(await readFile(join(root, workspace, "package.json"), "utf8"));
|
|
143
|
+
packages.set(manifest.name, join(root, workspace));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return packages;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
protected run(deployment : Deployment, command : string, args : Array<string>) : Promise<void> {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const child = spawn(command, args);
|
|
152
|
+
child.stdout.on("data", chunk => this.log(deployment, String(chunk).trimEnd()));
|
|
153
|
+
child.stderr.on("data", chunk => this.log(deployment, String(chunk).trimEnd()));
|
|
154
|
+
child.on("exit", code => code === 0
|
|
155
|
+
? resolve()
|
|
156
|
+
: reject(new Error(`${command} exited with code ${code}`)));
|
|
157
|
+
child.on("error", reject);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
protected summarize(deployment : Deployment) : DeploymentSummary {
|
|
162
|
+
return {
|
|
163
|
+
appName: deployment.appName,
|
|
164
|
+
status: deployment.status,
|
|
165
|
+
appPort: deployment.appPort,
|
|
166
|
+
appHost: deployment.appHost,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
protected log(deployment : Deployment, line : string) : void {
|
|
171
|
+
deployment.log.push(line);
|
|
172
|
+
if (deployment.log.length > LOG_LIMIT) deployment.log.shift();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export { BaseBuildLayer, httpProbe };
|
|
178
|
+
export type { Deployment, Probe };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
type DeploymentStatus = "building" | "running" | "failed" | "stopped";
|
|
2
|
+
|
|
3
|
+
type DeploymentSummary = {
|
|
4
|
+
appName : string,
|
|
5
|
+
status : DeploymentStatus,
|
|
6
|
+
appPort : number,
|
|
7
|
+
appHost : string,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
interface BuildLayer {
|
|
11
|
+
|
|
12
|
+
deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary;
|
|
13
|
+
status(appName : string) : (DeploymentSummary & { log : Array<string> }) | undefined;
|
|
14
|
+
list() : Array<DeploymentSummary>;
|
|
15
|
+
cleanUp() : Promise<void>;
|
|
16
|
+
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type { BuildLayer, DeploymentStatus, DeploymentSummary };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {spawn} from "node:child_process";
|
|
2
|
+
import {writeFile} from "node:fs/promises";
|
|
3
|
+
import {join} from "node:path";
|
|
4
|
+
import {BaseBuildLayer, Deployment, Probe} from "./BaseBuildLayer";
|
|
5
|
+
|
|
6
|
+
type DockerBuildLayerOptions = {
|
|
7
|
+
baseImage : string,
|
|
8
|
+
network : string,
|
|
9
|
+
platformUrl : string,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type CommandRunner = (command : string, args : Array<string>, onOutput : (line : string) => void) => Promise<void>;
|
|
13
|
+
|
|
14
|
+
const spawnRunner : CommandRunner = (command, args, onOutput) =>
|
|
15
|
+
new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(command, args);
|
|
17
|
+
child.stdout.on("data", chunk => onOutput(String(chunk).trimEnd()));
|
|
18
|
+
child.stderr.on("data", chunk => onOutput(String(chunk).trimEnd()));
|
|
19
|
+
child.on("exit", code => code === 0
|
|
20
|
+
? resolve()
|
|
21
|
+
: reject(new Error(`${command} ${args[0]} exited with code ${code}`)));
|
|
22
|
+
child.on("error", reject);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const dockerfileFor = (baseImage : string, entryPoint : string) : string => `FROM ${baseImage}
|
|
26
|
+
COPY . /anbaric-app
|
|
27
|
+
WORKDIR /anbaric-app
|
|
28
|
+
CMD ["/anbaric/node_modules/.bin/tsx", "${entryPoint}"]
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
class DockerBuildLayer extends BaseBuildLayer {
|
|
32
|
+
|
|
33
|
+
constructor(appsDir : string, private options : DockerBuildLayerOptions,
|
|
34
|
+
consumerPortBase? : number,
|
|
35
|
+
private runCommand : CommandRunner = spawnRunner, probe? : Probe) {
|
|
36
|
+
super(appsDir, consumerPortBase, probe);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
protected appHostFor(appName : string) : string {
|
|
40
|
+
return `anbaric-app-${appName}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
protected async start(deployment : Deployment, appDir : string, entryPoint : string) : Promise<void> {
|
|
44
|
+
const image = this.appHostFor(deployment.appName);
|
|
45
|
+
const container = this.appHostFor(deployment.appName);
|
|
46
|
+
|
|
47
|
+
await writeFile(join(appDir, "Dockerfile"), dockerfileFor(this.options.baseImage, entryPoint));
|
|
48
|
+
this.log(deployment, `baking image ${image}`);
|
|
49
|
+
await this.docker(deployment, ["build", "-t", image, appDir]);
|
|
50
|
+
|
|
51
|
+
await this.removeContainer(deployment, container);
|
|
52
|
+
|
|
53
|
+
this.log(deployment, `starting container ${container}`);
|
|
54
|
+
await this.docker(deployment, ["run", "--detach", "--name", container,
|
|
55
|
+
"--network", this.options.network,
|
|
56
|
+
"--env", `PORT=${deployment.appPort}`,
|
|
57
|
+
"--env", `ANBARIC_CLOUD_URL=${this.options.platformUrl}`,
|
|
58
|
+
"--env", "ANBARIC_JOB_PERSISTENCE_TYPE=cloud",
|
|
59
|
+
"--env", "ANBARIC_QUEUE_TYPE=cloud",
|
|
60
|
+
"--env", "ANBARIC_JSON_STORE_TYPE=cloud",
|
|
61
|
+
"--env", "ANBARIC_SECRET_STORE_TYPE=cloud",
|
|
62
|
+
"--env", `ANBARIC_CONSUMER_PORT=${deployment.consumerPort}`,
|
|
63
|
+
"--env", `ANBARIC_CONSUMER_URL=http://${container}:${deployment.consumerPort}`,
|
|
64
|
+
image]);
|
|
65
|
+
|
|
66
|
+
void this.docker(deployment, ["logs", "--follow", container]).catch(() => {});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected async stop(deployment : Deployment) : Promise<void> {
|
|
70
|
+
await this.removeContainer(deployment, this.appHostFor(deployment.appName));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async removeContainer(deployment : Deployment, container : string) : Promise<void> {
|
|
74
|
+
await this.docker(deployment, ["rm", "--force", container]).catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private docker(deployment : Deployment, args : Array<string>) : Promise<void> {
|
|
78
|
+
return this.runCommand("docker", args, line => this.log(deployment, line));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export { DockerBuildLayer, dockerfileFor };
|
|
84
|
+
export type { CommandRunner, DockerBuildLayerOptions };
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import {readFile, writeFile} from "node:fs/promises";
|
|
2
|
+
import {join} from "node:path";
|
|
3
|
+
import {CodeBuildClient, BatchGetBuildsCommand, StartBuildCommand} from "@aws-sdk/client-codebuild";
|
|
4
|
+
import {ECSClient, CreateServiceCommand, DeleteServiceCommand, DescribeServicesCommand,
|
|
5
|
+
RegisterTaskDefinitionCommand, UpdateServiceCommand} from "@aws-sdk/client-ecs";
|
|
6
|
+
import {S3Client, PutObjectCommand} from "@aws-sdk/client-s3";
|
|
7
|
+
import {ServiceDiscoveryClient, CreateServiceCommand as CreateDiscoveryServiceCommand,
|
|
8
|
+
ListServicesCommand} from "@aws-sdk/client-servicediscovery";
|
|
9
|
+
import {BaseBuildLayer, Deployment, Probe} from "./BaseBuildLayer";
|
|
10
|
+
import {dockerfileFor} from "./DockerBuildLayer";
|
|
11
|
+
|
|
12
|
+
type FargateBuildLayerOptions = {
|
|
13
|
+
awsRegion : string,
|
|
14
|
+
cluster : string,
|
|
15
|
+
subnets : Array<string>,
|
|
16
|
+
appSecurityGroup : string,
|
|
17
|
+
namespaceId : string,
|
|
18
|
+
namespaceName : string,
|
|
19
|
+
appsRepositoryUrl : string,
|
|
20
|
+
buildBucket : string,
|
|
21
|
+
buildProject : string,
|
|
22
|
+
baseImage : string,
|
|
23
|
+
appExecutionRoleArn : string,
|
|
24
|
+
appsLogGroup : string,
|
|
25
|
+
platformUrl : string,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type AwsClients = {
|
|
29
|
+
s3 : { send(command : any) : Promise<any> },
|
|
30
|
+
codeBuild : { send(command : any) : Promise<any> },
|
|
31
|
+
ecs : { send(command : any) : Promise<any> },
|
|
32
|
+
serviceDiscovery : { send(command : any) : Promise<any> },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const defaultClients = (region : string) : AwsClients => ({
|
|
36
|
+
s3: new S3Client({ region }),
|
|
37
|
+
codeBuild: new CodeBuildClient({ region }),
|
|
38
|
+
ecs: new ECSClient({ region }),
|
|
39
|
+
serviceDiscovery: new ServiceDiscoveryClient({ region }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const BUILD_TIMEOUT_MS = 900_000;
|
|
43
|
+
const FARGATE_LIVENESS_TIMEOUT_MS = 180_000;
|
|
44
|
+
|
|
45
|
+
class FargateBuildLayer extends BaseBuildLayer {
|
|
46
|
+
|
|
47
|
+
private aws : AwsClients;
|
|
48
|
+
|
|
49
|
+
constructor(appsDir : string, private options : FargateBuildLayerOptions,
|
|
50
|
+
consumerPortBase? : number,
|
|
51
|
+
aws? : AwsClients, probe? : Probe,
|
|
52
|
+
private buildPollIntervalMs : number = 5000) {
|
|
53
|
+
super(appsDir, consumerPortBase, probe, FARGATE_LIVENESS_TIMEOUT_MS);
|
|
54
|
+
this.aws = aws ?? defaultClients(options.awsRegion);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected appHostFor(appName : string) : string {
|
|
58
|
+
return `${appName}.${this.options.namespaceName}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
protected async start(deployment : Deployment, appDir : string, entryPoint : string) : Promise<void> {
|
|
62
|
+
const imageUri = `${this.options.appsRepositoryUrl}:${deployment.appName}`;
|
|
63
|
+
|
|
64
|
+
await writeFile(join(appDir, "Dockerfile"), dockerfileFor(this.options.baseImage, entryPoint));
|
|
65
|
+
await this.uploadBundle(deployment, appDir);
|
|
66
|
+
await this.bakeImage(deployment, imageUri);
|
|
67
|
+
|
|
68
|
+
if (!this.isCurrent(deployment)) return;
|
|
69
|
+
const taskDefinition = await this.registerTaskDefinition(deployment, imageUri);
|
|
70
|
+
const registryArn = await this.discoveryServiceFor(deployment.appName);
|
|
71
|
+
await this.upsertService(deployment, taskDefinition, registryArn);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
protected async stop(deployment : Deployment) : Promise<void> {
|
|
75
|
+
if (this.deployments.get(deployment.appName) !== deployment) return;
|
|
76
|
+
|
|
77
|
+
await this.aws.ecs.send(new DeleteServiceCommand({
|
|
78
|
+
cluster: this.options.cluster,
|
|
79
|
+
service: this.serviceNameFor(deployment.appName),
|
|
80
|
+
force: true,
|
|
81
|
+
})).catch(() => {});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async uploadBundle(deployment : Deployment, appDir : string) : Promise<void> {
|
|
85
|
+
const bundlePath = join(this.appsDir, `${deployment.appName}-image.tar.gz`);
|
|
86
|
+
await this.run(deployment, "tar", ["-czf", bundlePath, "-C", appDir, "."]);
|
|
87
|
+
|
|
88
|
+
this.log(deployment, "uploading build context");
|
|
89
|
+
await this.aws.s3.send(new PutObjectCommand({
|
|
90
|
+
Bucket: this.options.buildBucket,
|
|
91
|
+
Key: this.buildKeyFor(deployment.appName),
|
|
92
|
+
Body: await readFile(bundlePath),
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async bakeImage(deployment : Deployment, imageUri : string) : Promise<void> {
|
|
97
|
+
this.log(deployment, `baking image ${imageUri} with CodeBuild`);
|
|
98
|
+
const started = await this.aws.codeBuild.send(new StartBuildCommand({
|
|
99
|
+
projectName: this.options.buildProject,
|
|
100
|
+
environmentVariablesOverride: [
|
|
101
|
+
{ name: "TARBALL_S3_URI", value: `s3://${this.options.buildBucket}/${this.buildKeyFor(deployment.appName)}` },
|
|
102
|
+
{ name: "IMAGE_URI", value: imageUri },
|
|
103
|
+
{ name: "REPOSITORY_HOST", value: this.options.appsRepositoryUrl.split("/")[0] },
|
|
104
|
+
],
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
const deadline = Date.now() + BUILD_TIMEOUT_MS;
|
|
108
|
+
let lastPhase = "";
|
|
109
|
+
while (Date.now() < deadline) {
|
|
110
|
+
const {builds} = await this.aws.codeBuild.send(new BatchGetBuildsCommand({ ids: [started.build.id] }));
|
|
111
|
+
const build = builds[0];
|
|
112
|
+
|
|
113
|
+
if (build.currentPhase && build.currentPhase !== lastPhase) {
|
|
114
|
+
lastPhase = build.currentPhase;
|
|
115
|
+
this.log(deployment, `build ${lastPhase.toLowerCase()}`);
|
|
116
|
+
}
|
|
117
|
+
if (build.buildStatus === "SUCCEEDED") return;
|
|
118
|
+
if (build.buildStatus !== "IN_PROGRESS") {
|
|
119
|
+
throw new Error(`CodeBuild build finished with status ${build.buildStatus}`);
|
|
120
|
+
}
|
|
121
|
+
await new Promise(resolve => setTimeout(resolve, this.buildPollIntervalMs));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new Error(`CodeBuild build did not finish within ${BUILD_TIMEOUT_MS / 1000}s`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async registerTaskDefinition(deployment : Deployment, imageUri : string) : Promise<string> {
|
|
128
|
+
const registered = await this.aws.ecs.send(new RegisterTaskDefinitionCommand({
|
|
129
|
+
family: this.serviceNameFor(deployment.appName),
|
|
130
|
+
requiresCompatibilities: ["FARGATE"],
|
|
131
|
+
networkMode: "awsvpc",
|
|
132
|
+
cpu: "256",
|
|
133
|
+
memory: "512",
|
|
134
|
+
executionRoleArn: this.options.appExecutionRoleArn,
|
|
135
|
+
runtimePlatform: { operatingSystemFamily: "LINUX", cpuArchitecture: "X86_64" },
|
|
136
|
+
containerDefinitions: [{
|
|
137
|
+
name: deployment.appName,
|
|
138
|
+
image: imageUri,
|
|
139
|
+
essential: true,
|
|
140
|
+
portMappings: [
|
|
141
|
+
{ containerPort: deployment.appPort, protocol: "tcp" },
|
|
142
|
+
{ containerPort: deployment.consumerPort, protocol: "tcp" },
|
|
143
|
+
],
|
|
144
|
+
environment: [
|
|
145
|
+
{ name: "PORT", value: String(deployment.appPort) },
|
|
146
|
+
{ name: "ANBARIC_CLOUD_URL", value: this.options.platformUrl },
|
|
147
|
+
{ name: "ANBARIC_JOB_PERSISTENCE_TYPE", value: "cloud" },
|
|
148
|
+
{ name: "ANBARIC_QUEUE_TYPE", value: "cloud" },
|
|
149
|
+
{ name: "ANBARIC_JSON_STORE_TYPE", value: "cloud" },
|
|
150
|
+
{ name: "ANBARIC_SECRET_STORE_TYPE", value: "cloud" },
|
|
151
|
+
{ name: "ANBARIC_CONSUMER_PORT", value: String(deployment.consumerPort) },
|
|
152
|
+
{ name: "ANBARIC_CONSUMER_URL", value: `http://${deployment.appHost}:${deployment.consumerPort}` },
|
|
153
|
+
],
|
|
154
|
+
logConfiguration: {
|
|
155
|
+
logDriver: "awslogs",
|
|
156
|
+
options: {
|
|
157
|
+
"awslogs-group": this.options.appsLogGroup,
|
|
158
|
+
"awslogs-region": this.options.awsRegion,
|
|
159
|
+
"awslogs-stream-prefix": deployment.appName,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}],
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
return registered.taskDefinition.taskDefinitionArn;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async discoveryServiceFor(appName : string) : Promise<string> {
|
|
169
|
+
const {Services} = await this.aws.serviceDiscovery.send(new ListServicesCommand({
|
|
170
|
+
Filters: [{ Name: "NAMESPACE_ID", Values: [this.options.namespaceId], Condition: "EQ" }],
|
|
171
|
+
}));
|
|
172
|
+
const existing = (Services ?? []).find((service : { Name : string }) => service.Name === appName);
|
|
173
|
+
if (existing) return existing.Arn;
|
|
174
|
+
|
|
175
|
+
const created = await this.aws.serviceDiscovery.send(new CreateDiscoveryServiceCommand({
|
|
176
|
+
Name: appName,
|
|
177
|
+
NamespaceId: this.options.namespaceId,
|
|
178
|
+
DnsConfig: { DnsRecords: [{ Type: "A", TTL: 10 }], RoutingPolicy: "MULTIVALUE" },
|
|
179
|
+
}));
|
|
180
|
+
return created.Service.Arn;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async upsertService(deployment : Deployment, taskDefinition : string, registryArn : string) : Promise<void> {
|
|
184
|
+
const serviceName = this.serviceNameFor(deployment.appName);
|
|
185
|
+
const {services} = await this.aws.ecs.send(new DescribeServicesCommand({
|
|
186
|
+
cluster: this.options.cluster,
|
|
187
|
+
services: [serviceName],
|
|
188
|
+
}));
|
|
189
|
+
const active = (services ?? []).find((service : { status : string }) => service.status === "ACTIVE");
|
|
190
|
+
|
|
191
|
+
if (active) {
|
|
192
|
+
this.log(deployment, `rolling service ${serviceName}`);
|
|
193
|
+
await this.aws.ecs.send(new UpdateServiceCommand({
|
|
194
|
+
cluster: this.options.cluster,
|
|
195
|
+
service: serviceName,
|
|
196
|
+
taskDefinition,
|
|
197
|
+
desiredCount: 1,
|
|
198
|
+
forceNewDeployment: true,
|
|
199
|
+
}));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.log(deployment, `creating service ${serviceName}`);
|
|
204
|
+
await this.aws.ecs.send(new CreateServiceCommand({
|
|
205
|
+
cluster: this.options.cluster,
|
|
206
|
+
serviceName,
|
|
207
|
+
taskDefinition,
|
|
208
|
+
desiredCount: 1,
|
|
209
|
+
launchType: "FARGATE",
|
|
210
|
+
networkConfiguration: {
|
|
211
|
+
awsvpcConfiguration: {
|
|
212
|
+
subnets: this.options.subnets,
|
|
213
|
+
securityGroups: [this.options.appSecurityGroup],
|
|
214
|
+
assignPublicIp: "ENABLED",
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
serviceRegistries: [{ registryArn }],
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private serviceNameFor(appName : string) : string {
|
|
222
|
+
return `anbaric-app-${appName}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private buildKeyFor(appName : string) : string {
|
|
226
|
+
return `builds/${appName}.tar.gz`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export { FargateBuildLayer };
|
|
232
|
+
export type { AwsClients, FargateBuildLayerOptions };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {IncomingMessage, ServerResponse} from "node:http";
|
|
2
|
+
import {Tenant} from "./Tenant";
|
|
3
|
+
import {User} from "./User";
|
|
4
|
+
|
|
5
|
+
const SESSION_COOKIE = "anbaric_session";
|
|
6
|
+
|
|
7
|
+
abstract class Authenticator {
|
|
8
|
+
|
|
9
|
+
abstract authenticate(session : string | undefined, request : IncomingMessage,
|
|
10
|
+
response : ServerResponse) : Promise<[User, Tenant] | undefined>;
|
|
11
|
+
|
|
12
|
+
async authorize(_user : User, _request : IncomingMessage, _response : ServerResponse) : Promise<boolean> {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { Authenticator, SESSION_COOKIE }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {Authenticator} from "./Authenticator";
|
|
2
|
+
|
|
3
|
+
const loadAuthenticator = async (moduleName? : string) : Promise<Authenticator | undefined> => {
|
|
4
|
+
if (!moduleName) return undefined;
|
|
5
|
+
|
|
6
|
+
const module = await import(moduleName);
|
|
7
|
+
if (typeof module.createAuthenticator !== "function") {
|
|
8
|
+
throw new Error(`Authenticator module "${moduleName}" does not export a createAuthenticator function`);
|
|
9
|
+
}
|
|
10
|
+
return module.createAuthenticator();
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export { loadAuthenticator }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {generateKeyPairSync, randomUUID} from "node:crypto";
|
|
2
|
+
import {CliKey} from "./CliKey";
|
|
3
|
+
import {CliKeyStore} from "./CliKeyStore";
|
|
4
|
+
import {User} from "./User";
|
|
5
|
+
|
|
6
|
+
type IssuedKeyPair = {
|
|
7
|
+
keyId : string,
|
|
8
|
+
clientName : string,
|
|
9
|
+
publicKey : string,
|
|
10
|
+
privateKey : string,
|
|
11
|
+
tenant? : string,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
class CliAuthorizer {
|
|
15
|
+
|
|
16
|
+
private issued = new Map<string, IssuedKeyPair>();
|
|
17
|
+
|
|
18
|
+
constructor(private keyStore : CliKeyStore) {}
|
|
19
|
+
|
|
20
|
+
async approve(requestId : string, clientName : string, user : User, tenant? : string) : Promise<void> {
|
|
21
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
22
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
23
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
24
|
+
|
|
25
|
+
const key = new CliKey(randomUUID(), user.id, clientName, publicKeyPem);
|
|
26
|
+
await this.keyStore.save(key);
|
|
27
|
+
|
|
28
|
+
this.issued.set(requestId, {
|
|
29
|
+
keyId: key.id,
|
|
30
|
+
clientName,
|
|
31
|
+
publicKey: publicKeyPem,
|
|
32
|
+
privateKey: privateKeyPem,
|
|
33
|
+
tenant,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
collect(requestId : string) : IssuedKeyPair | undefined {
|
|
38
|
+
const keyPair = this.issued.get(requestId);
|
|
39
|
+
this.issued.delete(requestId);
|
|
40
|
+
return keyPair;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
keysFor(userId : string) : Promise<Array<CliKey>> {
|
|
44
|
+
return this.keyStore.listFor(userId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
revoke(id : string, userId : string) : Promise<void> {
|
|
48
|
+
return this.keyStore.delete(id, userId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export { CliAuthorizer };
|
|
54
|
+
export type { IssuedKeyPair };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class CliKey {
|
|
2
|
+
|
|
3
|
+
readonly id : string;
|
|
4
|
+
readonly userId : string;
|
|
5
|
+
readonly clientName : string;
|
|
6
|
+
readonly publicKey : string;
|
|
7
|
+
readonly createdAt : Date;
|
|
8
|
+
|
|
9
|
+
constructor(id : string, userId : string, clientName : string, publicKey : string,
|
|
10
|
+
createdAt : Date = new Date()) {
|
|
11
|
+
this.id = id;
|
|
12
|
+
this.userId = userId;
|
|
13
|
+
this.clientName = clientName;
|
|
14
|
+
this.publicKey = publicKey;
|
|
15
|
+
this.createdAt = createdAt;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { CliKey }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {CliKey} from "./CliKey";
|
|
2
|
+
|
|
3
|
+
interface CliKeyStore {
|
|
4
|
+
|
|
5
|
+
save(key : CliKey) : Promise<void>;
|
|
6
|
+
find(id : string) : Promise<CliKey | undefined>;
|
|
7
|
+
listFor(userId : string) : Promise<Array<CliKey>>;
|
|
8
|
+
delete(id : string, userId : string) : Promise<void>;
|
|
9
|
+
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { CliKeyStore }
|