anbaric-cloud-hosting 1.25.0 → 1.26.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.25.0",
3
+ "version": "1.26.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",
@@ -18,18 +18,18 @@
18
18
  "@aws-sdk/client-s3": "^3.1110.0",
19
19
  "@aws-sdk/client-secrets-manager": "^3.700.0",
20
20
  "@aws-sdk/client-servicediscovery": "^3.1110.0",
21
- "anbaric-data-store": "^1.25.0",
22
- "anbaric-plugins": "^1.25.0",
23
- "anbaric-tsapi": "^1.25.0",
24
- "anbaric-web": "^1.25.0",
21
+ "anbaric-data-store": "^1.26.0",
22
+ "anbaric-plugins": "^1.26.0",
23
+ "anbaric-tsapi": "^1.26.0",
24
+ "anbaric-web": "^1.26.0",
25
25
  "esbuild": "^0.28.2",
26
26
  "pg": "^8.16.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.2.0",
30
30
  "@types/pg": "^8.15.0",
31
- "anbaric-impl-cloud": "^1.25.0",
32
- "anbaric-state-machine": "^1.25.0",
31
+ "anbaric-impl-cloud": "^1.26.0",
32
+ "anbaric-state-machine": "^1.26.0",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -155,9 +155,27 @@ abstract class BaseBuildLayer implements BuildLayer {
155
155
  await new Promise(resolve => setTimeout(resolve, LIVENESS_PROBE_INTERVAL_MS));
156
156
  }
157
157
 
158
+ // The admin port never answering almost always means the app process
159
+ // crashed on boot, so pull whatever it logged and put the real cause in
160
+ // the deploy result - not just the timeout, which says nothing.
161
+ const diagnostics = await this.diagnostics(deployment).catch(() => []);
162
+
158
163
  await this.stop(deployment);
159
164
  deployment.status = "failed";
160
165
  this.log(deployment, `app admin port ${deployment.adminPort} did not answer within ${this.livenessTimeoutMs / 1000}s`);
166
+ if (diagnostics.length > 0) {
167
+ this.log(deployment, "the app's last output before it stopped:");
168
+ for (const line of diagnostics) this.log(deployment, ` ${line}`);
169
+ } else {
170
+ this.log(deployment, "no output was captured - the app may have failed before logging, or is still starting");
171
+ }
172
+ }
173
+
174
+ // The app's recent output, for diagnosing a boot that never became live.
175
+ // Each build layer reads it from where its apps log (container logs,
176
+ // CloudWatch); the default has nothing to offer.
177
+ protected async diagnostics(_deployment : Deployment) : Promise<Array<string>> {
178
+ return [];
161
179
  }
162
180
 
