anbaric-cloud-hosting 1.8.1 → 1.10.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 +7 -6
- package/src/app-admin/AdminServer.ts +64 -0
- package/src/app-admin/adminPing.ts +21 -0
- package/src/app-admin/launch.ts +38 -0
- package/src/app-management/BaseBuildLayer.ts +37 -13
- package/src/app-management/BuildLayer.ts +3 -0
- package/src/app-management/DockerBuildLayer.ts +24 -3
- package/src/app-management/FargateBuildLayer.ts +32 -0
- package/src/app-management/childLines.ts +42 -0
- package/src/auditing/AuditRecordStore.ts +2 -2
- package/src/data-store/PostgresAuditRecordStore.ts +10 -10
- package/src/data-store/Schema.ts +36 -3
- package/src/hosting/handlers/AppsHandler.ts +50 -1
- package/src/hosting/handlers/AuditsHandler.ts +2 -5
- package/src/hosting/handlers/QueueHandler.ts +2 -2
- package/src/index.ts +2 -0
- package/src/main.ts +5 -0
- package/src/queuing/PostgresQueue.ts +3 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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.
|
|
21
|
-
"anbaric-plugins": "^1.
|
|
22
|
-
"anbaric-tsapi": "^1.
|
|
21
|
+
"anbaric-data-store": "^1.10.0",
|
|
22
|
+
"anbaric-plugins": "^1.10.0",
|
|
23
|
+
"anbaric-tsapi": "^1.10.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.
|
|
30
|
-
"anbaric-state-machine": "^1.
|
|
30
|
+
"anbaric-impl-cloud": "^1.10.0",
|
|
31
|
+
"anbaric-state-machine": "^1.10.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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 =
|
|
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.
|
|
137
|
+
if (await this.probe(deployment.appHost, deployment.adminPort)) {
|
|
114
138
|
deployment.status = "running";
|
|
115
|
-
this.log(deployment, `app is live
|
|
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
|
|
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,
|
|
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 {
|
|
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? :
|
|
7
|
+
interaction? : string,
|
|
8
8
|
search? : string,
|
|
9
9
|
pageSize? : number,
|
|
10
10
|
page? : number,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {AuditRecord} from "anbaric-tsapi";
|
|
2
2
|
import {Pool} from "pg";
|
|
3
3
|
import {AuditFilter, AuditRecordStore} from "../auditing/AuditRecordStore";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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<
|
|
15
|
+
private writeMask : Set<string>;
|
|
16
16
|
|
|
17
|
-
constructor(private pool : Pool, writeMask : Set<
|
|
17
|
+
constructor(private pool : Pool, writeMask : Set<string> = PostgresAuditRecordStore.maskFromEnvironment()) {
|
|
18
18
|
this.writeMask = writeMask;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
static maskFromEnvironment() : Set<
|
|
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)
|
|
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::
|
|
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}::
|
|
57
|
+
conditions.push(`$${parameters.length}::text = ANY(interaction)`);
|
|
58
58
|
}
|
|
59
59
|
if (filter.search) {
|
|
60
60
|
parameters.push(`%${filter.search}%`);
|
package/src/data-store/Schema.ts
CHANGED
|
@@ -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
|
|
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
|
|
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']::
|
|
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
|
-
|
|
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" &&
|
|
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
|
|
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")),
|
|
@@ -23,8 +23,8 @@ class QueueHandler implements RequestHandler {
|
|
|
23
23
|
case "dequeue":
|
|
24
24
|
return request.reply(200, { messages: await this.queue.dequeueSome() });
|
|
25
25
|
case "confirm": {
|
|
26
|
-
const { jobId, workflowId } = await request.body();
|
|
27
|
-
await this.queue.confirm({ jobId, workflowId });
|
|
26
|
+
const { jobId, workflowId, position } = await request.body();
|
|
27
|
+
await this.queue.confirm({ jobId, workflowId, position });
|
|
28
28
|
return request.reply(204);
|
|
29
29
|
}
|
|
30
30
|
}
|
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
|
|
|
@@ -39,14 +39,12 @@ class PostgresQueue implements ConfirmableQueue {
|
|
|
39
39
|
if (!b.due) return 1;
|
|
40
40
|
return a.due.getTime() - b.due.getTime() || a.position - b.position;
|
|
41
41
|
})
|
|
42
|
-
.map(row => ({ jobId: row.job_id, workflowId: row.workflow_id }));
|
|
42
|
+
.map(row => ({ jobId: row.job_id, workflowId: row.workflow_id, position: Number(row.position) }));
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
async confirm(message : QueueMessage) : Promise<void> {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
[message.jobId, message.workflowId],
|
|
49
|
-
);
|
|
46
|
+
if (message.position === undefined) return;
|
|
47
|
+
await this.pool.query("DELETE FROM queue WHERE position = $1", [message.position]);
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
}
|