anbaric-cloud-hosting 1.61.0 → 1.62.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 -7
- package/src/auditing/AuditRecordStore.ts +6 -0
- package/src/auditing/AuditSweep.ts +67 -0
- package/src/auditing/InMemoryAuditRecordStore.ts +10 -0
- package/src/data-store/PlatformMigrations.ts +6 -0
- package/src/data-store/PostgresAuditRecordStore.ts +15 -0
- package/src/main.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.62.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.
|
|
22
|
-
"anbaric-plugins": "^1.
|
|
23
|
-
"anbaric-tsapi": "^1.
|
|
24
|
-
"anbaric-web": "^1.
|
|
21
|
+
"anbaric-data-store": "^1.62.0",
|
|
22
|
+
"anbaric-plugins": "^1.62.0",
|
|
23
|
+
"anbaric-tsapi": "^1.62.0",
|
|
24
|
+
"anbaric-web": "^1.62.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.
|
|
32
|
-
"anbaric-state-machine": "^1.
|
|
31
|
+
"anbaric-impl-cloud": "^1.62.0",
|
|
32
|
+
"anbaric-state-machine": "^1.62.0",
|
|
33
33
|
"tsx": "^4.20.0",
|
|
34
34
|
"typescript": "^7.0.2"
|
|
35
35
|
},
|
|
@@ -16,6 +16,12 @@ interface AuditRecordStore {
|
|
|
16
16
|
save(record : AuditRecord) : Promise<void>;
|
|
17
17
|
list(filter : AuditFilter) : Promise<Array<AuditRecord>>;
|
|
18
18
|
|
|
19
|
+
/* Removes records older than the moment given, at most `limit` of them,
|
|
20
|
+
oldest first, and says how many went - so a backlog is cleared a batch
|
|
21
|
+
at a time rather than in one statement that holds a lock and leaves
|
|
22
|
+
every dead row behind at once. */
|
|
23
|
+
deleteBefore(before : Date, limit : number) : Promise<number>;
|
|
24
|
+
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export type { AuditFilter, AuditRecordStore }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {AuditRecordStore} from "./AuditRecordStore";
|
|
2
|
+
|
|
3
|
+
const SWEEP_INTERVAL_MS = 24 * 60 * 60_000;
|
|
4
|
+
const BATCH_SIZE = 5_000;
|
|
5
|
+
const BATCHES_PER_SWEEP = 200;
|
|
6
|
+
|
|
7
|
+
/* How long audit records are kept unless the platform is told otherwise. A
|
|
8
|
+
year: long enough that nobody is surprised by what has gone, short enough
|
|
9
|
+
that the table does not grow for ever. Zero means keep everything. */
|
|
10
|
+
const DEFAULT_RETENTION_DAYS = 365;
|
|
11
|
+
|
|
12
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
13
|
+
|
|
14
|
+
/* Removes audit records older than the retention, once a day and once at
|
|
15
|
+
start - a platform that restarts on every release would otherwise never
|
|
16
|
+
reach its first daily tick. Deletion is done a batch at a time rather than
|
|
17
|
+
in one statement, so clearing a backlog neither holds a lock for long nor
|
|
18
|
+
leaves more dead rows at once than autovacuum can keep up with, and each
|
|
19
|
+
sweep stops after a bounded number of batches so a very large backlog is
|
|
20
|
+
worked through over several days rather than in one sitting. */
|
|
21
|
+
class AuditSweep {
|
|
22
|
+
|
|
23
|
+
private ticker? : NodeJS.Timeout;
|
|
24
|
+
private sweeping = false;
|
|
25
|
+
|
|
26
|
+
constructor(private records : AuditRecordStore,
|
|
27
|
+
private retentionDays : number = DEFAULT_RETENTION_DAYS,
|
|
28
|
+
private intervalMs : number = SWEEP_INTERVAL_MS,
|
|
29
|
+
private batchSize : number = BATCH_SIZE) {}
|
|
30
|
+
|
|
31
|
+
start() : void {
|
|
32
|
+
if (this.ticker || this.retentionDays <= 0) return;
|
|
33
|
+
void this.sweep();
|
|
34
|
+
this.ticker = setInterval(() => void this.sweep(), this.intervalMs);
|
|
35
|
+
this.ticker.unref();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async sweep() : Promise<number> {
|
|
39
|
+
if (this.sweeping || this.retentionDays <= 0) return 0;
|
|
40
|
+
this.sweeping = true;
|
|
41
|
+
|
|
42
|
+
const before = new Date(Date.now() - this.retentionDays * DAY_MS);
|
|
43
|
+
let removed = 0;
|
|
44
|
+
try {
|
|
45
|
+
for (let batch = 0; batch < BATCHES_PER_SWEEP; batch++) {
|
|
46
|
+
const went = await this.records.deleteBefore(before, this.batchSize);
|
|
47
|
+
removed += went;
|
|
48
|
+
if (went < this.batchSize) break;
|
|
49
|
+
}
|
|
50
|
+
if (removed > 0) console.log(`[audit] removed ${removed} record(s) older than ${this.retentionDays} days`);
|
|
51
|
+
return removed;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error(`[audit] the retention sweep failed: ${error instanceof Error ? error.message : error}`);
|
|
54
|
+
return removed;
|
|
55
|
+
} finally {
|
|
56
|
+
this.sweeping = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async cleanUp() : Promise<void> {
|
|
61
|
+
if (this.ticker) clearInterval(this.ticker);
|
|
62
|
+
this.ticker = undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { AuditSweep, DEFAULT_RETENTION_DAYS };
|
|
@@ -26,6 +26,16 @@ class InMemoryAuditRecordStore implements AuditRecordStore {
|
|
|
26
26
|
.slice(page * pageSize, (page + 1) * pageSize);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
async deleteBefore(before : Date, limit : number) : Promise<number> {
|
|
30
|
+
const stale = this.records
|
|
31
|
+
.filter(record => new Date(record.at ?? 0) < before)
|
|
32
|
+
.sort((left, right) => String(left.at).localeCompare(String(right.at)))
|
|
33
|
+
.slice(0, limit);
|
|
34
|
+
|
|
35
|
+
this.records = this.records.filter(record => ! stale.includes(record));
|
|
36
|
+
return stale.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
export { InMemoryAuditRecordStore }
|
|
@@ -256,6 +256,12 @@ const PLATFORM_MIGRATIONS : Array<Migration> = [
|
|
|
256
256
|
ON entitlement_invites (email, COALESCE(app_id, ''), entitlement_id)`,
|
|
257
257
|
),
|
|
258
258
|
|
|
259
|
+
// Audit records are weeded by age, which without this is a scan of the
|
|
260
|
+
// whole table every day.
|
|
261
|
+
statements("006-audit-records-by-age",
|
|
262
|
+
"CREATE INDEX IF NOT EXISTS audit_records_at ON anbaric_system.audit_records (at)",
|
|
263
|
+
),
|
|
264
|
+
|
|
259
265
|
];
|
|
260
266
|
|
|
261
267
|
export { PLATFORM_MIGRATIONS };
|
|
@@ -92,6 +92,21 @@ class PostgresAuditRecordStore implements AuditRecordStore {
|
|
|
92
92
|
}));
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/* Oldest first and bounded, so the sweep that calls this can work through
|
|
96
|
+
a large backlog a batch at a time. The subquery is what the index on
|
|
97
|
+
`at` serves; the delete itself goes by primary key. */
|
|
98
|
+
async deleteBefore(before : Date, limit : number) : Promise<number> {
|
|
99
|
+
const result = await this.pool.query(
|
|
100
|
+
`DELETE FROM anbaric_system.audit_records
|
|
101
|
+
WHERE id IN (
|
|
102
|
+
SELECT id FROM anbaric_system.audit_records
|
|
103
|
+
WHERE at < $1 ORDER BY at, id LIMIT $2
|
|
104
|
+
)`,
|
|
105
|
+
[before, limit],
|
|
106
|
+
);
|
|
107
|
+
return result.rowCount ?? 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
export { PostgresAuditRecordStore }
|
package/src/main.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {S3FileStorage} from "./data-store/S3FileStorage";
|
|
|
18
18
|
import {PostgresJobRunSchedulePersistence} from "./data-store/PostgresJobRunSchedulePersistence";
|
|
19
19
|
import {PostgresJobPersistence} from "./data-store/PostgresJobPersistence";
|
|
20
20
|
import {StallSweep} from "./data-store/StallSweep";
|
|
21
|
+
import {AuditSweep, DEFAULT_RETENTION_DAYS} from "./auditing/AuditSweep";
|
|
21
22
|
import {PostgresJsonStore} from "./data-store/PostgresJsonStore";
|
|
22
23
|
import {PostgresQueue} from "./queuing/PostgresQueue";
|
|
23
24
|
import {DockerBuildLayer} from "./app-management/DockerBuildLayer";
|
|
@@ -130,9 +131,10 @@ const fileStorageFor = (appId : string) : FileStorage =>
|
|
|
130
131
|
: new LocalFileSystemStorage(join(process.env.ANBARIC_FILE_STORAGE_PATH ?? join(tmpdir(), "anbaric", "files"), appId));
|
|
131
132
|
|
|
132
133
|
const jobs = new PostgresJobPersistence(pool);
|
|
134
|
+
const auditRecords = new PostgresAuditRecordStore(pool);
|
|
133
135
|
const server = new HostingServer(jobs, queue, registry, buildLayer,
|
|
134
136
|
(appId, collection) => new PostgresJsonStore(pool, appId, collection), secretStoreFor, authenticator, cliAuthorizer,
|
|
135
|
-
tokenAuthenticator, process.env.ANBARIC_TENANT,
|
|
137
|
+
tokenAuthenticator, process.env.ANBARIC_TENANT, auditRecords, plugins,
|
|
136
138
|
new PostgresJobRunSchedulePersistence(pool), await loadNotifier(process.env.ANBARIC_NOTIFIER_MODULE),
|
|
137
139
|
new PostgresEntitlementStore(pool), new PostgresUserDirectory(pool), memberships,
|
|
138
140
|
(appId) => new PostgresPromptManager(pool, appId), fileStorageFor, subdomains);
|
|
@@ -169,4 +171,8 @@ if (buildLayer && subdomains) {
|
|
|
169
171
|
// Jobs whose long-running step stopped saying it was alive are marked, not touched.
|
|
170
172
|
new StallSweep(jobs).start();
|
|
171
173
|
|
|
174
|
+
/* Audit records older than the retention are removed, daily. The retention is
|
|
175
|
+
in days; zero keeps everything, and an unset value keeps a year. */
|
|
176
|
+
new AuditSweep(auditRecords, Number(process.env.ANBARIC_AUDIT_RETENTION_DAYS ?? DEFAULT_RETENTION_DAYS)).start();
|
|
177
|
+
|
|
172
178
|
console.log(`anbaric-cloud-hosting listening on port ${port}, internal entry point on ${internal}`);
|