163
181
  private async linkWorkspacePackages(appDir : string, manifest : { dependencies? : Record<string, string> }) : Promise<void> {
@@ -53,6 +53,20 @@ class DockerBuildLayer extends BaseBuildLayer {
53
53
  super(appsDir, consumerPortBase, probe);
54
54
  }
55
55
 
56
+ protected async diagnostics(deployment : Deployment) : Promise<Array<string>> {
57
+ return new Promise((resolve) => {
58
+ const lines : Array<string> = [];
59
+ const take = (buffer : Buffer) => {
60
+ for (const line of buffer.toString().split("\n")) if (line.trim()) lines.push(line);
61
+ };
62
+ const logs = spawn("docker", ["logs", "--tail", "30", this.appHostFor(deployment.appName)]);
63
+ logs.stdout.on("data", take);
64
+ logs.stderr.on("data", take);
65
+ logs.on("close", () => resolve(lines.slice(-30)));
66
+ logs.on("error", () => resolve([]));
67
+ });
68
+ }
69
+
56
70
  protected appHostFor(appName : string) : string {
57
71
  return `anbaric-app-${appName}`;
58
72
  }
@@ -90,6 +104,7 @@ class DockerBuildLayer extends BaseBuildLayer {
90
104
  "--env", "ANBARIC_SECRET_STORE_TYPE=cloud",
91
105
  "--env", "ANBARIC_AUDITOR_TYPE=cloud",
92
106
  "--env", "ANBARIC_SESSION_RESOLVER_TYPE=cloud",
107
+ "--env", "ANBARIC_NOTIFIER_TYPE=cloud",
93
108
  "--env", `ANBARIC_CONSUMER_PORT=${deployment.consumerPort}`,
94
109
  "--env", `ANBARIC_CONSUMER_URL=http://${container}:${deployment.consumerPort}`,
95
110
  ...sqlEnv,
@@ -7,7 +7,7 @@ import {ECSClient, CreateServiceCommand, DeleteServiceCommand, DescribeServicesC
7
7
  import {S3Client, PutObjectCommand} from "@aws-sdk/client-s3";
8
8
  import {ServiceDiscoveryClient, CreateServiceCommand as CreateDiscoveryServiceCommand,
9
9
  ListServicesCommand} from "@aws-sdk/client-servicediscovery";
10
- import {CloudWatchLogsClient, StartLiveTailCommand} from "@aws-sdk/client-cloudwatch-logs";
10
+ import {CloudWatchLogsClient, FilterLogEventsCommand, StartLiveTailCommand} from "@aws-sdk/client-cloudwatch-logs";
11
11
  import {BaseBuildLayer, Deployment, Probe} from "./BaseBuildLayer";
12
12
  import {dockerfileFor} from "./DockerBuildLayer";
13
13
 
@@ -63,6 +63,19 @@ class FargateBuildLayer extends BaseBuildLayer {
63
63
  this.aws = aws ?? defaultClients(options.awsRegion);
64
64
  }
65
65
 
66
+ protected async diagnostics(deployment : Deployment) : Promise<Array<string>> {
67
+ const response = await this.aws.cloudWatchLogs.send(new FilterLogEventsCommand({
68
+ logGroupName: this.options.appsLogGroup,
69
+ logStreamNamePrefix: deployment.appName,
70
+ startTime: Date.now() - 10 * 60_000,
71
+ limit: 40,
72
+ }));
73
+ return (response.events ?? [])
74
+ .map((event : any) => (event.message ?? "").trimEnd())
75
+ .filter((line : string) => line.length > 0)
76
+ .slice(-30);
77
+ }
78
+
66
79
  protected appHostFor(appName : string) : string {
67
80
  return `${appName}.${this.options.namespaceName}`;
68
81
  }
@@ -179,6 +192,7 @@ class FargateBuildLayer extends BaseBuildLayer {
179
192
  { name: "ANBARIC_SECRET_STORE_TYPE", value: "cloud" },
180
193
  { name: "ANBARIC_AUDITOR_TYPE", value: "cloud" },
181
194
  { name: "ANBARIC_SESSION_RESOLVER_TYPE", value: "cloud" },
195
+ { name: "ANBARIC_NOTIFIER_TYPE", value: "cloud" },
182
196
  { name: "ANBARIC_CONSUMER_PORT", value: String(deployment.consumerPort) },
183
197
  { name: "ANBARIC_CONSUMER_URL", value: `http://${deployment.appHost}:${deployment.consumerPort}` },
184
198
  ...(this.options.servicesUrl ? [{ name: "ANBARIC_SERVICES_URL", value: this.options.servicesUrl }] : []),
@@ -1,4 +1,4 @@
1
- import {JobPersistence, JobRunSchedulePersistence, JsonStore, SecretStore} from "anbaric-tsapi";
1
+ import {JobPersistence, JobRunSchedulePersistence, JsonStore, Notifier, SecretStore} from "anbaric-tsapi";
2
2
  import {BuildLayer} from "../app-management/BuildLayer";
3
3
  import {AuditRecordStore} from "../auditing/AuditRecordStore";
4
4
  import {Authenticator} from "../auth/Authenticator";
@@ -19,6 +19,7 @@ import {DocumentsHandler} from "./handlers/DocumentsHandler";
19
19
  import {JobsHandler} from "./handlers/JobsHandler";
20
20
  import {FaviconHandler} from "./handlers/FaviconHandler";
21
21
  import {JobRunSchedulesHandler} from "./handlers/JobRunSchedulesHandler";
22
+ import {NotificationsHandler} from "./handlers/NotificationsHandler";
22
23
  import {LogoutHandler} from "./handlers/LogoutHandler";
23
24
  import {PagesHandler} from "./handlers/PagesHandler";
24
25
  import {PingHandler} from "./handlers/PingHandler";
@@ -56,7 +57,8 @@ class HostingServer {
56
57
  tenant? : string,
57
58
  auditRecords? : AuditRecordStore,
58
59
  plugins : Array<LoadedPlugin> = [],
59
- jobRunSchedules? : JobRunSchedulePersistence) {
60
+ jobRunSchedules? : JobRunSchedulePersistence,
61
+ notifier? : Notifier) {
60
62
  const pages = new PagesHandler();
61
63
  const ping = new PingHandler(tenant);
62
64
  const jobs = new JobsHandler(persistence);
@@ -84,6 +86,7 @@ class HostingServer {
84
86
  if (secrets) publicRouter.registerApi("secrets", secrets);
85
87
  if (audits) publicRouter.registerApi("audits", audits);
86
88
  if (jobRunSchedules) publicRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
89
+ if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
87
90
  if (cliAuthorizer) {
88
91
  publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
89
92
  publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
@@ -107,6 +110,7 @@ class HostingServer {
107
110
  if (secrets) internalRouter.registerApi("secrets", secrets);
108
111
  if (audits) internalRouter.registerApi("audits", audits);
109
112
  if (jobRunSchedules) internalRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
113
+ if (notifier) internalRouter.registerApi("notifications", new NotificationsHandler(notifier));
110
114
 
111
115
  // The favicon is requested by the browser before anyone has signed in,
112
116
  // and by deployed apps' pages, so gating it behind a session would send
@@ -0,0 +1,26 @@
1
+ import {Notifier, deserializeJob, deserializeWaitForInput} from "anbaric-tsapi";
2
+ import {Request} from "../Request";
3
+ import {RequestHandler} from "../RequestHandler";
4
+
5
+ /* Receives a deployed app's notification request and hands it to the platform's
6
+ server-side notifier. Delivery is the notifier's business - the platform just
7
+ reconstructs the job and its wait from the wire and passes them on. */
8
+ class NotificationsHandler implements RequestHandler {
9
+
10
+ constructor(private notifier : Notifier) {}
11
+
12
+ async handle(request : Request) : Promise<void> {
13
+ if (request.method !== "POST" || request.id) return request.notFound();
14
+
15
+ const { targets, job, waitingFor } = await request.body();
16
+ if (!Array.isArray(targets) || !job || !waitingFor) {
17
+ return request.reply(400, { error: "Expected { targets, job, waitingFor }" });
18
+ }
19
+
20
+ await this.notifier.notify(targets, deserializeJob(job), deserializeWaitForInput(waitingFor));
21
+ request.reply(204);
22
+ }
23
+
24
+ }
25
+
26
+ export { NotificationsHandler }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./auth/CliKeyStore";
9
9
  export * from "./auth/HttpCliKeyStore";
10
10
  export * from "./auth/InMemoryCliKeyStore";
11
11
  export * from "./auth/AuthenticatorLoader";
12
+ export * from "./notifications/NotifierLoader";
12
13
  export * from "./auth/KeyPair";
13
14
  export * from "./auth/Role";
14
15
  export * from "./auth/SessionSigner";
@@ -49,6 +50,7 @@ export * from "./plugins/PluginLoader";
49
50
  export * from "./plugins/PageDirectory";
50
51
  export * from "./hosting/handlers/QueueHandler";
51
52
  export * from "./hosting/handlers/JobRunSchedulesHandler";
53
+ export * from "./hosting/handlers/NotificationsHandler";
52
54
  export * from "./hosting/handlers/SecretsHandler";
53
55
  export * from "./hosting/handlers/StateMachinesHandler";
54
56
  export * from "./hosting/handlers/auth/AuthorizeCliHandler";
package/src/main.ts CHANGED
@@ -3,6 +3,7 @@ import {SecretStore} from "anbaric-tsapi";
3
3
  import {InMemorySecretStore} from "anbaric-data-store";
4
4
  import {Pool} from "pg";
5
5
  import {loadAuthenticator} from "./auth/AuthenticatorLoader";
6
+ import {loadNotifier} from "./notifications/NotifierLoader";
6
7
  import {CliAuthorizer} from "./auth/CliAuthorizer";
7
8
  import {HttpCliKeyStore} from "./auth/HttpCliKeyStore";
8
9
  import {TokenAuthenticator} from "./auth/TokenAuthenticator";
@@ -84,7 +85,7 @@ const plugins = await new PluginLoader().load(process.env.ANBARIC_PLUGINS ?? "an
84
85
  const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
85
86
  (appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
86
87
  tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins,
87
- new PostgresJobRunSchedulePersistence(pool));
88
+ new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE));
88
89
  const port = await server.listen(hostingPort);
89
90
  const internal = await server.listenInternal(internalPort);
90
91
 
@@ -0,0 +1,18 @@
1
+ import {Notifier} from "anbaric-tsapi";
2
+
3
+ /* Loads the server-side notifier by module name, the same way the authenticator
4
+ is loaded (see AuthenticatorLoader). The platform is open source and cannot
5
+ name a proprietary delivery mechanism, so a host names its module through
6
+ ANBARIC_NOTIFIER_MODULE and that module exports a createNotifier factory.
7
+ With nothing named, the platform accepts notifications but delivers nothing. */
8
+ const loadNotifier = async (moduleName? : string) : Promise<Notifier | undefined> => {
9
+ if (!moduleName) return undefined;
10
+
11
+ const module = await import(moduleName);
12
+ if (typeof module.createNotifier !== "function") {
13
+ throw new Error(`Notifier module "${moduleName}" does not export a createNotifier function`);
14
+ }
15
+ return module.createNotifier();
16
+ };
17
+
18
+ export { loadNotifier }