@crvouga/atlas 0.17.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +52 -8
- package/dist/core/src/cli.d.ts +1 -1
- package/dist/core/src/cli.js +1 -1
- package/dist/reports/src/garbage.d.ts +39 -0
- package/dist/reports/src/garbage.js +82 -0
- package/dist/reports/src/report-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +14 -0
- package/dist/schema/src/index.d.ts +1 -0
- package/dist/schema/src/index.js +1 -0
- package/dist/schema/src/server-config.d.ts +7 -49
- package/dist/schema/src/server-config.js +8 -21
- package/dist/schema/src/user-state.d.ts +115 -0
- package/dist/schema/src/user-state.js +54 -0
- package/dist/server/src/app.d.ts +16 -2
- package/dist/server/src/app.js +62 -0
- package/dist/server/src/config.d.ts +16 -21
- package/dist/server/src/config.js +48 -57
- package/dist/server/src/dev.d.ts +1 -1
- package/dist/server/src/dev.js +20 -7
- package/dist/server/src/index.d.ts +5 -1
- package/dist/server/src/index.js +5 -1
- package/dist/server/src/main.d.ts +23 -7
- package/dist/server/src/main.js +72 -26
- package/dist/server/src/openapi.js +24 -5
- package/dist/server/src/projects.js +89 -14
- package/dist/server/src/settings.d.ts +23 -7
- package/dist/server/src/settings.js +50 -60
- package/dist/server/src/storage.d.ts +68 -0
- package/dist/server/src/storage.js +125 -0
- package/dist/server/src/user-state.d.ts +31 -0
- package/dist/server/src/user-state.js +74 -0
- package/docs/agents.md +1 -1
- package/docs/deploying.md +38 -39
- package/docs/reference/cli.md +12 -7
- package/docs/reference/http-api.md +30 -2
- package/docs/reference/reports.md +71 -0
- package/docs/reference/schema.md +102 -16
- package/docs/reference/server.md +263 -41
- package/docs/report-backends.md +0 -144
- package/docs/report-contract.md +1 -1
- package/docs/runners.md +1 -1
- package/docs/server.md +141 -36
- package/docs/statecharts.md +1 -1
- package/docs/ui.md +1 -1
- package/docs/workspaces.md +5 -2
- package/llms-full.txt +231 -234
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/packages/reports/STORAGE.md +1 -1
- package/packages/schema/schemas/gc-policy.schema.json +36 -0
- package/packages/schema/schemas/gc-run-result.schema.json +106 -0
- package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
- package/packages/schema/schemas/server-config.schema.json +0 -41
- package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
- package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
- package/packages/schema/schemas/user-state.schema.json +140 -0
- package/packages/server/README.md +11 -6
- package/packages/server/openapi.json +510 -126
- package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
|
@@ -1,30 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module `atlas serve`
|
|
3
3
|
*
|
|
4
|
-
* Starts the Atlas server from its configuration
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Starts the Atlas server from its configuration. Storage comes first and is not optional: the required `ATLAS_S3_*`
|
|
5
|
+
* variables are checked (every problem listed together), the bucket is proven reachable and writable, and the user
|
|
6
|
+
* state (garbage collection policy and saved settings) is read from it, all before anything listens. Any failure prints
|
|
7
|
+
* a banner naming every variable and exits 2; there is no fallback to a local folder, memory or a database. Then it
|
|
8
|
+
* finds or builds the client, listens at once (so a liveness probe finds the process while a large bucket is still being
|
|
9
|
+
* read), and stops cleanly on SIGTERM or SIGINT. `serverOptionsOf` is shared with `atlas dev`, so the two run the same server.
|
|
8
10
|
*
|
|
9
11
|
* @see docs/server.md#run-it
|
|
10
12
|
*/
|
|
11
13
|
import type { BackendInput } from '../../reports/src/compose.js';
|
|
12
14
|
import { type AtlasServerOptions } from './app.js';
|
|
13
15
|
import { type ResolvedServerConfig } from './config.js';
|
|
16
|
+
import { type StoredUserState } from './user-state.js';
|
|
14
17
|
type Env = Readonly<Record<string, string | undefined>>;
|
|
15
|
-
/** What `serveMain` and `devMain` are given besides arguments: where output goes, and, for tests and embedding, the environment, working directory and an `AbortSignal` that stops the server (otherwise SIGTERM and SIGINT do). */
|
|
18
|
+
/** What `serveMain` and `devMain` are given besides arguments: where output goes, and, for tests and embedding, the environment, working directory, the `fetch` S3 requests are sent with, and an `AbortSignal` that stops the server (otherwise SIGTERM and SIGINT do). */
|
|
16
19
|
export type ServeIo = {
|
|
17
20
|
readonly out: (text: string) => void;
|
|
18
21
|
readonly env?: Env;
|
|
19
22
|
readonly cwd?: string;
|
|
20
23
|
readonly signal?: AbortSignal;
|
|
24
|
+
readonly fetch?: typeof fetch;
|
|
21
25
|
};
|
|
22
|
-
/**
|
|
23
|
-
export declare
|
|
26
|
+
/** The id of the one backend: the S3 bucket. */
|
|
27
|
+
export declare const S3_BACKEND_ID = "s3";
|
|
28
|
+
/** Open the S3 bucket named by a configuration, with exactly the credentials it gives (no ambient AWS lookup). Nothing is contacted yet. */
|
|
29
|
+
export declare function openConfiguredBackends(config: ResolvedServerConfig, fetchImpl?: typeof fetch): (BackendInput & {
|
|
24
30
|
readonly writable: boolean;
|
|
25
31
|
})[];
|
|
26
32
|
/** What `createAtlasServer` is given for a configuration: `atlas serve` and `atlas dev` run the same server. */
|
|
27
33
|
export declare function serverOptionsOf(config: ResolvedServerConfig, backends: ReturnType<typeof openConfiguredBackends>, log?: (line: Record<string, unknown>) => void): AtlasServerOptions;
|
|
34
|
+
/**
|
|
35
|
+
* Check the storage and read the user state, in that order, with the failure banner for each way it can go wrong.
|
|
36
|
+
* Returns the opened backends and the state, or the text to print before exiting 2.
|
|
37
|
+
*/
|
|
38
|
+
export declare function connectStorage(config: ResolvedServerConfig, env: Env, io: Pick<ServeIo, 'fetch'>): Promise<{
|
|
39
|
+
readonly backends: ReturnType<typeof openConfiguredBackends>;
|
|
40
|
+
readonly stored: StoredUserState;
|
|
41
|
+
} | {
|
|
42
|
+
readonly failure: string;
|
|
43
|
+
}>;
|
|
28
44
|
/** `atlas serve`. Runs until it is told to stop; returns the exit code. */
|
|
29
45
|
export declare function serveMain(argv: readonly string[], io: ServeIo): Promise<number>;
|
|
30
46
|
export {};
|
package/dist/server/src/main.js
CHANGED
|
@@ -1,38 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module `atlas serve`
|
|
3
3
|
*
|
|
4
|
-
* Starts the Atlas server from its configuration
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Starts the Atlas server from its configuration. Storage comes first and is not optional: the required `ATLAS_S3_*`
|
|
5
|
+
* variables are checked (every problem listed together), the bucket is proven reachable and writable, and the user
|
|
6
|
+
* state (garbage collection policy and saved settings) is read from it, all before anything listens. Any failure prints
|
|
7
|
+
* a banner naming every variable and exits 2; there is no fallback to a local folder, memory or a database. Then it
|
|
8
|
+
* finds or builds the client, listens at once (so a liveness probe finds the process while a large bucket is still being
|
|
9
|
+
* read), and stops cleanly on SIGTERM or SIGINT. `serverOptionsOf` is shared with `atlas dev`, so the two run the same server.
|
|
8
10
|
*
|
|
9
11
|
* @see docs/server.md#run-it
|
|
10
12
|
*/
|
|
11
|
-
import { mkdirSync } from 'node:fs';
|
|
12
13
|
import path from 'node:path';
|
|
13
|
-
import {
|
|
14
|
+
import { s3Store } from '../../reports/src/s3-store.js';
|
|
14
15
|
import { createAtlasServer } from './app.js';
|
|
15
16
|
import { ConfigError, loadServerConfig, SERVER_USAGE } from './config.js';
|
|
16
17
|
import { listen } from './host.js';
|
|
17
|
-
import {
|
|
18
|
+
import { storedSettings } from './settings.js';
|
|
18
19
|
import { findSite } from './site.js';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
import { describeS3, inspectS3Env, S3ConfigError, startupFailure } from './storage.js';
|
|
21
|
+
import { USER_STATE_KEY, UserStateError, userStateStore } from './user-state.js';
|
|
22
|
+
/** The id of the one backend: the S3 bucket. */
|
|
23
|
+
export const S3_BACKEND_ID = 's3';
|
|
24
|
+
/** Open the S3 bucket named by a configuration, with exactly the credentials it gives (no ambient AWS lookup). Nothing is contacted yet. */
|
|
25
|
+
export function openConfiguredBackends(config, fetchImpl) {
|
|
26
|
+
const { s3 } = config;
|
|
27
|
+
const store = s3Store({
|
|
28
|
+
bucket: s3.bucket, prefix: s3.prefix, region: s3.region, endpoint: s3.endpoint, forcePathStyle: s3.pathStyle,
|
|
29
|
+
credentials: { accessKeyId: s3.accessKeyId, secretAccessKey: s3.secretAccessKey, ...(s3.sessionToken ? { sessionToken: s3.sessionToken } : {}) },
|
|
30
|
+
env: {}, ...(fetchImpl ? { fetch: fetchImpl } : {})
|
|
26
31
|
});
|
|
32
|
+
return [{ id: S3_BACKEND_ID, label: describeS3(s3), store, writable: true }];
|
|
27
33
|
}
|
|
28
|
-
/** Can the server
|
|
29
|
-
async function
|
|
34
|
+
/** Can the server use the bucket? Said at the start, not at the first upload: list, write, read back and delete. */
|
|
35
|
+
async function probeStorage(backend) {
|
|
30
36
|
const key = `layers/doctor-${Math.random().toString(36).slice(2, 10)}/probe`;
|
|
31
37
|
try {
|
|
38
|
+
await backend.store.listKeys('server/');
|
|
32
39
|
await backend.store.put(key, new TextEncoder().encode('atlas'), 'text/plain');
|
|
33
40
|
if (!(await backend.store.head(key)))
|
|
34
41
|
return 'an object was written but could not be found again';
|
|
35
|
-
await backend.store.delete(key)
|
|
42
|
+
await backend.store.delete(key);
|
|
36
43
|
return null;
|
|
37
44
|
}
|
|
38
45
|
catch (error) {
|
|
@@ -46,13 +53,35 @@ export function serverOptionsOf(config, backends, log) {
|
|
|
46
53
|
? (layer) => (!branches?.length || (layer.branch !== null && branches.includes(layer.branch))) && (!config.include.ignoreModified || layer.freshness === undefined)
|
|
47
54
|
: undefined;
|
|
48
55
|
return {
|
|
49
|
-
backends, write:
|
|
50
|
-
|
|
56
|
+
backends, write: S3_BACKEND_ID,
|
|
57
|
+
tokens: config.tokens, anonymousRead: config.anonymousRead,
|
|
51
58
|
...(config.outlineBranch ? { outlineBranch: config.outlineBranch } : {}), ...(include ? { include } : {}),
|
|
52
59
|
runTimeoutMs: config.runTimeoutSeconds * 1000, runStallMs: config.runStallSeconds * 1000, refreshMs: config.refreshSeconds * 1000, limits: config.limits, heartbeatMs: config.heartbeatSeconds * 1000,
|
|
53
60
|
...(config.log && log ? { log } : {})
|
|
54
61
|
};
|
|
55
62
|
}
|
|
63
|
+
/** What the garbage collection policy in S3 means, in one line for the log. */
|
|
64
|
+
const gcLine = (stored) => stored.state.gc
|
|
65
|
+
? `Garbage collection policy loaded from S3 (${USER_STATE_KEY}): ${stored.state.gc.enabled ? `ON, delete superseded layers older than ${stored.state.gc.maxAgeDays} days, keep the newest ${stored.state.gc.keepLatest}, every ${stored.state.gc.intervalHours} h` : 'OFF (saved but disabled)'}.\n`
|
|
66
|
+
: `No garbage collection policy has been saved in S3 (${USER_STATE_KEY}) yet: garbage collection is OFF and nothing is deleted. Define a policy in the Atlas UI at /admin.\n`;
|
|
67
|
+
/**
|
|
68
|
+
* Check the storage and read the user state, in that order, with the failure banner for each way it can go wrong.
|
|
69
|
+
* Returns the opened backends and the state, or the text to print before exiting 2.
|
|
70
|
+
*/
|
|
71
|
+
export async function connectStorage(config, env, io) {
|
|
72
|
+
const backends = openConfiguredBackends(config, io.fetch);
|
|
73
|
+
const problem = await probeStorage(backends[0]);
|
|
74
|
+
if (problem)
|
|
75
|
+
return { failure: startupFailure(inspectS3Env(env), `the S3 storage ${describeS3(config.s3)} cannot be used with these settings`, problem) };
|
|
76
|
+
try {
|
|
77
|
+
return { backends, stored: await userStateStore(backends[0].store).read() };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof UserStateError)
|
|
81
|
+
return { failure: startupFailure(inspectS3Env(env), 'the user state stored in S3 cannot be read', error.message) };
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
56
85
|
/** `atlas serve`. Runs until it is told to stop; returns the exit code. */
|
|
57
86
|
export async function serveMain(argv, io) {
|
|
58
87
|
const cwd = io.cwd ?? process.cwd();
|
|
@@ -66,24 +95,41 @@ export async function serveMain(argv, io) {
|
|
|
66
95
|
config = loadServerConfig(argv, env, cwd);
|
|
67
96
|
}
|
|
68
97
|
catch (error) {
|
|
98
|
+
if (error instanceof S3ConfigError) {
|
|
99
|
+
io.out(`${error.message}\n`);
|
|
100
|
+
return 2;
|
|
101
|
+
}
|
|
69
102
|
if (error instanceof ConfigError) {
|
|
70
103
|
io.out(`atlas serve: ${error.message}\n`);
|
|
71
104
|
return 2;
|
|
72
105
|
}
|
|
73
106
|
throw error;
|
|
74
107
|
}
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const problem = await probeWritable(writeBackend);
|
|
79
|
-
if (problem) {
|
|
80
|
-
io.out(`atlas serve: uploads would go to "${writeBackend.id}" and it cannot be written to: ${problem}\n`);
|
|
108
|
+
const connected = await connectStorage(config, env, io);
|
|
109
|
+
if ('failure' in connected) {
|
|
110
|
+
io.out(`${connected.failure}\n`);
|
|
81
111
|
return 2;
|
|
82
112
|
}
|
|
113
|
+
const { backends, stored } = connected;
|
|
114
|
+
if (stored.state.settings) {
|
|
115
|
+
try {
|
|
116
|
+
config = loadServerConfig(argv, env, cwd, undefined, stored.state.settings);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error instanceof ConfigError) {
|
|
120
|
+
io.out(`atlas serve: the settings saved in S3 (${USER_STATE_KEY}) do not work with this deployment: ${error.message}\n`);
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
io.out(`${config.configFile ? `Configuration ${path.relative(cwd, config.configFile) || config.configFile}. ` : ''}Storage: ${describeS3(config.s3)}. All reports and user state live there.\n`);
|
|
127
|
+
io.out(gcLine(stored));
|
|
83
128
|
const site = await findSite(config.site, cwd, io.out);
|
|
84
129
|
if (!site)
|
|
85
130
|
io.out('No Atlas client to serve: build one with `atlas client build --no-data --out atlas-site`. The API is served meanwhile.\n');
|
|
86
|
-
const
|
|
131
|
+
const { settings, gc } = storedSettings(config, argv, env, cwd, userStateStore(backends[0].store));
|
|
132
|
+
const server = createAtlasServer({ settings, gc, ...serverOptionsOf(config, backends, line => io.out(`${JSON.stringify(line)}\n`)), ...(site ? { site } : {}) });
|
|
87
133
|
// Listen at once: the first pass over a large backend can take a while, and a liveness probe must find the
|
|
88
134
|
// process meanwhile. /readyz is 503 until there is a report to show.
|
|
89
135
|
const running = await listen(server.fetch, { port: config.port, host: config.host, maxBodyBytes: Math.max(config.limits.maxObjectMiB, config.limits.maxJsonMiB) * 1024 * 1024 });
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { z } from 'zod/v4';
|
|
16
16
|
import { ProjectsSchema } from '../../schema/src/project.js';
|
|
17
17
|
import { EffectiveDeltaSchema, EffectiveEvidenceSchema, EvidencePageSchema, HealthSchema, LayerRowsSchema, CheckDetailSchema, OutlineSchema, ProblemSchema, ReadySchema, ServerInfoSchema, StoreListingSchema, StoreWriteResultSchema, ChecksResultSchema, HelloEventSchema, ReportEventSchema, PingEventSchema, TestCatalogSchema, CatalogHealthSchema, TestHistorySchema, RunListSchema, RunDetailSchema, ArtifactTagEditSchema, ArtifactTagEntrySchema, ArtifactTagsSchema } from '../../schema/src/api.js';
|
|
18
|
-
import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
|
|
18
|
+
import { LiveRunSchema, LiveRunStartSchema, LiveRunUpdateSchema, ServerEditableSettingsSchema, ServerSettingsSnapshotSchema, GcPolicySchema, GcSnapshotSchema, GcRunResultSchema, AtlasChangeSchema, PublishChecksSchema, ReportSourcesSchema, ReportStatusSchema, RunsIndexSchema, SpecIndexSchema } from '../../schema/src/index.js';
|
|
19
19
|
/** The version of the API described, `major.minor.patch`: the minor grows when an operation or field is added, the major never within `/api`. */
|
|
20
20
|
export const API_VERSION = '1.5.0';
|
|
21
21
|
/** The JSON Schema of a Zod schema, as OpenAPI 3.1 embeds it. `output` for what the server sends, `input` for what it accepts. */
|
|
@@ -30,6 +30,7 @@ function schemaOf(schema, io = 'output') {
|
|
|
30
30
|
const COMPONENTS = {
|
|
31
31
|
LiveRun: [LiveRunSchema, 'output'], LiveRunStart: [LiveRunStartSchema, 'input'], LiveRunUpdate: [LiveRunUpdateSchema, 'input'],
|
|
32
32
|
ServerEditableSettings: [ServerEditableSettingsSchema, 'input'], ServerSettingsSnapshot: [ServerSettingsSnapshotSchema, 'output'],
|
|
33
|
+
GcPolicy: [GcPolicySchema, 'input'], GcSnapshot: [GcSnapshotSchema, 'output'], GcRunResult: [GcRunResultSchema, 'output'],
|
|
33
34
|
Problem: [ProblemSchema, 'output'], Health: [HealthSchema, 'output'], Ready: [ReadySchema, 'output'], ServerInfo: [ServerInfoSchema, 'output'],
|
|
34
35
|
EffectiveEvidence: [EffectiveEvidenceSchema, 'output'], EffectiveDelta: [EffectiveDeltaSchema, 'output'], ReportStatus: [ReportStatusSchema, 'output'],
|
|
35
36
|
PublishChecks: [PublishChecksSchema.partial({ recordedAtMs: true }), 'input'], ChecksResult: [ChecksResultSchema, 'output'],
|
|
@@ -92,16 +93,34 @@ const BASE_OPERATIONS = [
|
|
|
92
93
|
},
|
|
93
94
|
{
|
|
94
95
|
route: 'GET /api/settings', path: '/api/settings', operationId: 'getSettings', tag: 'Operations', role: 'admin',
|
|
95
|
-
summary: 'Read server settings', description: 'Returns editable
|
|
96
|
+
summary: 'Read server settings', description: 'Returns the editable report and live-run settings saved in the user state in S3, active settings, a revision, and configuration precedence. Storage and authentication settings are excluded. Requires an admin Bearer token. Embedded applications may manage settings outside this API.',
|
|
96
97
|
responses: { '200': json('Settings and revision.', 'ServerSettingsSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '503': problem('Configuration is managed by the embedding application.') }
|
|
97
98
|
},
|
|
98
99
|
{
|
|
99
100
|
route: 'PUT /api/settings', path: '/api/settings', operationId: 'saveSettings', tag: 'Operations', role: 'admin',
|
|
100
|
-
summary: 'Save settings for the next restart', description: 'Replaces editable
|
|
101
|
+
summary: 'Save settings for the next restart', description: 'Replaces the editable report and live-run settings in the user state in S3 (`server/state.json`), preserving the garbage collection policy and other fields. Persists a validated configuration; no report data is moved. Environment variables and flags override these values after restart. Requires an admin Bearer token, same-origin requests in browsers, and a current revision.',
|
|
101
102
|
parameters: [{ name: 'If-Match', in: 'header', required: true, description: 'The quoted revision returned by GET /api/settings.', schema: str }],
|
|
102
|
-
body: { description: 'Editable fields from ServerConfig, excluding authentication, host, port and site. Maximum 64 KiB.', contentType: 'application/json', required: true, schema: ref('ServerEditableSettings') },
|
|
103
|
+
body: { description: 'Editable fields from ServerConfig, excluding storage, authentication, host, port and site. Maximum 64 KiB.', contentType: 'application/json', required: true, schema: ref('ServerEditableSettings') },
|
|
103
104
|
responses: { '200': json('Saved settings and new revision. Restart required.', 'ServerSettingsSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('Settings changed; reload before saving.'), '413': problem('Settings exceed the body limit.'), '422': problem('Invalid configuration.'), '428': problem('A quoted settings revision is required.'), '503': problem('Configuration is managed by the embedding application.') }
|
|
104
105
|
},
|
|
106
|
+
{
|
|
107
|
+
route: 'GET /api/settings/gc', path: '/api/settings/gc', operationId: 'getGarbageCollectionPolicy', tag: 'Operations', role: 'admin',
|
|
108
|
+
summary: 'Read the garbage collection policy', description: 'Returns the policy kept in the user state in S3 (`server/state.json`), or `policy: null, saved: false` when none has been saved yet, in which case garbage collection is off and nothing is deleted. Also returns the revision to send back when saving and the last pass over each project. Requires an admin Bearer token. If the stored user state is unreadable the answer is 503 and the stored object is left untouched.',
|
|
109
|
+
responses: { '200': json('The policy, its revision and the last passes.', 'GcSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '503': problem('The user state in S3 cannot be read, or settings are managed by the embedding application.') }
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
route: 'PUT /api/settings/gc', path: '/api/settings/gc', operationId: 'saveGarbageCollectionPolicy', tag: 'Operations', role: 'admin',
|
|
113
|
+
summary: 'Save the garbage collection policy', description: 'Replaces the policy in the user state in S3 and leaves the rest of that document alone. The server reads the policy from S3 each time it collects, so the new policy applies at the next pass, without a restart. Requires an admin Bearer token, same-origin requests in browsers, and the current revision.',
|
|
114
|
+
parameters: [{ name: 'If-Match', in: 'header', required: true, description: 'The quoted revision returned by GET /api/settings/gc.', schema: str }],
|
|
115
|
+
body: { description: 'The policy. Maximum 16 KiB.', contentType: 'application/json', required: true, schema: ref('GcPolicy') },
|
|
116
|
+
responses: { '200': json('The saved policy and the new revision.', 'GcSnapshot'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('The state changed; reload before saving.'), '413': problem('The body exceeds the limit.'), '422': problem('Invalid policy.'), '428': problem('A quoted revision is required.'), '503': problem('The user state in S3 cannot be read, or settings are managed by the embedding application.') }
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
route: 'POST /api/settings/gc/run', path: '/api/settings/gc/run', operationId: 'runGarbageCollection', tag: 'Operations', role: 'admin',
|
|
120
|
+
summary: 'Collect garbage now, or preview it', description: 'Applies the policy saved in S3 to every project now, instead of waiting for the schedule. Only report layers that were read and no longer decide any result, older than the policy allows and beyond its `keepLatest`, are deleted, with the captured files only they used. With `dryRun=1` nothing is deleted and each run reports what it would delete (a disabled policy is previewed as if enabled). Answers 409 when no policy has been saved, and, without `dryRun`, when the saved policy is disabled. Requires an admin Bearer token and a same-origin request in browsers.',
|
|
121
|
+
parameters: [{ name: 'dryRun', in: 'query', required: false, description: '`1` or `true` to only count what would be deleted.', schema: str }],
|
|
122
|
+
responses: { '200': json('One run per project.', 'GcRunResult'), '401': UNAUTHORIZED, '403': FORBIDDEN, '409': problem('No policy has been saved, or it is disabled.') }
|
|
123
|
+
},
|
|
105
124
|
{
|
|
106
125
|
route: 'GET /healthz', path: '/healthz', operationId: 'getHealth', tag: 'Operations', role: 'none',
|
|
107
126
|
summary: 'Is the process up',
|
|
@@ -437,7 +456,7 @@ const BASE_OPERATIONS = [
|
|
|
437
456
|
responses: { '200': { description: 'The file.', contentType: 'application/octet-stream', schema: { type: 'string', format: 'binary' } }, '401': UNAUTHORIZED, '404': problem('No such run or file.') }
|
|
438
457
|
}
|
|
439
458
|
];
|
|
440
|
-
const NODE_OPERATIONS = new Set(['/api/projects', '/api/settings', '/api/server', '/api/openapi.json']);
|
|
459
|
+
const NODE_OPERATIONS = new Set(['/api/projects', '/api/settings', '/api/settings/gc', '/api/settings/gc/run', '/api/server', '/api/openapi.json']);
|
|
441
460
|
const PROJECT_PARAMETER = { name: 'project', in: 'query', description: 'Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.', schema: { type: 'string', pattern: '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', maxLength: 64 }, example: 'todo-app' };
|
|
442
461
|
/** Every node operation and the same stable report protocol inside each project namespace. */
|
|
443
462
|
export const OPERATIONS = BASE_OPERATIONS.flatMap(operation => {
|
|
@@ -9,7 +9,7 @@ import { createAuth } from './auth.js';
|
|
|
9
9
|
import { problemResponse } from './problem.js';
|
|
10
10
|
const REGISTRY = 'server/projects/';
|
|
11
11
|
const SCOPED = /^\/api\/projects\/([^/]+)(\/.*)$/;
|
|
12
|
-
const GLOBAL = new Set(['/api/settings', '/api/projects', '/api/openapi.json', '/api/server']);
|
|
12
|
+
const GLOBAL = new Set(['/api/settings', '/api/settings/gc', '/api/settings/gc/run', '/api/projects', '/api/openapi.json', '/api/server']);
|
|
13
13
|
const scopeStore = (store, slug) => typeof store.forProject === 'function'
|
|
14
14
|
? store.forProject(slug) : new ObjectStoreWithPrefix(store, `projects/${slug}`);
|
|
15
15
|
/** Wrap the single-project protocol in project selection. Auth, upload validation and composition stay identical. */
|
|
@@ -61,7 +61,71 @@ export function projectServer(options, create) {
|
|
|
61
61
|
};
|
|
62
62
|
const timer = setInterval(expire, 60_000);
|
|
63
63
|
timer.unref?.();
|
|
64
|
-
|
|
64
|
+
/** The application of one named project, created on first use over a store scoped to it. `null` when too many are active. */
|
|
65
|
+
const ensureChild = (slug) => {
|
|
66
|
+
let child = children.get(slug);
|
|
67
|
+
if (child)
|
|
68
|
+
return child;
|
|
69
|
+
expire();
|
|
70
|
+
if (children.size >= 64)
|
|
71
|
+
return null;
|
|
72
|
+
child = { touched: Date.now(), pending: 0, server: create({ ...options,
|
|
73
|
+
...(options.log ? { log: line => options.log({ ...line, project: slug }) } : {}),
|
|
74
|
+
backends: options.backends.map(backend => ({ ...backend, store: scopeStore(backend.store, slug) })),
|
|
75
|
+
...(options.liveStore ? { liveStore: scopeStore(options.liveStore, slug) } : {}), site: undefined, settings: undefined, gc: undefined }) };
|
|
76
|
+
children.set(slug, child);
|
|
77
|
+
return child;
|
|
78
|
+
};
|
|
79
|
+
// Garbage collection. The policy is read from S3 every time, never cached, so a policy saved in the UI is the one applied.
|
|
80
|
+
const clock = options.now ?? Date.now;
|
|
81
|
+
let collecting;
|
|
82
|
+
let lastCollectedAt = 0;
|
|
83
|
+
const collectAll = (policy, dryRun) => {
|
|
84
|
+
collecting ??= (async () => {
|
|
85
|
+
const runs = [];
|
|
86
|
+
await discover().catch(() => { });
|
|
87
|
+
for (const slug of ['default', ...[...known].filter(item => item !== 'default').sort()]) {
|
|
88
|
+
if (closed)
|
|
89
|
+
break;
|
|
90
|
+
if (slug === 'default') {
|
|
91
|
+
runs.push(await base.collectGarbage(policy, { dryRun, project: slug }));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const child = ensureChild(slug);
|
|
95
|
+
if (!child) {
|
|
96
|
+
runs.push({ project: slug, dryRun, at: new Date(clock()).toISOString(), candidates: 0, deletedLayers: 0, deletedBlobs: 0, keptLayers: 0, error: 'Too many active projects; this one was skipped.' });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
child.pending++;
|
|
100
|
+
try {
|
|
101
|
+
await child.server.ready;
|
|
102
|
+
runs.push(await child.server.collectGarbage(policy, { dryRun, project: slug }));
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
child.pending--;
|
|
106
|
+
child.touched = Date.now();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!dryRun)
|
|
110
|
+
options.gc?.record(runs);
|
|
111
|
+
return runs;
|
|
112
|
+
})().finally(() => { collecting = undefined; });
|
|
113
|
+
return collecting;
|
|
114
|
+
};
|
|
115
|
+
const gcTimer = options.gc ? setInterval(() => {
|
|
116
|
+
void (async () => {
|
|
117
|
+
if (closed || collecting)
|
|
118
|
+
return;
|
|
119
|
+
const policy = await options.gc.policy();
|
|
120
|
+
if (!policy?.enabled || clock() - lastCollectedAt < policy.intervalHours * 3_600_000)
|
|
121
|
+
return;
|
|
122
|
+
await base.ready;
|
|
123
|
+
lastCollectedAt = clock();
|
|
124
|
+
await collectAll(policy, false);
|
|
125
|
+
})().catch(error => options.log?.({ level: 'error', gc: 'schedule', error: String(error) }));
|
|
126
|
+
}, options.gcCheckMs ?? 60_000) : undefined;
|
|
127
|
+
gcTimer?.unref?.();
|
|
128
|
+
const routes = [...base.routes, { method: 'GET', path: '/api/projects' }, { method: 'POST', path: '/api/settings/gc/run' }];
|
|
65
129
|
for (const route of base.routes)
|
|
66
130
|
if (route.path.startsWith('/api/') && route.path !== '/api/*' && !GLOBAL.has(route.path))
|
|
67
131
|
routes.push({ method: route.method, path: route.path.replace('/api/', '/api/projects/:project/') });
|
|
@@ -71,6 +135,24 @@ export function projectServer(options, create) {
|
|
|
71
135
|
if (closed)
|
|
72
136
|
return problemResponse(503, 'Atlas is stopping.');
|
|
73
137
|
const url = new URL(request.url);
|
|
138
|
+
if (url.pathname === '/api/settings/gc/run' && request.method === 'POST') {
|
|
139
|
+
const verdict = auth.check(request, 'admin');
|
|
140
|
+
if (!verdict.ok)
|
|
141
|
+
return problemResponse(verdict.status, verdict.message, verdict.status === 401 ? { 'www-authenticate': 'Basic realm="Atlas"' } : {});
|
|
142
|
+
const origin = request.headers.get('origin');
|
|
143
|
+
const site = request.headers.get('sec-fetch-site');
|
|
144
|
+
if (site === 'cross-site' || (origin && origin !== url.origin && site !== 'same-origin'))
|
|
145
|
+
return problemResponse(403, 'Changes must come from this server origin.');
|
|
146
|
+
if (!options.gc)
|
|
147
|
+
return problemResponse(503, 'Garbage collection settings are managed by the embedding application.');
|
|
148
|
+
const policy = await options.gc.policy();
|
|
149
|
+
if (!policy)
|
|
150
|
+
return problemResponse(409, 'No garbage collection policy has been saved yet, so nothing is deleted. Save one first.');
|
|
151
|
+
const dryRun = ['1', 'true'].includes(url.searchParams.get('dryRun') ?? '');
|
|
152
|
+
if (!dryRun && !policy.enabled)
|
|
153
|
+
return problemResponse(409, 'The saved garbage collection policy is disabled. Enable it, or use dryRun to preview.');
|
|
154
|
+
return Response.json({ runs: await collectAll(dryRun ? { ...policy, enabled: true } : policy, dryRun), policy }, { headers: { 'cache-control': 'no-store' } });
|
|
155
|
+
}
|
|
74
156
|
if (url.pathname === '/api/projects' && request.method === 'GET') {
|
|
75
157
|
const verdict = auth.check(request, 'read');
|
|
76
158
|
if (!verdict.ok)
|
|
@@ -116,17 +198,9 @@ export function projectServer(options, create) {
|
|
|
116
198
|
return problemResponse(404, 'Unknown project. Publish reports with this project slug first.');
|
|
117
199
|
}
|
|
118
200
|
}
|
|
119
|
-
|
|
120
|
-
if (!child)
|
|
121
|
-
|
|
122
|
-
if (children.size >= 64)
|
|
123
|
-
return problemResponse(429, 'Too many active projects. Retry when another project is idle.');
|
|
124
|
-
child = { touched: Date.now(), pending: 0, server: create({ ...options,
|
|
125
|
-
...(options.log ? { log: line => options.log({ ...line, project: slug }) } : {}),
|
|
126
|
-
backends: options.backends.map(backend => ({ ...backend, store: scopeStore(backend.store, slug) })),
|
|
127
|
-
...(options.liveStore ? { liveStore: scopeStore(options.liveStore, slug) } : {}), site: undefined, settings: undefined }) };
|
|
128
|
-
children.set(slug, child);
|
|
129
|
-
}
|
|
201
|
+
const child = ensureChild(slug);
|
|
202
|
+
if (!child)
|
|
203
|
+
return problemResponse(429, 'Too many active projects. Retry when another project is idle.');
|
|
130
204
|
child.touched = Date.now();
|
|
131
205
|
child.pending++;
|
|
132
206
|
try {
|
|
@@ -149,6 +223,7 @@ export function projectServer(options, create) {
|
|
|
149
223
|
return problemResponse(503, 'The project backend is unavailable. Retry later.');
|
|
150
224
|
}
|
|
151
225
|
},
|
|
152
|
-
close() { closed = true; clearInterval(timer);
|
|
226
|
+
close() { closed = true; clearInterval(timer); if (gcTimer)
|
|
227
|
+
clearInterval(gcTimer); base.close(); for (const child of children.values())
|
|
153
228
|
child.server.close(); children.clear(); } };
|
|
154
229
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { type GcPolicy, type GcRun, type GcSnapshot } from '../../schema/src/user-state.js';
|
|
1
2
|
import { type ResolvedServerConfig } from './config.js';
|
|
2
|
-
|
|
3
|
+
import { type UserStateStore } from './user-state.js';
|
|
4
|
+
/** The saved editable settings, the active settings and a revision for optimistic concurrency. Authentication token settings are excluded. */
|
|
3
5
|
export type SettingsSnapshot = {
|
|
4
6
|
readonly revision: string;
|
|
5
7
|
readonly settings: Record<string, unknown>;
|
|
@@ -7,17 +9,31 @@ export type SettingsSnapshot = {
|
|
|
7
9
|
readonly restartRequired: boolean;
|
|
8
10
|
readonly precedence: string;
|
|
9
11
|
};
|
|
10
|
-
/** Storage for the admin API; saving is
|
|
12
|
+
/** Storage for the admin API; saving is serialised and refuses a stale revision. */
|
|
11
13
|
export type ServerSettings = {
|
|
12
14
|
read(): Promise<SettingsSnapshot>;
|
|
13
15
|
save(settings: unknown, revision: string): Promise<SettingsSnapshot>;
|
|
14
16
|
};
|
|
17
|
+
/** The garbage collection policy as the admin API and the collector see it. */
|
|
18
|
+
export type GcSettings = {
|
|
19
|
+
/** The policy in S3 with its revision and the last passes. */
|
|
20
|
+
read(): Promise<GcSnapshot>;
|
|
21
|
+
/** Validates and saves a policy; a stale `revision` is refused. */
|
|
22
|
+
save(policy: unknown, revision: string): Promise<GcSnapshot>;
|
|
23
|
+
/** The policy in S3 right now, or `null` when none has been saved. Read from S3 every time. */
|
|
24
|
+
policy(): Promise<GcPolicy | null>;
|
|
25
|
+
/** Remembers the result of a pass, for `read`. */
|
|
26
|
+
record(runs: readonly GcRun[]): void;
|
|
27
|
+
};
|
|
15
28
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
29
|
+
* The settings and garbage collection policy kept in `userState` (S3). Validates a settings change against the
|
|
30
|
+
* whole deployment (file, environment and flags) before saving, so what is saved can start. Never moves report data.
|
|
31
|
+
*
|
|
19
32
|
* @example
|
|
20
|
-
* const settings =
|
|
33
|
+
* const { settings, gc } = storedSettings(config, argv, process.env, process.cwd(), userStateStore(store));
|
|
21
34
|
*/
|
|
22
|
-
export declare function
|
|
35
|
+
export declare function storedSettings(config: ResolvedServerConfig, argv: readonly string[], env: Readonly<Record<string, string | undefined>>, cwd: string, userState: UserStateStore): {
|
|
36
|
+
settings: ServerSettings;
|
|
37
|
+
gc: GcSettings;
|
|
38
|
+
};
|
|
23
39
|
export { SETTINGS_PAGE, renderSettingsPage } from './settings-page.js';
|
|
@@ -1,72 +1,62 @@
|
|
|
1
1
|
/** @module Persisted server settings
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
2
|
+
* The settings page edits two things, and both are kept in the user state document in S3 (`server/state.json`), never on
|
|
3
|
+
* the server's disk: the report and live-run settings (applied at the next start, below the configuration file,
|
|
4
|
+
* the environment and the flags in precedence) and the garbage collection policy (read by the server each time it
|
|
5
|
+
* collects). Storage and authentication are deployment-owned and are not editable here.
|
|
4
6
|
*/
|
|
5
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
-
import { readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
7
|
-
import path from 'node:path';
|
|
8
7
|
import { ServerEditableSettingsSchema } from '../../schema/src/server-config.js';
|
|
9
|
-
import {
|
|
8
|
+
import { GcPolicySchema } from '../../schema/src/user-state.js';
|
|
10
9
|
import { ConfigError, loadServerConfig } from './config.js';
|
|
10
|
+
import { USER_STATE_KEY, UserStateError } from './user-state.js';
|
|
11
11
|
const editable = ServerEditableSettingsSchema;
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const conflict = (error) => {
|
|
13
|
+
if (error instanceof UserStateError)
|
|
14
|
+
throw new ConfigError(error.message);
|
|
15
|
+
throw error;
|
|
16
|
+
};
|
|
15
17
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* The settings and garbage collection policy kept in `userState` (S3). Validates a settings change against the
|
|
19
|
+
* whole deployment (file, environment and flags) before saving, so what is saved can start. Never moves report data.
|
|
20
|
+
*
|
|
19
21
|
* @example
|
|
20
|
-
* const settings =
|
|
22
|
+
* const { settings, gc } = storedSettings(config, argv, process.env, process.cwd(), userStateStore(store));
|
|
21
23
|
*/
|
|
22
|
-
export function
|
|
23
|
-
const
|
|
24
|
-
const active = { data: config.data, backends: config.backends.slice(1), write: config.writeId, outlineBranch: config.outlineBranch, include: config.include, refreshSeconds: config.refreshSeconds, limits: config.limits, heartbeatSeconds: config.heartbeatSeconds, runTimeoutSeconds: config.runTimeoutSeconds, runStallSeconds: config.runStallSeconds, log: config.log };
|
|
24
|
+
export function storedSettings(config, argv, env, cwd, userState) {
|
|
25
|
+
const active = { outlineBranch: config.outlineBranch, include: config.include, refreshSeconds: config.refreshSeconds, limits: config.limits, heartbeatSeconds: config.heartbeatSeconds, runTimeoutSeconds: config.runTimeoutSeconds, runStallSeconds: config.runStallSeconds, log: config.log };
|
|
25
26
|
let saved = false;
|
|
26
|
-
let
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
let lastRuns = [];
|
|
28
|
+
const snapshot = (stored) => ({
|
|
29
|
+
revision: stored.revision, settings: (stored.state.settings ?? {}), active, restartRequired: saved,
|
|
30
|
+
precedence: `Saved in S3 (${USER_STATE_KEY}) and applied after restart. The configuration file, environment variables and command-line flags override them.`
|
|
31
|
+
});
|
|
32
|
+
const gcSnapshot = (stored) => ({
|
|
33
|
+
revision: stored.revision, policy: stored.state.gc ? GcPolicySchema.parse(stored.state.gc) : null, saved: stored.state.gc !== undefined, lastRuns, stateKey: USER_STATE_KEY
|
|
34
|
+
});
|
|
35
|
+
const settings = {
|
|
36
|
+
read: async () => snapshot(await userState.read().catch(conflict)),
|
|
37
|
+
async save(input, revision) {
|
|
38
|
+
const parsed = editable.safeParse(input);
|
|
39
|
+
if (!parsed.success)
|
|
40
|
+
throw new ConfigError(parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '));
|
|
41
|
+
// Prove the exact deployment (file, environment, flags and these settings) can start before keeping them.
|
|
42
|
+
loadServerConfig(argv, env, cwd, undefined, parsed.data);
|
|
43
|
+
// What the person chose, as sent: validated, but without the defaults filled in, so unchosen settings keep following the defaults.
|
|
44
|
+
const stored = await userState.update(revision, state => ({ ...state, settings: input })).catch(conflict);
|
|
45
|
+
saved = true;
|
|
46
|
+
return snapshot(stored);
|
|
47
|
+
}
|
|
33
48
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const base = loadServerConfig(['--config', file], env, cwd, () => text);
|
|
47
|
-
const effective = loadServerConfig([...argv, '--config', file], env, cwd, () => text);
|
|
48
|
-
try {
|
|
49
|
-
for (const backend of [...base.backends, ...effective.backends])
|
|
50
|
-
parseStoreLocation(backend.url, path.dirname(file), env);
|
|
51
|
-
}
|
|
52
|
-
catch (error) {
|
|
53
|
-
throw new ConfigError(error instanceof Error ? error.message : String(error));
|
|
54
|
-
}
|
|
55
|
-
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
56
|
-
try {
|
|
57
|
-
await writeFile(temporary, text, { mode: 0o600, flag: 'wx' });
|
|
58
|
-
if (await readText() !== before)
|
|
59
|
-
throw new ConfigError('Settings changed. Reload them before saving.');
|
|
60
|
-
await rename(temporary, file);
|
|
61
|
-
}
|
|
62
|
-
finally {
|
|
63
|
-
await rm(temporary, { force: true });
|
|
64
|
-
}
|
|
65
|
-
saved = true;
|
|
66
|
-
return read();
|
|
67
|
-
});
|
|
68
|
-
pending = update.catch(() => undefined);
|
|
69
|
-
return update;
|
|
70
|
-
} };
|
|
49
|
+
const gc = {
|
|
50
|
+
read: async () => gcSnapshot(await userState.read().catch(conflict)),
|
|
51
|
+
async save(input, revision) {
|
|
52
|
+
const parsed = GcPolicySchema.safeParse(input);
|
|
53
|
+
if (!parsed.success)
|
|
54
|
+
throw new ConfigError(parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; '));
|
|
55
|
+
return gcSnapshot(await userState.update(revision, state => ({ ...state, gc: parsed.data })).catch(conflict));
|
|
56
|
+
},
|
|
57
|
+
policy: async () => { const { state } = await userState.read(); return state.gc ? GcPolicySchema.parse(state.gc) : null; },
|
|
58
|
+
record(runs) { lastRuns = [...runs, ...lastRuns.filter(old => !runs.some(run => run.project === old.project))].slice(0, 50); }
|
|
59
|
+
};
|
|
60
|
+
return { settings, gc };
|
|
71
61
|
}
|
|
72
62
|
export { SETTINGS_PAGE, renderSettingsPage } from './settings-page.js';
|