anbaric-cloud-hosting 1.8.1 → 1.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
5
5
  "license": "MIT",
6
6
  "author": "chris@anbaric.ai",
@@ -12,22 +12,23 @@
12
12
  "test": "vitest run"
13
13
  },
14
14
  "dependencies": {
15
+ "@aws-sdk/client-cloudwatch-logs": "^3.1110.0",
15
16
  "@aws-sdk/client-codebuild": "^3.1110.0",
16
17
  "@aws-sdk/client-ecs": "^3.1110.0",
17
18
  "@aws-sdk/client-s3": "^3.1110.0",
18
19
  "@aws-sdk/client-secrets-manager": "^3.700.0",
19
20
  "@aws-sdk/client-servicediscovery": "^3.1110.0",
20
- "anbaric-data-store": "^1.8.1",
21
- "anbaric-plugins": "^1.8.1",
22
- "anbaric-tsapi": "^1.8.1",
21
+ "anbaric-data-store": "^1.9.0",
22
+ "anbaric-plugins": "^1.9.0",
23
+ "anbaric-tsapi": "^1.9.0",
23
24
  "esbuild": "^0.28.2",
24
25
  "pg": "^8.16.0"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@types/node": "^26.2.0",
28
29
  "@types/pg": "^8.15.0",
29
- "anbaric-impl-cloud": "^1.8.1",
30
- "anbaric-state-machine": "^1.8.1",
30
+ "anbaric-impl-cloud": "^1.9.0",
31
+ "anbaric-state-machine": "^1.9.0",
31
32
  "tsx": "^4.20.0",
32
33
  "typescript": "^7.0.2"
33
34
  },
@@ -0,0 +1,64 @@
1
+ import {createServer, Server, Socket} from "node:net";
2
+
3
+ type AdminCommand = (argument : string) => string | Promise<string>;
4
+
5
+ /* The socket-level admin server baked into every app image. It listens on the
6
+ admin port and answers newline-terminated commands, writing one line back per
7
+ command. `ping` (answering `ok`) is built in and is what the platform probes
8
+ and reports as the app being up; further commands are registered with
9
+ `handle`. Kept deliberately transport-light (raw TCP, no HTTP) so an app that
10
+ serves no web traffic still has a liveness surface. */
11
+ class AdminServer {
12
+
13
+ private commands = new Map<string, AdminCommand>();
14
+ private server? : Server;
15
+
16
+ constructor(private port : number = Number(process.env.ANBARIC_ADMIN_PORT ?? 8791)) {
17
+ this.handle("ping", () => "ok");
18
+ }
19
+
20
+ handle(command : string, run : AdminCommand) : this {
21
+ this.commands.set(command, run);
22
+ return this;
23
+ }
24
+
25
+ listen() : Promise<number> {
26
+ this.server = createServer(socket => this.serve(socket));
27
+ return new Promise(resolve => this.server!.listen(this.port, () =>
28
+ resolve((this.server!.address() as { port : number }).port)));
29
+ }
30
+
31
+ async close() : Promise<void> {
32
+ await new Promise<void>(resolve => this.server ? this.server.close(() => resolve()) : resolve());
33
+ }
34
+
35
+ private serve(socket : Socket) : void {
36
+ let buffer = "";
37
+ socket.setEncoding("utf8");
38
+ socket.on("data", async chunk => {
39
+ buffer += chunk;
40
+ let newline : number;
41
+ while ((newline = buffer.indexOf("\n")) >= 0) {
42
+ const line = buffer.slice(0, newline).trim();
43
+ buffer = buffer.slice(newline + 1);
44
+ if (line) await this.respond(socket, line);
45
+ }
46
+ });
47
+ socket.on("error", () => socket.destroy());
48
+ }
49
+
50
+ private async respond(socket : Socket, line : string) : Promise<void> {
51
+ const [command, ...rest] = line.split(" ");
52
+ const run = this.commands.get(command);
53
+ if (!run) return void socket.write(`error unknown command "${command}"\n`);
54
+ try {
55
+ socket.write(`${await run(rest.join(" "))}\n`);
56
+ } catch (error) {
57
+ socket.write(`error ${error instanceof Error ? error.message : error}\n`);
58
+ }
59
+ }
60
+
61
+ }
62
+
63
+ export { AdminServer };
64
+ export type { AdminCommand };
@@ -0,0 +1,21 @@
1
+ import {connect} from "node:net";
2
+
3
+ /* Opens an app's admin port, sends `ping`, and resolves true when it answers
4
+ `ok` within the timeout - false on any timeout, refusal or other error. Used
5
+ both by the liveness probe and by on-demand status reporting. */
6
+ const adminPing = (host : string, port : number, timeoutMs : number = 1000) : Promise<boolean> =>
7
+ new Promise(resolve => {
8
+ const socket = connect({ host, port });
9
+ let answered = "";
10
+ const done = (result : boolean) => { socket.destroy(); resolve(result); };
11
+ socket.setTimeout(timeoutMs, () => done(false));
12
+ socket.setEncoding("utf8");
13
+ socket.on("connect", () => socket.write("ping\n"));
14
+ socket.on("data", chunk => {
15
+ answered += chunk;
16
+ if (answered.includes("\n")) done(answered.trim().startsWith("ok"));
17
+ });
18
+ socket.on("error", () => done(false));
19
+ });
20
+
21
+ export { adminPing };
@@ -0,0 +1,38 @@
1
+ import {spawn} from "node:child_process";
2
+ import {AdminServer} from "./AdminServer";
3
+
4
+ /* The entry point every deployed app image runs (its Dockerfile CMD). It starts
5
+ the built-in admin server - a separate concern from the app itself: liveness
6
+ now, more later - and then runs the app as its own child process. The
7
+ container's lifecycle follows the app's: when the app exits, so does this
8
+ launcher (and the admin port with it), so a failed app is a failed container
9
+ and its admin port stops answering `ping`. */
10
+ const TSX = process.env.ANBARIC_TSX_BIN ?? "/anbaric/node_modules/.bin/tsx";
11
+
12
+ const main = async () => {
13
+ const entryPoint = process.argv[2];
14
+ if (!entryPoint) throw new Error("Expected the app entry point as the first argument");
15
+
16
+ const admin = new AdminServer();
17
+ const port = await admin.listen();
18
+ console.log(`[anbaric-admin] listening on ${port}`);
19
+
20
+ const app = spawn(TSX, [entryPoint], { stdio: "inherit" });
21
+
22
+ const shutdown = (signal : NodeJS.Signals) => app.kill(signal);
23
+ process.on("SIGTERM", shutdown);
24
+ process.on("SIGINT", shutdown);
25
+
26
+ app.on("error", error => {
27
+ console.error(`[anbaric-admin] failed to start the app: ${error.message}`);
28
+ admin.close().finally(() => process.exit(1));
29
+ });
30
+ app.on("exit", (code, signal) => {
31
+ admin.close().finally(() => process.exit(code ?? (signal ? 1 : 0)));
32
+ });
33
+ };
34
+
35
+ main().catch(error => {
36
+ console.error(`[anbaric-admin] ${error instanceof Error ? error.message : error}`);
37
+ process.exit(1);
38
+ });
@@ -1,6 +1,7 @@
1
1
  import {ChildProcess, spawn} from "node:child_process";
2
2
  import {mkdir, readFile, rm, symlink, writeFile} from "node:fs/promises";
3
3
  import {join} from "node:path";
4
+ import {adminPing} from "../app-admin/adminPing";
4
5
  import {BuildLayer, DeploymentStatus, DeploymentSummary} from "./BuildLayer";
5
6
 
6
7
  type Deployment = {
@@ -8,6 +9,7 @@ type Deployment = {
8
9
  status : DeploymentStatus,
9
10
  appPort : number,
10
11
  appHost : string,
12
+ adminPort : number,
11
13
  consumerPort : number,
12
14
  log : Array<string>,
13
15
  process? : ChildProcess,
@@ -19,15 +21,11 @@ type Probe = (host : string, port : number) => Promise<boolean>;
19
21
  const LOG_LIMIT = 200;
20
22
  const LIVENESS_TIMEOUT_MS = 30_000;
21
23
  const LIVENESS_PROBE_INTERVAL_MS = 250;
24
+ const APP_ADMIN_PORT = 8791;
22
25
 
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
- };
26
+ /* Liveness is the built-in admin server answering `ping` on the admin port, so
27
+ an app that serves no HTTP still passes. */
28
+ const adminProbe : Probe = (host, port) => adminPing(host, port, LIVENESS_PROBE_INTERVAL_MS);
31
29
 
32
30
  abstract class BaseBuildLayer implements BuildLayer {
33
31
 
@@ -35,7 +33,7 @@ abstract class BaseBuildLayer implements BuildLayer {
35
33
  private nextAppIndex = 0;
36
34
 
37
35
  constructor(protected appsDir : string, private consumerPortBase : number = 8800,
38
- private probe : Probe = httpProbe,
36
+ private probe : Probe = adminProbe,
39
37
  private livenessTimeoutMs : number = LIVENESS_TIMEOUT_MS) {}
40
38
 
41
39
  deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary {
@@ -46,6 +44,7 @@ abstract class BaseBuildLayer implements BuildLayer {
46
44
  status: "building",
47
45
  appPort,
48
46
  appHost: this.appHostFor(appName),
47
+ adminPort: APP_ADMIN_PORT,
49
48
  consumerPort: existing?.consumerPort ?? this.consumerPortBase + this.nextAppIndex++,
50
49
  log: [],
51
50
  replaces: existing,
@@ -69,6 +68,30 @@ abstract class BaseBuildLayer implements BuildLayer {
69
68
  return Array.from(this.deployments.values(), deployment => this.summarize(deployment));
70
69
  }
71
70
 
71
+ async ping(appName : string) : Promise<boolean> {
72
+ const deployment = this.deployments.get(appName);
73
+ if (!deployment || deployment.status !== "running") return false;
74
+ return adminPing(deployment.appHost, deployment.adminPort, LIVENESS_PROBE_INTERVAL_MS);
75
+ }
76
+
77
+ async *logs(appName : string, signal : AbortSignal) : AsyncGenerator<string> {
78
+ const deployment = this.deployments.get(appName);
79
+ if (!deployment) throw new Error(`No app named "${appName}"`);
80
+ yield* this.streamLogs(deployment, signal);
81
+ }
82
+
83
+ async teardown(appName : string) : Promise<boolean> {
84
+ const deployment = this.deployments.get(appName);
85
+ if (!deployment) return false;
86
+
87
+ // stop() runs while the deployment is still the mapped one, so the
88
+ // Fargate guard lets it delete the service; then drop it from the map.
89
+ deployment.status = "stopped";
90
+ await this.stop(deployment);
91
+ this.deployments.delete(appName);
92
+ return true;
93
+ }
94
+
72
95
  async cleanUp() : Promise<void> {
73
96
  await Promise.all(Array.from(this.deployments.values(), deployment => {
74
97
  deployment.status = "stopped";
@@ -79,6 +102,7 @@ abstract class BaseBuildLayer implements BuildLayer {
79
102
  protected abstract appHostFor(appName : string) : string;
80
103
  protected abstract start(deployment : Deployment, appDir : string, entryPoint : string) : Promise<void>;
81
104
  protected abstract stop(deployment : Deployment) : Promise<void>;
105
+ protected abstract streamLogs(deployment : Deployment, signal : AbortSignal) : AsyncIterable<string>;
82
106
 
83
107
  private async buildAndStart(deployment : Deployment, tarball : Buffer) : Promise<void> {
84
108
  const appDir = join(this.appsDir, deployment.appName);
@@ -110,9 +134,9 @@ abstract class BaseBuildLayer implements BuildLayer {
110
134
 
111
135
  while (Date.now() < deadline) {
112
136
  if (deployment.status !== "building") return;
113
- if (await this.probe(deployment.appHost, deployment.appPort)) {
137
+ if (await this.probe(deployment.appHost, deployment.adminPort)) {
114
138
  deployment.status = "running";
115
- this.log(deployment, `app is live at ${deployment.appHost}:${deployment.appPort}`);
139
+ this.log(deployment, `app is live (admin port ${deployment.adminPort})`);
116
140
  return;
117
141
  }
118
142
  await new Promise(resolve => setTimeout(resolve, LIVENESS_PROBE_INTERVAL_MS));
@@ -120,7 +144,7 @@ abstract class BaseBuildLayer implements BuildLayer {
120
144
 
121
145
  await this.stop(deployment);
122
146
  deployment.status = "failed";
123
- this.log(deployment, `app did not respond at ${deployment.appHost}:${deployment.appPort} within ${this.livenessTimeoutMs / 1000}s`);
147
+ this.log(deployment, `app admin port ${deployment.adminPort} did not answer within ${this.livenessTimeoutMs / 1000}s`);
124
148
  }
125
149
 
126
150
  private async linkWorkspacePackages(appDir : string, manifest : { dependencies? : Record<string, string> }) : Promise<void> {
@@ -174,5 +198,5 @@ abstract class BaseBuildLayer implements BuildLayer {
174
198
 
175
199
  }
176
200
 
177
- export { BaseBuildLayer, httpProbe };
201
+ export { BaseBuildLayer, adminProbe };
178
202
  export type { Deployment, Probe };
@@ -12,6 +12,9 @@ interface BuildLayer {
12
12
  deploy(appName : string, appPort : number, tarball : Buffer) : DeploymentSummary;
13
13
  status(appName : string) : (DeploymentSummary & { log : Array<string> }) | undefined;
14
14
  list() : Array<DeploymentSummary>;
15
+ ping(appName : string) : Promise<boolean>;
16
+ logs(appName : string, signal : AbortSignal) : AsyncIterable<string>;
17
+ teardown(appName : string) : Promise<boolean>;
15
18
  cleanUp() : Promise<void>;
16
19
 
17
20
  }
@@ -2,11 +2,19 @@ import {spawn} from "node:child_process";
2
2
  import {writeFile} from "node:fs/promises";
3
3
  import {join} from "node:path";
4
4
  import {BaseBuildLayer, Deployment, Probe} from "./BaseBuildLayer";
5
+ import {childLines} from "./childLines";
6
+
7
+ type LogStreamer = (container : string, signal : AbortSignal) => AsyncIterable<string>;
8
+
9
+ const dockerLogStreamer : LogStreamer = (container, signal) =>
10
+ childLines(spawn("docker", ["logs", "--follow", "--tail", "50", container]), signal);
5
11
 
6
12
  type DockerBuildLayerOptions = {
7
13
  baseImage : string,
8
14
  network : string,
9
15
  platformUrl : string,
16
+ sqlDatabaseUrl? : string,
17
+ sqlSchema? : string,
10
18
  };
11
19
 
12
20
  type CommandRunner = (command : string, args : Array<string>, onOutput : (line : string) => void) => Promise<void>;
@@ -33,14 +41,15 @@ const dockerfileFor = (baseImage : string, entryPoint : string) : string => `FRO
33
41
  COPY . /anbaric-app
34
42
  WORKDIR /anbaric-app
35
43
  RUN npm install --omit=dev --no-audit --no-fund
36
- CMD ["/anbaric/node_modules/.bin/tsx", "${entryPoint}"]
44
+ CMD ["/anbaric/node_modules/.bin/tsx", "/anbaric/node_modules/anbaric-cloud-hosting/src/app-admin/launch.ts", "${entryPoint}"]
37
45
  `;
38
46
 
39
47
  class DockerBuildLayer extends BaseBuildLayer {
40
48
 
41
49
  constructor(appsDir : string, private options : DockerBuildLayerOptions,
42
50
  consumerPortBase? : number,
43
- private runCommand : CommandRunner = spawnRunner, probe? : Probe) {
51
+ private runCommand : CommandRunner = spawnRunner, probe? : Probe,
52
+ private logStreamer : LogStreamer = dockerLogStreamer) {
44
53
  super(appsDir, consumerPortBase, probe);
45
54
  }
46
55
 
@@ -48,6 +57,10 @@ class DockerBuildLayer extends BaseBuildLayer {
48
57
  return `anbaric-app-${appName}`;
49
58
  }
50
59
 
60
+ protected streamLogs(deployment : Deployment, signal : AbortSignal) : AsyncIterable<string> {
61
+ return this.logStreamer(this.appHostFor(deployment.appName), signal);
62
+ }
63
+
51
64
  protected async start(deployment : Deployment, appDir : string, entryPoint : string) : Promise<void> {
52
65
  const image = this.appHostFor(deployment.appName);
53
66
  const container = this.appHostFor(deployment.appName);
@@ -58,10 +71,17 @@ class DockerBuildLayer extends BaseBuildLayer {
58
71
 
59
72
  await this.removeContainer(deployment, container);
60
73
 
74
+ const sqlEnv = this.options.sqlDatabaseUrl ? [
75
+ "--env", "ANBARIC_SQL_STORE_TYPE=cloud",
76
+ "--env", `ANBARIC_SQL_DATABASE_URL=${this.options.sqlDatabaseUrl}`,
77
+ "--env", `ANBARIC_SQL_SCHEMA=${this.options.sqlSchema ?? "anbaric_app_data"}`,
78
+ ] : [];
79
+
61
80
  this.log(deployment, `starting container ${container}`);
62
81
  await this.docker(deployment, ["run", "--detach", "--name", container,
63
82
  "--network", this.options.network,
64
83
  "--env", `PORT=${deployment.appPort}`,
84
+ "--env", `ANBARIC_ADMIN_PORT=${deployment.adminPort}`,
65
85
  "--env", `ANBARIC_CLOUD_URL=${this.options.platformUrl}`,
66
86
  "--env", "ANBARIC_JOB_PERSISTENCE_TYPE=cloud",
67
87
  "--env", "ANBARIC_QUEUE_TYPE=cloud",
@@ -70,6 +90,7 @@ class DockerBuildLayer extends BaseBuildLayer {
70
90
  "--env", "ANBARIC_AUDITOR_TYPE=cloud",
71
91
  "--env", `ANBARIC_CONSUMER_PORT=${deployment.consumerPort}`,
72
92
  "--env", `ANBARIC_CONSUMER_URL=http://${container}:${deployment.consumerPort}`,
93
+ ...sqlEnv,
73
94
  image]);
74
95
 
75
96
  void this.docker(deployment, ["logs", "--follow", container]).catch(() => {});
@@ -90,4 +111,4 @@ class DockerBuildLayer extends BaseBuildLayer {
90
111
  }
91
112
 
92
113
  export { DockerBuildLayer, dockerfileFor };
93
- export type { CommandRunner, DockerBuildLayerOptions };
114
+ export type { CommandRunner, DockerBuildLayerOptions, LogStreamer };
@@ -6,6 +6,7 @@ import {ECSClient, CreateServiceCommand, DeleteServiceCommand, DescribeServicesC
6
6
  import {S3Client, PutObjectCommand} from "@aws-sdk/client-s3";
7
7
  import {ServiceDiscoveryClient, CreateServiceCommand as CreateDiscoveryServiceCommand,
8
8
  ListServicesCommand} from "@aws-sdk/client-servicediscovery";
9
+ import {CloudWatchLogsClient, StartLiveTailCommand} from "@aws-sdk/client-cloudwatch-logs";
9
10
  import {BaseBuildLayer, Deployment, Probe} from "./BaseBuildLayer";
10
11
  import {dockerfileFor} from "./DockerBuildLayer";
11
12
 
@@ -22,9 +23,12 @@ type FargateBuildLayerOptions = {
22
23
  baseImage : string,
23
24
  appExecutionRoleArn : string,
24
25
  appsLogGroup : string,
26
+ appsLogGroupArn : string,
25
27
  platformUrl : string,
26
28
  servicesUrl? : string,
27
29
  servicesApiKey? : string,
30
+ sqlDatabaseUrlSecretArn? : string,
31
+ sqlSchema? : string,
28
32
  };
29
33
 
30
34
  type AwsClients = {
@@ -32,6 +36,7 @@ type AwsClients = {
32
36
  codeBuild : { send(command : any) : Promise<any> },
33
37
  ecs : { send(command : any) : Promise<any> },
34
38
  serviceDiscovery : { send(command : any) : Promise<any> },
39
+ cloudWatchLogs : { send(command : any, options? : any) : Promise<any> },
35
40
  };
36
41
 
37
42
  const defaultClients = (region : string) : AwsClients => ({
@@ -39,6 +44,7 @@ const defaultClients = (region : string) : AwsClients => ({
39
44
  codeBuild: new CodeBuildClient({ region }),
40
45
  ecs: new ECSClient({ region }),
41
46
  serviceDiscovery: new ServiceDiscoveryClient({ region }),
47
+ cloudWatchLogs: new CloudWatchLogsClient({ region }),
42
48
  });
43
49
 
44
50
  const BUILD_TIMEOUT_MS = 900_000;
@@ -60,6 +66,23 @@ class FargateBuildLayer extends BaseBuildLayer {
60
66
  return `${appName}.${this.options.namespaceName}`;
61
67
  }
62
68
 
69
+ protected async *streamLogs(deployment : Deployment, signal : AbortSignal) : AsyncIterable<string> {
70
+ const response = await this.aws.cloudWatchLogs.send(new StartLiveTailCommand({
71
+ logGroupIdentifiers: [this.options.appsLogGroupArn],
72
+ logStreamNamePrefixes: [deployment.appName],
73
+ }), { abortSignal: signal });
74
+
75
+ try {
76
+ for await (const event of response.responseStream) {
77
+ for (const result of event.sessionUpdate?.sessionResults ?? []) {
78
+ if (result.message !== undefined) yield result.message;
79
+ }
80
+ }
81
+ } catch (error) {
82
+ if (!signal.aborted) throw error;
83
+ }
84
+ }
85
+
63
86
  protected async start(deployment : Deployment, appDir : string, entryPoint : string) : Promise<void> {
64
87
  const imageUri = `${this.options.appsRepositoryUrl}:${deployment.appName}`;
65
88
 
@@ -141,10 +164,12 @@ class FargateBuildLayer extends BaseBuildLayer {
141
164
  essential: true,
142
165
  portMappings: [
143
166
  { containerPort: deployment.appPort, protocol: "tcp" },
167
+ { containerPort: deployment.adminPort, protocol: "tcp" },
144
168
  { containerPort: deployment.consumerPort, protocol: "tcp" },
145
169
  ],
146
170
  environment: [
147
171
  { name: "PORT", value: String(deployment.appPort) },
172
+ { name: "ANBARIC_ADMIN_PORT", value: String(deployment.adminPort) },
148
173
  { name: "ANBARIC_CLOUD_URL", value: this.options.platformUrl },
149
174
  { name: "ANBARIC_JOB_PERSISTENCE_TYPE", value: "cloud" },
150
175
  { name: "ANBARIC_QUEUE_TYPE", value: "cloud" },
@@ -155,7 +180,14 @@ class FargateBuildLayer extends BaseBuildLayer {
155
180
  { name: "ANBARIC_CONSUMER_URL", value: `http://${deployment.appHost}:${deployment.consumerPort}` },
156
181
  ...(this.options.servicesUrl ? [{ name: "ANBARIC_SERVICES_URL", value: this.options.servicesUrl }] : []),
157
182
  ...(this.options.servicesApiKey ? [{ name: "ANBARIC_SERVICES_API_KEY", value: this.options.servicesApiKey }] : []),
183
+ ...(this.options.sqlDatabaseUrlSecretArn ? [
184
+ { name: "ANBARIC_SQL_STORE_TYPE", value: "cloud" },
185
+ { name: "ANBARIC_SQL_SCHEMA", value: this.options.sqlSchema ?? "anbaric_app_data" },
186
+ ] : []),
158
187
  ],
188
+ secrets: this.options.sqlDatabaseUrlSecretArn
189
+ ? [{ name: "ANBARIC_SQL_DATABASE_URL", valueFrom: this.options.sqlDatabaseUrlSecretArn }]
190
+ : undefined,
159
191
  logConfiguration: {
160
192
  logDriver: "awslogs",
161
193
  options: {
@@ -0,0 +1,42 @@
1
+ import {ChildProcessWithoutNullStreams} from "node:child_process";
2
+
3
+ /* Yields the child's stdout and stderr merged, one line at a time, until the
4
+ process ends or the signal aborts (which kills it). Backs the docker-logs
5
+ follow stream. */
6
+ async function* childLines(child : ChildProcessWithoutNullStreams, signal : AbortSignal) : AsyncGenerator<string> {
7
+ const queue : Array<string> = [];
8
+ let wake : (() => void) | undefined;
9
+ let ended = false;
10
+
11
+ const push = (line : string) => { queue.push(line); wake?.(); wake = undefined; };
12
+ const finish = () => { ended = true; wake?.(); wake = undefined; };
13
+
14
+ for (const stream of [child.stdout, child.stderr]) {
15
+ let buffer = "";
16
+ stream.setEncoding("utf8");
17
+ stream.on("data", chunk => {
18
+ buffer += chunk;
19
+ let newline : number;
20
+ while ((newline = buffer.indexOf("\n")) >= 0) {
21
+ push(buffer.slice(0, newline));
22
+ buffer = buffer.slice(newline + 1);
23
+ }
24
+ });
25
+ }
26
+
27
+ child.on("close", finish);
28
+ child.on("error", finish);
29
+ const abort = () => { child.kill(); finish(); };
30
+ signal.addEventListener("abort", abort);
31
+
32
+ try {
33
+ while (!ended || queue.length > 0) {
34
+ if (queue.length > 0) yield queue.shift()!;
35
+ else await new Promise<void>(resolve => { wake = resolve; });
36
+ }
37
+ } finally {
38
+ signal.removeEventListener("abort", abort);
39
+ }
40
+ }
41
+
42
+ export { childLines };
@@ -1,10 +1,10 @@
1
- import {AuditInteraction, AuditRecord} from "anbaric-tsapi";
1
+ import {AuditRecord} from "anbaric-tsapi";
2
2
 
3
3
  type AuditFilter = {
4
4
  resourceType? : string,
5
5
  resourceId? : string,
6
6
  actorId? : string,
7
- interaction? : AuditInteraction,
7
+ interaction? : string,
8
8
  search? : string,
9
9
  pageSize? : number,
10
10
  page? : number,
@@ -1,10 +1,10 @@
1
- import {AuditInteraction, AuditRecord} from "anbaric-tsapi";
1
+ import {AuditRecord} from "anbaric-tsapi";
2
2
  import {Pool} from "pg";
3
3
  import {AuditFilter, AuditRecordStore} from "../auditing/AuditRecordStore";
4
4
 
5
- const DEFAULT_WRITE_MASK : Array<AuditInteraction> = [
6
- AuditInteraction.CREATE, AuditInteraction.UPDATE_PROPERTIES, AuditInteraction.CHANGE_STATE, AuditInteraction.DELETE,
7
- ];
5
+ /* The writes worth keeping in the durable trail; reads (READ, LIST, QUERY) are
6
+ dropped by default. Interactions are plain strings chosen by each store. */
7
+ const DEFAULT_WRITE_MASK : Array<string> = ["CREATE", "SAVE", "UPDATE_PROPERTIES", "CHANGE_STATE", "DELETE", "EXECUTE"];
8
8
 
9
9
  /* Persists audit records, subject to a write mask: only the interactions in
10
10
  the mask are stored, so high-volume reads can be audited at the call site
@@ -12,16 +12,16 @@ const DEFAULT_WRITE_MASK : Array<AuditInteraction> = [
12
12
  overridable via ANBARIC_AUDIT_INTERACTIONS. */
13
13
  class PostgresAuditRecordStore implements AuditRecordStore {
14
14
 
15
- private writeMask : Set<AuditInteraction>;
15
+ private writeMask : Set<string>;
16
16
 
17
- constructor(private pool : Pool, writeMask : Set<AuditInteraction> = PostgresAuditRecordStore.maskFromEnvironment()) {
17
+ constructor(private pool : Pool, writeMask : Set<string> = PostgresAuditRecordStore.maskFromEnvironment()) {
18
18
  this.writeMask = writeMask;
19
19
  }
20
20
 
21
- static maskFromEnvironment() : Set<AuditInteraction> {
21
+ static maskFromEnvironment() : Set<string> {
22
22
  const configured = process.env.ANBARIC_AUDIT_INTERACTIONS;
23
23
  if (!configured) return new Set(DEFAULT_WRITE_MASK);
24
- return new Set(configured.split(",").map(entry => entry.trim()).filter(Boolean) as Array<AuditInteraction>);
24
+ return new Set(configured.split(",").map(entry => entry.trim()).filter(Boolean) );
25
25
  }
26
26
 
27
27
  async save(record : AuditRecord) : Promise<void> {
@@ -30,7 +30,7 @@ class PostgresAuditRecordStore implements AuditRecordStore {
30
30
  await this.pool.query(
31
31
  `INSERT INTO anbaric_system.audit_records
32
32
  (resource_type, resource_id, actor_id, actor_type, interaction, description, details)
33
- VALUES ($1, $2, $3, $4, $5::anbaric_system.audit_interaction[], $6, $7)`,
33
+ VALUES ($1, $2, $3, $4, $5::text[], $6, $7)`,
34
34
  [record.resourceType, record.resourceId, record.actorId, record.actorType, record.interaction,
35
35
  record.description, JSON.stringify(record.details ?? null)],
36
36
  );
@@ -54,7 +54,7 @@ class PostgresAuditRecordStore implements AuditRecordStore {
54
54
  }
55
55
  if (filter.interaction) {
56
56
  parameters.push(filter.interaction);
57
- conditions.push(`$${parameters.length}::anbaric_system.audit_interaction = ANY(interaction)`);
57
+ conditions.push(`$${parameters.length}::text = ANY(interaction)`);
58
58
  }
59
59
  if (filter.search) {
60
60
  parameters.push(`%${filter.search}%`);
@@ -66,7 +66,7 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
66
66
  resource_id TEXT NOT NULL,
67
67
  actor_id TEXT NOT NULL,
68
68
  actor_type TEXT NOT NULL,
69
- interaction anbaric_system.audit_interaction[] NOT NULL,
69
+ interaction TEXT[] NOT NULL,
70
70
  description TEXT NOT NULL,
71
71
  details JSONB,
72
72
  at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -74,7 +74,7 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
74
74
  `);
75
75
  await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS resource_type TEXT");
76
76
  await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS resource_id TEXT");
77
- await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS interaction anbaric_system.audit_interaction[]");
77
+ await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS interaction TEXT[]");
78
78
  await pool.query(`
79
79
  DO $$ BEGIN
80
80
  IF EXISTS (SELECT 1 FROM information_schema.columns
@@ -100,13 +100,46 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
100
100
  END IF;
101
101
  END $$
102
102
  `);
103
+ await pool.query(`
104
+ DO $$ BEGIN
105
+ IF EXISTS (SELECT 1 FROM information_schema.columns
106
+ WHERE table_schema = 'anbaric_system' AND table_name = 'audit_records'
107
+ AND column_name = 'interaction' AND udt_name = '_audit_interaction') THEN
108
+ ALTER TABLE anbaric_system.audit_records
109
+ ALTER COLUMN interaction TYPE TEXT[] USING interaction::text[];
110
+ END IF;
111
+ END $$
112
+ `);
103
113
  await pool.query("UPDATE anbaric_system.audit_records SET resource_type = 'job' WHERE resource_type IS NULL");
104
114
  await pool.query("UPDATE anbaric_system.audit_records SET resource_id = 'unknown' WHERE resource_id IS NULL");
105
- await pool.query("UPDATE anbaric_system.audit_records SET interaction = ARRAY['UPDATE_PROPERTIES']::anbaric_system.audit_interaction[] WHERE interaction IS NULL");
115
+ await pool.query("UPDATE anbaric_system.audit_records SET interaction = ARRAY['UPDATE_PROPERTIES']::text[] WHERE interaction IS NULL");
106
116
  await pool.query("ALTER TABLE anbaric_system.audit_records ALTER COLUMN resource_type SET NOT NULL");
107
117
  await pool.query("ALTER TABLE anbaric_system.audit_records ALTER COLUMN resource_id SET NOT NULL");
108
118
  await pool.query("ALTER TABLE anbaric_system.audit_records ALTER COLUMN interaction SET NOT NULL");
109
119
  await pool.query("CREATE INDEX IF NOT EXISTS audit_records_resource ON anbaric_system.audit_records (resource_type, resource_id)");
120
+
121
+ // The shared SQL store for apps: a dedicated schema plus a login role scoped
122
+ // to it, so deployed apps reach only anbaric_app_data - never the platform's
123
+ // public (jobs/documents/queue) or anbaric_system schemas.
124
+ const appSqlSchema = process.env.ANBARIC_SQL_SCHEMA ?? "anbaric_app_data";
125
+ if (!/^[a-z_][a-z0-9_]*$/i.test(appSqlSchema)) throw new Error(`Invalid ANBARIC_SQL_SCHEMA "${appSqlSchema}"`);
126
+ await pool.query(`CREATE SCHEMA IF NOT EXISTS ${appSqlSchema}`);
127
+
128
+ const appDbPassword = process.env.ANBARIC_APP_DB_PASSWORD;
129
+ if (appDbPassword) {
130
+ await pool.query(`DO $$ BEGIN
131
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anbaric_app') THEN CREATE ROLE anbaric_app LOGIN; END IF;
132
+ END $$`);
133
+ await pool.query(`ALTER ROLE anbaric_app LOGIN PASSWORD '${appDbPassword.replace(/'/g, "''")}'`);
134
+ await pool.query(`ALTER ROLE anbaric_app SET search_path TO ${appSqlSchema}`);
135
+ await pool.query(`GRANT USAGE, CREATE ON SCHEMA ${appSqlSchema} TO anbaric_app`);
136
+ await pool.query(`GRANT ALL ON ALL TABLES IN SCHEMA ${appSqlSchema} TO anbaric_app`);
137
+ await pool.query(`GRANT ALL ON ALL SEQUENCES IN SCHEMA ${appSqlSchema} TO anbaric_app`);
138
+ await pool.query(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${appSqlSchema} GRANT ALL ON TABLES TO anbaric_app`);
139
+ await pool.query(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${appSqlSchema} GRANT ALL ON SEQUENCES TO anbaric_app`);
140
+ await pool.query("REVOKE ALL ON SCHEMA public FROM anbaric_app");
141
+ await pool.query("REVOKE ALL ON SCHEMA anbaric_system FROM anbaric_app");
142
+ }
110
143
  };
111
144
 
112
145
  export { ensureSchema }
@@ -11,6 +11,9 @@ class AppsHandler implements RequestHandler {
11
11
  case "deploy":
12
12
  if (request.id) return this.handleDeploy(request, request.id);
13
13
  break;
14
+ case "logs":
15
+ if (request.id) return this.handleLogs(request, request.id);
16
+ break;
14
17
  case undefined:
15
18
  if (request.id) return this.handleApp(request, request.id);
16
19
  return this.handleCollection(request);
@@ -38,12 +41,58 @@ class AppsHandler implements RequestHandler {
38
41
  case "GET": {
39
42
  const status = this.buildLayer.status(appName);
40
43
  if (!status) return request.reply(404, { error: `No app named "${appName}"` });
41
- return request.reply(200, status);
44
+ const live = await this.buildLayer.ping(appName);
45
+ return request.reply(200, { ...status, live });
46
+ }
47
+ case "DELETE": {
48
+ const torn = await this.buildLayer.teardown(appName);
49
+ if (!torn) return request.reply(404, { error: `No app named "${appName}"` });
50
+ return request.reply(200, { appName, status: "stopped" });
42
51
  }
43
52
  }
44
53
  request.notFound();
45
54
  }
46
55
 
56
+ private async handleLogs(request : Request, appName : string) : Promise<void> {
57
+ if (request.method !== "GET") return request.notFound();
58
+ if (!this.buildLayer.status(appName)) return request.reply(404, { error: `No app named "${appName}"` });
59
+
60
+ const response = request.rawResponse;
61
+ response.writeHead(200, {
62
+ "content-type": "text/plain; charset=utf-8",
63
+ "cache-control": "no-cache",
64
+ "x-content-type-options": "nosniff",
65
+ });
66
+
67
+ const controller = new AbortController();
68
+ request.raw.on("close", () => controller.abort());
69
+
70
+ // A newline heartbeat after each idle interval keeps the streamed
71
+ // connection alive through the edge (CloudFront/ALB) read timeouts when
72
+ // the app is producing no output.
73
+ const beat = () : ReturnType<typeof setTimeout> => setTimeout(() => {
74
+ if (!response.writableEnded) { response.write("\n"); heartbeat = beat(); }
75
+ }, 15_000);
76
+ let heartbeat = beat();
77
+
78
+ try {
79
+ for await (const line of this.buildLayer.logs(appName, controller.signal)) {
80
+ clearTimeout(heartbeat);
81
+ if (!response.write(`${line}\n`)) {
82
+ await new Promise<void>(resolve => response.once("drain", resolve));
83
+ }
84
+ heartbeat = beat();
85
+ }
86
+ } catch (error) {
87
+ if (!controller.signal.aborted) {
88
+ response.write(`[log stream error: ${error instanceof Error ? error.message : error}]\n`);
89
+ }
90
+ } finally {
91
+ clearTimeout(heartbeat);
92
+ response.end();
93
+ }
94
+ }
95
+
47
96
  private async handleCollection(request : Request) : Promise<void> {
48
97
  switch (request.method) {
49
98
  case "GET":
@@ -1,10 +1,7 @@
1
- import {AuditInteraction} from "anbaric-tsapi";
2
1
  import {AuditRecordStore} from "../../auditing/AuditRecordStore";
3
2
  import {Request} from "../Request";
4
3
  import {RequestHandler} from "../RequestHandler";
5
4
 
6
- const AUDIT_INTERACTIONS = new Set(["CREATE", "UPDATE_PROPERTIES", "CHANGE_STATE", "DELETE", "READ", "LIST"]);
7
-
8
5
  class AuditsHandler implements RequestHandler {
9
6
 
10
7
  constructor(private auditRecords : AuditRecordStore) {}
@@ -27,7 +24,7 @@ class AuditsHandler implements RequestHandler {
27
24
  || typeof record?.description !== "string" || typeof record?.actorId !== "string"
28
25
  || typeof record?.actorType !== "string"
29
26
  || !Array.isArray(record?.interaction) || record.interaction.length === 0
30
- || !record.interaction.every((interaction : unknown) => typeof interaction === "string" && AUDIT_INTERACTIONS.has(interaction))) {
27
+ || !record.interaction.every((interaction : unknown) => typeof interaction === "string" && interaction.length > 0)) {
31
28
  return request.reply(400, { error: "Expected a body of { resourceType, resourceId, actorId, actorType, interaction: [...], description, ... }" });
32
29
  }
33
30
  await this.auditRecords.save(record);
@@ -40,7 +37,7 @@ class AuditsHandler implements RequestHandler {
40
37
  resourceType: request.query("resourceType"),
41
38
  resourceId: request.query("resourceId"),
42
39
  actorId: request.query("actorId"),
43
- interaction: interaction && AUDIT_INTERACTIONS.has(interaction) ? interaction as AuditInteraction : undefined,
40
+ interaction: interaction || undefined,
44
41
  search: request.query("search"),
45
42
  pageSize: request.query("pageSize") === undefined ? undefined : Number(request.query("pageSize")),
46
43
  page: request.query("page") === undefined ? undefined : Number(request.query("page")),
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ export * from "./auth/TokenAuthenticator";
17
17
  export * from "./auth/User";
18
18
  export * from "./app-management/DockerBuildLayer";
19
19
  export * from "./app-management/FargateBuildLayer";
20
+ export * from "./app-admin/AdminServer";
21
+ export * from "./app-admin/adminPing";
20
22
  export * from "./data-store/PostgresCliKeyStore";
21
23
  export * from "./data-store/PostgresJobPersistence";
22
24
  export * from "./data-store/PostgresJsonStore";
package/src/main.ts CHANGED
@@ -35,6 +35,8 @@ const buildLayer = process.env.ANBARIC_BUILD_LAYER === "docker"
35
35
  baseImage: process.env.ANBARIC_APP_BASE_IMAGE ?? "anbaric-v2-platform:local",
36
36
  network: process.env.ANBARIC_DOCKER_NETWORK ?? "anbaric-v2-local",
37
37
  platformUrl: process.env.ANBARIC_PLATFORM_INTERNAL_URL ?? `http://localhost:${internalPort}`,
38
+ sqlDatabaseUrl: process.env.ANBARIC_APP_SQL_DATABASE_URL,
39
+ sqlSchema: process.env.ANBARIC_SQL_SCHEMA,
38
40
  })
39
41
  : process.env.ANBARIC_BUILD_LAYER === "fargate"
40
42
  ? new FargateBuildLayer(appsDir, {
@@ -50,9 +52,12 @@ const buildLayer = process.env.ANBARIC_BUILD_LAYER === "docker"
50
52
  baseImage: process.env.ANBARIC_AWS_BASE_IMAGE!,
51
53
  appExecutionRoleArn: process.env.ANBARIC_AWS_APP_EXECUTION_ROLE!,
52
54
  appsLogGroup: process.env.ANBARIC_AWS_APPS_LOG_GROUP!,
55
+ appsLogGroupArn: process.env.ANBARIC_AWS_APPS_LOG_GROUP_ARN!,
53
56
  platformUrl: process.env.ANBARIC_PLATFORM_INTERNAL_URL!,
54
57
  servicesUrl: process.env.ANBARIC_SERVICES_URL,
55
58
  servicesApiKey: process.env.ANBARIC_SERVICES_API_KEY,
59
+ sqlDatabaseUrlSecretArn: process.env.ANBARIC_AWS_APP_SQL_URL_SECRET,
60
+ sqlSchema: process.env.ANBARIC_SQL_SCHEMA,
56
61
  })
57
62
  : undefined;
58
63