@crvouga/atlas 0.16.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +52 -8
  3. package/dist/core/src/cli.d.ts +1 -1
  4. package/dist/core/src/cli.js +1 -1
  5. package/dist/reports/src/attachment-files.d.ts +3 -1
  6. package/dist/reports/src/attachment-files.js +14 -4
  7. package/dist/reports/src/evidence-types.d.ts +22 -0
  8. package/dist/reports/src/garbage.d.ts +39 -0
  9. package/dist/reports/src/garbage.js +82 -0
  10. package/dist/reports/src/layer-merge.js +5 -0
  11. package/dist/reports/src/layer-types.d.ts +1 -1
  12. package/dist/reports/src/publish-checks.js +11 -3
  13. package/dist/reports/src/recording-receipt.d.ts +48 -0
  14. package/dist/reports/src/recording-receipt.js +103 -0
  15. package/dist/reports/src/report-cli.d.ts +1 -1
  16. package/dist/reports/src/report-cli.js +20 -2
  17. package/dist/reports/src/report-index.d.ts +6 -0
  18. package/dist/reports/src/report-index.js +14 -0
  19. package/dist/reports/src/report-status.d.ts +2 -0
  20. package/dist/reports/src/report-status.js +17 -1
  21. package/dist/reports/src/reports-explorer-route.d.ts +8 -0
  22. package/dist/reports/src/reports-route.d.ts +8 -0
  23. package/dist/schema/src/api.d.ts +65 -0
  24. package/dist/schema/src/api.js +7 -2
  25. package/dist/schema/src/cli-results.d.ts +107 -2
  26. package/dist/schema/src/cli-results.js +11 -3
  27. package/dist/schema/src/index.d.ts +1 -0
  28. package/dist/schema/src/index.js +1 -0
  29. package/dist/schema/src/layer.d.ts +165 -0
  30. package/dist/schema/src/layer.js +72 -1
  31. package/dist/schema/src/server-config.d.ts +7 -49
  32. package/dist/schema/src/server-config.js +8 -21
  33. package/dist/schema/src/user-state.d.ts +115 -0
  34. package/dist/schema/src/user-state.js +54 -0
  35. package/dist/server/src/app.d.ts +16 -2
  36. package/dist/server/src/app.js +62 -0
  37. package/dist/server/src/config.d.ts +16 -21
  38. package/dist/server/src/config.js +48 -57
  39. package/dist/server/src/dev.d.ts +1 -1
  40. package/dist/server/src/dev.js +20 -7
  41. package/dist/server/src/index.d.ts +5 -1
  42. package/dist/server/src/index.js +5 -1
  43. package/dist/server/src/main.d.ts +23 -7
  44. package/dist/server/src/main.js +72 -26
  45. package/dist/server/src/openapi.js +24 -5
  46. package/dist/server/src/projects.js +89 -14
  47. package/dist/server/src/settings.d.ts +23 -7
  48. package/dist/server/src/settings.js +50 -60
  49. package/dist/server/src/storage.d.ts +68 -0
  50. package/dist/server/src/storage.js +125 -0
  51. package/dist/server/src/user-state.d.ts +31 -0
  52. package/dist/server/src/user-state.js +74 -0
  53. package/docs/agents.md +1 -1
  54. package/docs/deploying.md +38 -39
  55. package/docs/reference/cli.md +17 -7
  56. package/docs/reference/http-api.md +30 -2
  57. package/docs/reference/reports.md +165 -2
  58. package/docs/reference/schema.md +161 -16
  59. package/docs/reference/server.md +263 -41
  60. package/docs/report-backends.md +0 -144
  61. package/docs/report-contract.md +1 -1
  62. package/docs/rich-evidence.md +29 -0
  63. package/docs/runners.md +1 -1
  64. package/docs/server.md +141 -36
  65. package/docs/statecharts.md +1 -1
  66. package/docs/ui.md +1 -1
  67. package/docs/workspaces.md +5 -2
  68. package/llms-full.txt +265 -234
  69. package/llms.txt +1 -1
  70. package/package.json +2 -2
  71. package/packages/reports/STORAGE.md +1 -1
  72. package/packages/schema/schemas/check-detail.schema.json +20 -0
  73. package/packages/schema/schemas/effective-delta.schema.json +61 -0
  74. package/packages/schema/schemas/effective-evidence.schema.json +61 -0
  75. package/packages/schema/schemas/evidence-page.schema.json +45 -0
  76. package/packages/schema/schemas/gc-policy.schema.json +36 -0
  77. package/packages/schema/schemas/gc-run-result.schema.json +106 -0
  78. package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
  79. package/packages/schema/schemas/layer-rows.schema.json +16 -0
  80. package/packages/schema/schemas/layer.schema.json +153 -0
  81. package/packages/schema/schemas/publish-checks.schema.json +24 -0
  82. package/packages/schema/schemas/publish-result.schema.json +161 -1
  83. package/packages/schema/schemas/report-status.schema.json +104 -0
  84. package/packages/schema/schemas/server-config.schema.json +0 -41
  85. package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
  86. package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
  87. package/packages/schema/schemas/user-state.schema.json +140 -0
  88. package/packages/server/README.md +11 -6
  89. package/packages/server/openapi.json +843 -128
  90. package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
  91. package/packages/visualizer/src/evidence/marks.tsx +16 -0
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * @module Server configuration
3
3
  *
4
- * Loads how the server runs: `atlas.server.json`, then environment variables, then flags, each overriding the last, checked
5
- * against `ServerConfigSchema` and resolved (paths made absolute, backends named, tokens read). Every mistake is a
6
- * `ConfigError` that says what to change, and a server that would listen beyond this machine with no tokens refuses to
7
- * start. `SERVER_SETTINGS` documents every setting once; the table in docs/server.md is generated from it and a test proves
8
- * each row.
4
+ * Loads how the server runs: the settings saved in S3 (the user state), then `atlas.server.json`, then environment
5
+ * variables, then flags, each overriding the last, checked against `ServerConfigSchema` and resolved (storage read from
6
+ * the required `ATLAS_S3_*` variables, tokens read). Storage is never optional: all reports and user state live in
7
+ * S3-compatible object storage, so missing or invalid storage variables are an `S3ConfigError` naming every variable.
8
+ * Every other mistake is a `ConfigError` that says what to change, and a server that would listen beyond this machine
9
+ * with no tokens refuses to start. `SERVER_SETTINGS` documents every setting once; the table in docs/server.md is
10
+ * generated from it and a test proves each row.
9
11
  *
10
12
  * @see docs/server.md#configuration
11
13
  */
12
14
  import { type ServerConfig, type ServerRole } from '../../schema/src/server-config.js';
15
+ import { type S3Config } from './storage.js';
13
16
  type Env = Readonly<Record<string, string | undefined>>;
14
17
  /** A token as the server holds it: the `name` it is shown under in logs, its `role`, and the secret `token`. */
15
18
  export type ResolvedToken = {
@@ -17,19 +20,10 @@ export type ResolvedToken = {
17
20
  readonly role: ServerRole;
18
21
  readonly token: string;
19
22
  };
20
- /** A backend as configured: its `id`, the `label` people see, and the `url` (a folder, `s3://…`, or another server) to open. */
21
- export type BackendSpec = {
22
- readonly id: string;
23
- readonly label: string;
24
- readonly url: string;
25
- };
26
23
  /** The configuration the server runs with: the file, then the environment, then the flags, checked and resolved. */
27
- export type ResolvedServerConfig = Omit<ServerConfig, 'backends' | 'auth' | 'write'> & {
28
- /** The folder that is the default backend, absolute. */
29
- readonly data: string;
30
- /** Every backend, the data folder first. All are read; uploads go to `writeId`. */
31
- readonly backends: readonly BackendSpec[];
32
- readonly writeId: string;
24
+ export type ResolvedServerConfig = Omit<ServerConfig, 'auth'> & {
25
+ /** The S3-compatible storage that holds every report and all user state. Required; there is no other. */
26
+ readonly s3: S3Config;
33
27
  readonly tokens: readonly ResolvedToken[];
34
28
  readonly anonymousRead: boolean;
35
29
  readonly insecure: boolean;
@@ -71,13 +65,14 @@ export type ServerSetting = {
71
65
  */
72
66
  export declare const SERVER_SETTINGS: readonly ServerSetting[];
73
67
  /** The text `atlas serve --help` prints. Also the source of the `atlas serve` section of `docs/reference/cli.md`. */
74
- export declare const SERVER_USAGE = "atlas serve: the Atlas server\n\n atlas serve [--config atlas.server.json] [--data <dir>] [--port 8080] [--host 127.0.0.1]\n [--store <url|id=url>]... [--write <id>] [--site <dir>]\n [--outline-branch <b>] [--branch <name>]... [--ignore-modified] [--refresh <seconds>]\n [--anonymous-read] [--insecure] [--no-log]\n\n One process serves the client and the API. Test results are uploaded to it from anywhere (laptops, CI) and it\n shows everyone the combined report, live. It keeps its data in --data (default ./atlas-data) and can read more\n backends (--store): S3, other servers, folders. Uploads go to the data folder unless --write names another.\n\n Tokens (ATLAS_TOKEN admin, ATLAS_PUBLISH_TOKEN, ATLAS_READ_TOKEN, or \"auth.tokens\" in the config) are required\n to listen beyond this machine unless --insecure. Every option has an environment variable (docs/deploying.md).\n";
68
+ export declare const SERVER_USAGE = "atlas serve: the Atlas server\n\n atlas serve [--config atlas.server.json] [--port 8080] [--host 127.0.0.1] [--site <dir>]\n [--outline-branch <b>] [--branch <name>]... [--ignore-modified] [--refresh <seconds>]\n [--anonymous-read] [--insecure] [--no-log]\n\n One process serves the client and the API. Test results are uploaded to it from anywhere (laptops, CI) and it\n shows everyone the combined report, live.\n\n Storage is required. ALL reports and ALL user state (the garbage collection policy, saved settings) are kept in\n S3-compatible object storage and nowhere else; there is no local-disk, in-memory or database fallback. Without\n these environment variables the server prints every one of them with what is wrong, and exits with status 2:\n ATLAS_S3_ENDPOINT ATLAS_S3_BUCKET ATLAS_S3_REGION ATLAS_S3_ACCESS_KEY_ID ATLAS_S3_SECRET_ACCESS_KEY\n Optional: ATLAS_S3_PREFIX, ATLAS_S3_PATH_STYLE, ATLAS_S3_SESSION_TOKEN. See .env.example.\n\n Tokens (ATLAS_TOKEN admin, ATLAS_PUBLISH_TOKEN, ATLAS_READ_TOKEN, or \"auth.tokens\" in the config) are required\n to listen beyond this machine unless --insecure. Every option has an environment variable (docs/deploying.md).\n";
75
69
  /**
76
70
  * Read the file named by `--config`/`ATLAS_CONFIG`, or `atlas.server.json` in the folder if there is one, lay the
77
- * environment and then the flags over it, and check the result. Every problem is a {@link ConfigError} that says
78
- * what to change.
71
+ * environment and then the flags over it, and check the result. `saved` is the settings kept in S3 (the user state),
72
+ * the lowest layer. Every problem is a {@link ConfigError} that says what to change; missing or invalid storage
73
+ * variables are an `S3ConfigError` that names all of them, and are checked first.
79
74
  */
80
- export declare function loadServerConfig(argv: readonly string[], env: Env, cwd: string, read?: (file: string) => string): ResolvedServerConfig;
75
+ export declare function loadServerConfig(argv: readonly string[], env: Env, cwd: string, read?: (file: string) => string, saved?: Record<string, unknown>): ResolvedServerConfig;
81
76
  /** A server that anyone on the network can upload to must say who may. */
82
77
  export declare function assertSafeToListen(config: Pick<ResolvedServerConfig, 'host' | 'tokens' | 'insecure'>): void;
83
78
  export {};
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * @module Server configuration
3
3
  *
4
- * Loads how the server runs: `atlas.server.json`, then environment variables, then flags, each overriding the last, checked
5
- * against `ServerConfigSchema` and resolved (paths made absolute, backends named, tokens read). Every mistake is a
6
- * `ConfigError` that says what to change, and a server that would listen beyond this machine with no tokens refuses to
7
- * start. `SERVER_SETTINGS` documents every setting once; the table in docs/server.md is generated from it and a test proves
8
- * each row.
4
+ * Loads how the server runs: the settings saved in S3 (the user state), then `atlas.server.json`, then environment
5
+ * variables, then flags, each overriding the last, checked against `ServerConfigSchema` and resolved (storage read from
6
+ * the required `ATLAS_S3_*` variables, tokens read). Storage is never optional: all reports and user state live in
7
+ * S3-compatible object storage, so missing or invalid storage variables are an `S3ConfigError` naming every variable.
8
+ * Every other mistake is a `ConfigError` that says what to change, and a server that would listen beyond this machine
9
+ * with no tokens refuses to start. `SERVER_SETTINGS` documents every setting once; the table in docs/server.md is
10
+ * generated from it and a test proves each row.
9
11
  *
10
12
  * @see docs/server.md#configuration
11
13
  */
@@ -13,13 +15,14 @@ import { existsSync, readFileSync } from 'node:fs';
13
15
  import path from 'node:path';
14
16
  import { parseArgs } from 'node:util';
15
17
  import { ServerConfigSchema } from '../../schema/src/server-config.js';
18
+ import { resolveS3Config, S3_VARIABLES } from './storage.js';
16
19
  /** A configuration that cannot be used. The message says what to change; `atlas serve` prints it and exits 2. */
17
20
  export class ConfigError extends Error {
18
21
  }
19
22
  const DEFAULT_FILE = 'atlas.server.json';
20
23
  const MIN_TOKEN = 12;
21
24
  const OPTIONS = {
22
- config: { type: 'string' }, data: { type: 'string' }, store: { type: 'string', multiple: true }, write: { type: 'string' },
25
+ config: { type: 'string' },
23
26
  host: { type: 'string' }, port: { type: 'string' }, site: { type: 'string' }, 'outline-branch': { type: 'string' },
24
27
  branch: { type: 'string', multiple: true }, 'ignore-modified': { type: 'boolean' }, refresh: { type: 'string' },
25
28
  insecure: { type: 'boolean' }, 'no-log': { type: 'boolean' }, 'anonymous-read': { type: 'boolean' }
@@ -33,11 +36,23 @@ export const SERVER_FLAGS = Object.keys(OPTIONS).map(name => `--${name}`);
33
36
  * table; one documented here that the loader ignores fails the test.
34
37
  */
35
38
  export const SERVER_SETTINGS = [
39
+ // Storage is required and comes only from the environment: reports and user state live in S3 and nowhere else.
40
+ ...(Object.entries({
41
+ ATLAS_S3_ENDPOINT: ['endpoint', 'S3 endpoint URL', c => c.s3.endpoint, 'https://s3.example.com'],
42
+ ATLAS_S3_BUCKET: ['bucket', 'Bucket for all reports and user state', c => c.s3.bucket, 'atlas-reports'],
43
+ ATLAS_S3_REGION: ['region', 'Bucket region', c => c.s3.region, 'eu-west-1'],
44
+ ATLAS_S3_ACCESS_KEY_ID: ['accessKeyId', 'Access key id', c => c.s3.accessKeyId, 'AKIAEXAMPLE'],
45
+ ATLAS_S3_SECRET_ACCESS_KEY: ['secretAccessKey', 'Secret access key', c => c.s3.secretAccessKey, 'secret-example-key'],
46
+ ATLAS_S3_PREFIX: ['prefix', 'Folder in the bucket for everything Atlas writes', c => c.s3.prefix, 'atlas'],
47
+ ATLAS_S3_PATH_STYLE: ['pathStyle', 'Path-style bucket addressing (`0` for virtual-hosted)', c => c.s3.pathStyle, '0'],
48
+ ATLAS_S3_SESSION_TOKEN: ['sessionToken', 'Session token for temporary credentials', c => c.s3.sessionToken, 'session-example-token']
49
+ }).map(([variable, [field, summary, read, value]]) => {
50
+ const required = S3_VARIABLES.find(item => item.name === variable).required;
51
+ const expected = field === 'pathStyle' ? false : value;
52
+ return { summary: `${summary} (${required ? '**required**' : 'optional'})`, key: `storage.s3.${field}`, flag: null, env: [variable], default: required ? '**none: the server refuses to start**' : field === 'pathStyle' ? 'yes' : field === 'prefix' ? 'the bucket root' : 'none', example: { value, read, expected } };
53
+ })),
36
54
  { summary: 'Address to listen on', key: 'host', flag: '--host <address>', env: ['ATLAS_HOST', 'HOST'], default: '`127.0.0.1`', example: { value: '0.0.0.0', read: c => c.host, expected: '0.0.0.0' } },
37
55
  { summary: 'Port (`0` picks a free one)', key: 'port', flag: '--port <n>', env: ['ATLAS_PORT', 'PORT'], default: '`8080`', example: { value: '9191', read: c => c.port, expected: 9191 } },
38
- { summary: 'The server\'s own folder: the default backend and write target', key: 'data', flag: '--data <dir>', env: ['ATLAS_DATA'], default: '`./atlas-data`', example: { value: 'results', read: c => path.basename(c.data), expected: 'results' } },
39
- { summary: 'More backends, read together with the data folder', key: 'backends', flag: '--store <[id=]url>` (repeatable)', env: ['ATLAS_STORE'], default: 'none', example: { value: 'lab=https://lab.example.com', read: c => c.backends.map(backend => backend.id), expected: ['data', 'lab'] } },
40
- { summary: 'The backend uploads go to', key: 'write', flag: '--write <id>', env: ['ATLAS_WRITE'], default: '`data`', example: { value: 'data', read: c => c.writeId, expected: 'data' } },
41
56
  { summary: 'The built client', key: 'site', flag: '--site <dir>', env: ['ATLAS_SITE'], default: '`./atlas-site`, else built once', example: { value: 'client', read: c => path.basename(c.site ?? ''), expected: 'client' } },
42
57
  { summary: 'Whose specs the report is laid over', key: 'outlineBranch', flag: '--outline-branch <branch>', env: ['ATLAS_OUTLINE_BRANCH'], default: '`main`, then `master`, then the newest', example: { value: 'trunk', read: c => c.outlineBranch, expected: 'trunk' } },
43
58
  { summary: 'Only layers recorded on these branches take part', key: 'include.branches', flag: '--branch <name>` (repeatable)', env: ['ATLAS_ONLY_BRANCH'], default: 'all', example: { value: 'main', read: c => c.include.branches, expected: ['main'] } },
@@ -58,14 +73,18 @@ export const SERVER_SETTINGS = [
58
73
  /** The text `atlas serve --help` prints. Also the source of the `atlas serve` section of `docs/reference/cli.md`. */
59
74
  export const SERVER_USAGE = `atlas serve: the Atlas server
60
75
 
61
- atlas serve [--config atlas.server.json] [--data <dir>] [--port 8080] [--host 127.0.0.1]
62
- [--store <url|id=url>]... [--write <id>] [--site <dir>]
76
+ atlas serve [--config atlas.server.json] [--port 8080] [--host 127.0.0.1] [--site <dir>]
63
77
  [--outline-branch <b>] [--branch <name>]... [--ignore-modified] [--refresh <seconds>]
64
78
  [--anonymous-read] [--insecure] [--no-log]
65
79
 
66
80
  One process serves the client and the API. Test results are uploaded to it from anywhere (laptops, CI) and it
67
- shows everyone the combined report, live. It keeps its data in --data (default ./atlas-data) and can read more
68
- backends (--store): S3, other servers, folders. Uploads go to the data folder unless --write names another.
81
+ shows everyone the combined report, live.
82
+
83
+ Storage is required. ALL reports and ALL user state (the garbage collection policy, saved settings) are kept in
84
+ S3-compatible object storage and nowhere else; there is no local-disk, in-memory or database fallback. Without
85
+ these environment variables the server prints every one of them with what is wrong, and exits with status 2:
86
+ ATLAS_S3_ENDPOINT ATLAS_S3_BUCKET ATLAS_S3_REGION ATLAS_S3_ACCESS_KEY_ID ATLAS_S3_SECRET_ACCESS_KEY
87
+ Optional: ATLAS_S3_PREFIX, ATLAS_S3_PATH_STYLE, ATLAS_S3_SESSION_TOKEN. See .env.example.
69
88
 
70
89
  Tokens (ATLAS_TOKEN admin, ATLAS_PUBLISH_TOKEN, ATLAS_READ_TOKEN, or "auth.tokens" in the config) are required
71
90
  to listen beyond this machine unless --insecure. Every option has an environment variable (docs/deploying.md).
@@ -80,24 +99,16 @@ function parseFlags(argv) {
80
99
  }
81
100
  const list = (text) => (text ?? '').split(/[\n,]+/).map(item => item.trim()).filter(Boolean);
82
101
  const truthy = (value) => value === '1' || value === 'true';
83
- /** `id=url`, or a bare url with an id made from it. */
84
- function backendOf(spec, taken) {
85
- const named = /^([a-z0-9-]+)=(.+)$/.exec(spec);
86
- if (named)
87
- return { id: named[1], url: named[2] };
88
- const base = spec.replace(/^[a-z0-9+.-]+:\/\//i, '').replace(/[?#].*$/, '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase().slice(0, 40) || 'store';
89
- let id = base;
90
- for (let n = 2; taken.has(id); n++)
91
- id = `${base}-${n}`;
92
- return { id, url: spec };
93
- }
94
102
  /**
95
103
  * Read the file named by `--config`/`ATLAS_CONFIG`, or `atlas.server.json` in the folder if there is one, lay the
96
- * environment and then the flags over it, and check the result. Every problem is a {@link ConfigError} that says
97
- * what to change.
104
+ * environment and then the flags over it, and check the result. `saved` is the settings kept in S3 (the user state),
105
+ * the lowest layer. Every problem is a {@link ConfigError} that says what to change; missing or invalid storage
106
+ * variables are an `S3ConfigError` that names all of them, and are checked first.
98
107
  */
99
- export function loadServerConfig(argv, env, cwd, read = file => readFileSync(file, 'utf8')) {
108
+ export function loadServerConfig(argv, env, cwd, read = file => readFileSync(file, 'utf8'), saved = {}) {
100
109
  const flags = parseFlags(argv);
110
+ // Storage first: nothing else matters while there is nowhere to keep the reports. Throws S3ConfigError naming every variable.
111
+ const s3 = resolveS3Config(env);
101
112
  const named = flags.config ?? env.ATLAS_CONFIG;
102
113
  const file = named ? path.resolve(cwd, named) : existsSync(path.join(cwd, DEFAULT_FILE)) ? path.join(cwd, DEFAULT_FILE) : null;
103
114
  let raw = {};
@@ -109,18 +120,21 @@ export function loadServerConfig(argv, env, cwd, read = file => readFileSync(fil
109
120
  throw new ConfigError(`Cannot read ${file}: ${error instanceof Error ? error.message : String(error)}`);
110
121
  }
111
122
  }
123
+ for (const removed of ['data', 'backends', 'write']) {
124
+ if (removed in raw)
125
+ throw new ConfigError(`${file}: "${removed}" is no longer supported. Reports and user state are kept only in S3-compatible storage, set with the required ATLAS_S3_* environment variables (see .env.example). Remove "${removed}" from the file.`);
126
+ }
112
127
  const base = file ? path.dirname(file) : cwd;
128
+ // The settings saved in S3 are the lowest layer: the file, the environment and the flags all override them.
113
129
  const auth = { ...raw.auth };
114
- const include = { ...raw.include };
115
- const merged = { ...raw, auth, include };
130
+ const include = { ...saved.include, ...raw.include };
131
+ const merged = { ...saved, ...raw, auth, include };
116
132
  const set = (key, value) => { if (value !== undefined)
117
133
  merged[key] = value; };
118
134
  // ATLAS_HOST and ATLAS_PORT first: HOST is often the machine's own name, and PORT belongs to whatever else is running here.
119
135
  set('host', flags.host ?? env.ATLAS_HOST ?? env.HOST);
120
136
  const port = env.ATLAS_PORT ?? env.PORT;
121
137
  set('port', flags.port !== undefined ? Number(flags.port) : port !== undefined ? Number(port) : undefined);
122
- set('data', flags.data ?? env.ATLAS_DATA);
123
- set('write', flags.write ?? env.ATLAS_WRITE);
124
138
  set('site', flags.site ?? env.ATLAS_SITE);
125
139
  set('outlineBranch', flags['outline-branch'] ?? env.ATLAS_OUTLINE_BRANCH);
126
140
  set('refreshSeconds', flags.refresh !== undefined ? Number(flags.refresh) : env.ATLAS_REFRESH_SECONDS !== undefined ? Number(env.ATLAS_REFRESH_SECONDS) : undefined);
@@ -131,7 +145,7 @@ export function loadServerConfig(argv, env, cwd, read = file => readFileSync(fil
131
145
  set('runTimeoutSeconds', env.ATLAS_RUN_TIMEOUT_SECONDS === undefined ? undefined : Number(env.ATLAS_RUN_TIMEOUT_SECONDS));
132
146
  set('runStallSeconds', env.ATLAS_RUN_STALL_SECONDS === undefined ? undefined : Number(env.ATLAS_RUN_STALL_SECONDS));
133
147
  set('heartbeatSeconds', env.ATLAS_HEARTBEAT_SECONDS === undefined ? undefined : Number(env.ATLAS_HEARTBEAT_SECONDS));
134
- const limits = { ...raw.limits };
148
+ const limits = { ...saved.limits, ...raw.limits };
135
149
  for (const [key, variable] of [['maxObjectMiB', 'ATLAS_MAX_OBJECT_MIB'], ['maxJsonMiB', 'ATLAS_MAX_JSON_MIB'], ['maxStreams', 'ATLAS_MAX_STREAMS']]) {
136
150
  if (env[variable] !== undefined)
137
151
  limits[key] = Number(env[variable]);
@@ -150,29 +164,6 @@ export function loadServerConfig(argv, env, cwd, read = file => readFileSync(fil
150
164
  if (!parsed.success)
151
165
  throw new ConfigError(`${file ? `${file}: ` : 'Configuration: '}${parsed.error.issues.map(issue => `${issue.path.join('.') || '(config)'}: ${issue.message}`).join('; ')}`);
152
166
  const config = parsed.data;
153
- // Backends: the data folder, then those in the file, then --store and ATLAS_STORE.
154
- const taken = new Set(['data']);
155
- const backends = [{ id: 'data', label: 'Server data', url: path.resolve(base, config.data) }];
156
- const extra = config.backends.map(item => ({ id: item.id, label: item.label ?? item.id, url: item.url, write: item.write === true }));
157
- for (const item of extra) {
158
- if (taken.has(item.id))
159
- throw new ConfigError(`Two backends are called "${item.id}" ("data" is the data folder)`);
160
- taken.add(item.id);
161
- backends.push({ id: item.id, label: item.label, url: item.url });
162
- }
163
- for (const spec of [...(flags.store ?? []), ...(flags.store ? [] : list(env.ATLAS_STORE))]) {
164
- const item = backendOf(spec, taken);
165
- if (taken.has(item.id))
166
- throw new ConfigError(`Two backends are called "${item.id}"`);
167
- taken.add(item.id);
168
- backends.push({ id: item.id, label: item.id, url: item.url });
169
- }
170
- const marked = extra.filter(item => item.write).map(item => item.id);
171
- if (marked.length > 1)
172
- throw new ConfigError(`Only one backend can take uploads; ${marked.join(', ')} are all marked write`);
173
- const writeId = config.write ?? marked[0] ?? 'data';
174
- if (!backends.some(item => item.id === writeId))
175
- throw new ConfigError(`write names "${writeId}", which is not a backend (${backends.map(item => item.id).join(', ')})`);
176
167
  // Tokens: the file's, then the environment's.
177
168
  const tokens = [];
178
169
  for (const item of config.auth.tokens) {
@@ -190,8 +181,8 @@ export function loadServerConfig(argv, env, cwd, read = file => readFileSync(fil
190
181
  throw new ConfigError(`Token "${item.name}" is shorter than ${MIN_TOKEN} characters: use a long random one (openssl rand -hex 24)`);
191
182
  if (new Set(tokens.map(item => item.token)).size !== tokens.length)
192
183
  throw new ConfigError('Two tokens are the same: each token must say who it is');
193
- const { backends: _backends, auth: _auth, write: _write, ...rest } = config;
194
- const resolved = { ...rest, data: backends[0].url, backends, writeId, tokens, anonymousRead: config.auth.anonymousRead, insecure: config.auth.insecure, configFile: file, ...(config.site ? { site: path.resolve(base, config.site) } : {}) };
184
+ const { auth: _auth, ...rest } = config;
185
+ const resolved = { ...rest, s3, tokens, anonymousRead: config.auth.anonymousRead, insecure: config.auth.insecure, configFile: file, ...(config.site ? { site: path.resolve(base, config.site) } : {}) };
195
186
  assertSafeToListen(resolved);
196
187
  return resolved;
197
188
  }
@@ -7,6 +7,6 @@
7
7
  */
8
8
  import { type ServeIo } from './main.js';
9
9
  /** The text `atlas dev --help` prints. Also the source of the `atlas dev` section of `docs/reference/cli.md`. */
10
- export declare const DEV_USAGE = "atlas dev: the Atlas server and the client, for working on either\n\n atlas dev [--port 5180] [--host 127.0.0.1] [--data <dir>] [--store <url|id=url>]... [--config atlas.server.json]\n\n Starts the server (API only, on a port of its own) and the client's dev server, which proxies /api to it, so the\n browser talks to one host and a change to the client shows at once. In production `atlas serve` does both from\n one process.\n";
10
+ export declare const DEV_USAGE = "atlas dev: the Atlas server and the client, for working on either\n\n atlas dev [--port 5180] [--host 127.0.0.1] [--config atlas.server.json]\n\n Starts the server (API only, on a port of its own) and the client's dev server, which proxies /api to it, so the\n browser talks to one host and a change to the client shows at once. In production `atlas serve` does both from\n one process. Storage is the same as for `atlas serve`: the required ATLAS_S3_* environment variables name the S3\n bucket that holds all reports and user state; without them it exits with status 2 and lists every variable.\n";
11
11
  /** `atlas dev`. */
12
12
  export declare function devMain(argv: readonly string[], io: ServeIo): Promise<number>;
@@ -6,20 +6,23 @@
6
6
  * uploaders and the client all use one host, exactly as in production, and a change to the client shows at once.
7
7
  */
8
8
  import path from 'node:path';
9
- import { openConfiguredBackends, serverOptionsOf } from './main.js';
9
+ import { connectStorage, serverOptionsOf } from './main.js';
10
10
  import { createAtlasServer } from './app.js';
11
11
  import { ConfigError, loadServerConfig, SERVER_USAGE } from './config.js';
12
+ import { describeS3, S3ConfigError } from './storage.js';
13
+ import { userStateStore } from './user-state.js';
12
14
  import { listen } from './host.js';
13
- import { fileServerSettings } from './settings.js';
15
+ import { storedSettings } from './settings.js';
14
16
  import { visualizerRoot } from './site.js';
15
17
  /** The text `atlas dev --help` prints. Also the source of the `atlas dev` section of `docs/reference/cli.md`. */
16
18
  export const DEV_USAGE = `atlas dev: the Atlas server and the client, for working on either
17
19
 
18
- atlas dev [--port 5180] [--host 127.0.0.1] [--data <dir>] [--store <url|id=url>]... [--config atlas.server.json]
20
+ atlas dev [--port 5180] [--host 127.0.0.1] [--config atlas.server.json]
19
21
 
20
22
  Starts the server (API only, on a port of its own) and the client's dev server, which proxies /api to it, so the
21
23
  browser talks to one host and a change to the client shows at once. In production \`atlas serve\` does both from
22
- one process.
24
+ one process. Storage is the same as for \`atlas serve\`: the required ATLAS_S3_* environment variables name the S3
25
+ bucket that holds all reports and user state; without them it exits with status 2 and lists every variable.
23
26
  `;
24
27
  /** `atlas dev`. */
25
28
  export async function devMain(argv, io) {
@@ -44,14 +47,24 @@ export async function devMain(argv, io) {
44
47
  config = loadServerConfig([...forServer, '--port', '0'], { ...env, PORT: undefined, ATLAS_PORT: undefined }, cwd);
45
48
  }
46
49
  catch (error) {
50
+ if (error instanceof S3ConfigError) {
51
+ io.out(`${error.message}\n`);
52
+ return 2;
53
+ }
47
54
  if (error instanceof ConfigError) {
48
55
  io.out(`atlas dev: ${error.message}\n${SERVER_USAGE.split('\n')[0]}\n`);
49
56
  return 2;
50
57
  }
51
58
  throw error;
52
59
  }
53
- const backends = openConfiguredBackends(config, env, cwd);
54
- const api = createAtlasServer({ ...serverOptionsOf(config, backends, line => io.out(`${JSON.stringify(line)}\n`)), settings: fileServerSettings(config, forServer, env, cwd) });
60
+ const connected = await connectStorage(config, env, io);
61
+ if ('failure' in connected) {
62
+ io.out(`${connected.failure}\n`);
63
+ return 2;
64
+ }
65
+ const { backends } = connected;
66
+ const { settings, gc } = storedSettings(config, forServer, env, cwd, userStateStore(backends[0].store));
67
+ const api = createAtlasServer({ ...serverOptionsOf(config, backends, line => io.out(`${JSON.stringify(line)}\n`)), settings, gc });
55
68
  const running = await listen(api.fetch, { port: 0, host: '127.0.0.1', maxBodyBytes: Math.max(config.limits.maxObjectMiB, config.limits.maxJsonMiB) * 1024 * 1024 });
56
69
  await api.ready;
57
70
  // The client's config reads this to proxy /api, and to write the same page config a server would.
@@ -74,7 +87,7 @@ export async function devMain(argv, io) {
74
87
  throw error;
75
88
  }
76
89
  vite.printUrls();
77
- io.out(`Data in ${path.relative(cwd, config.data) || config.data}. Publish with: ATLAS_STORE=${vite.resolvedUrls?.local[0] ?? `http://localhost:${publicPort}`} atlas report publish …\n`);
90
+ io.out(`Reports are stored in ${describeS3(config.s3)}. Publish with: ATLAS_STORE=${vite.resolvedUrls?.local[0] ?? `http://localhost:${publicPort}`} atlas report publish …\n`);
78
91
  await new Promise(resolve => {
79
92
  io.signal?.addEventListener('abort', () => resolve(), { once: true });
80
93
  if (!io.signal) {
@@ -4,15 +4,19 @@
4
4
  * Everything needed to run the Atlas server from code: `createAtlasServer` (a `fetch` handler with the live report, uploads
5
5
  * and the viewer), `listen` (serve it over HTTP on Bun or Node), `loadServerConfig` (the file, the environment and the flags
6
6
  * as one checked configuration), `serveMain` (what `atlas serve` runs), `EventHub`, `createAuth`, and the upload checks.
7
+ * `atlas serve` keeps everything in S3: `resolveS3Config` reads the required `ATLAS_S3_*` variables, `userStateStore` the user
8
+ * state in the bucket, and `storedSettings` serves the settings page from it.
7
9
  *
8
10
  * @see docs/server.md
9
11
  */
10
12
  export { createAtlasServer, type AtlasServer, type AtlasServerOptions } from './app.js';
11
13
  export { createAuth, type Auth, type Identity } from './auth.js';
12
14
  export { assertSafeToListen, ConfigError, loadServerConfig, SERVER_USAGE, type ResolvedServerConfig, type ResolvedToken } from './config.js';
15
+ export { resolveS3Config, S3_VARIABLES, S3ConfigError, startupFailure, type S3Config } from './storage.js';
16
+ export { userStateStore, USER_STATE_KEY, UserStateError, type UserStateStore } from './user-state.js';
13
17
  export { EventHub, type EventName } from './events.js';
14
18
  export { listen, type RunningServer } from './host.js';
15
19
  export { serveMain } from './main.js';
16
20
  export { classify, validateUpload, validKey, Refusal } from './validate.js';
17
- export { fileServerSettings, type ServerSettings, type SettingsSnapshot } from './settings.js';
21
+ export { storedSettings, type GcSettings, type ServerSettings, type SettingsSnapshot } from './settings.js';
18
22
  export { LiveRuns, LiveRunError } from './live-runs.js';
@@ -4,15 +4,19 @@
4
4
  * Everything needed to run the Atlas server from code: `createAtlasServer` (a `fetch` handler with the live report, uploads
5
5
  * and the viewer), `listen` (serve it over HTTP on Bun or Node), `loadServerConfig` (the file, the environment and the flags
6
6
  * as one checked configuration), `serveMain` (what `atlas serve` runs), `EventHub`, `createAuth`, and the upload checks.
7
+ * `atlas serve` keeps everything in S3: `resolveS3Config` reads the required `ATLAS_S3_*` variables, `userStateStore` the user
8
+ * state in the bucket, and `storedSettings` serves the settings page from it.
7
9
  *
8
10
  * @see docs/server.md
9
11
  */
10
12
  export { createAtlasServer } from './app.js';
11
13
  export { createAuth } from './auth.js';
12
14
  export { assertSafeToListen, ConfigError, loadServerConfig, SERVER_USAGE } from './config.js';
15
+ export { resolveS3Config, S3_VARIABLES, S3ConfigError, startupFailure } from './storage.js';
16
+ export { userStateStore, USER_STATE_KEY, UserStateError } from './user-state.js';
13
17
  export { EventHub } from './events.js';
14
18
  export { listen } from './host.js';
15
19
  export { serveMain } from './main.js';
16
20
  export { classify, validateUpload, validKey, Refusal } from './validate.js';
17
- export { fileServerSettings } from './settings.js';
21
+ export { storedSettings } from './settings.js';
18
22
  export { LiveRuns, LiveRunError } from './live-runs.js';
@@ -1,30 +1,46 @@
1
1
  /**
2
2
  * @module `atlas serve`
3
3
  *
4
- * Starts the Atlas server from its configuration: opens every backend (creating the data folder), proves the upload
5
- * backend can be written to before listening, finds or builds the client, listens at once (so a liveness probe finds the
6
- * process while a large backend is still being read), and stops cleanly on SIGTERM or SIGINT. `serverOptionsOf` is shared
7
- * with `atlas dev`, so the two run the same server.
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
- /** Open every backend of a configuration. The data folder is made if it is not there. */
23
- export declare function openConfiguredBackends(config: ResolvedServerConfig, env: Env, cwd: string): (BackendInput & {
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 {};
@@ -1,38 +1,45 @@
1
1
  /**
2
2
  * @module `atlas serve`
3
3
  *
4
- * Starts the Atlas server from its configuration: opens every backend (creating the data folder), proves the upload
5
- * backend can be written to before listening, finds or builds the client, listens at once (so a liveness probe finds the
6
- * process while a large backend is still being read), and stops cleanly on SIGTERM or SIGINT. `serverOptionsOf` is shared
7
- * with `atlas dev`, so the two run the same server.
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 { openStore } from '../../reports/src/store-url.js';
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 { fileServerSettings } from './settings.js';
18
+ import { storedSettings } from './settings.js';
18
19
  import { findSite } from './site.js';
19
- /** Open every backend of a configuration. The data folder is made if it is not there. */
20
- export function openConfiguredBackends(config, env, cwd) {
21
- return config.backends.map((spec, i) => {
22
- if (i === 0)
23
- mkdirSync(spec.url, { recursive: true });
24
- const opened = openStore(spec.url, { base: cwd, env });
25
- return { id: spec.id, label: spec.label, store: opened.store, writable: spec.id === config.writeId };
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 put things in the place uploads go? Said at the start, not at the first upload. */
29
- async function probeWritable(backend) {
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).catch(() => { });
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: config.writeId,
50
- ...(/^https?:\/\//.test(config.backends.find(backend => backend.id === config.writeId).url) ? { liveStore: backends[0].store } : {}), tokens: config.tokens, anonymousRead: config.anonymousRead,
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 backends = openConfiguredBackends(config, env, cwd);
76
- const writeBackend = backends.find(backend => backend.writable);
77
- io.out(`${config.configFile ? `Configuration ${path.relative(cwd, config.configFile) || config.configFile}. ` : ''}Backends: ${backends.map(backend => `${backend.id}${backend.writable ? ' (uploads)' : ''}`).join(', ')}\n`);
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 server = createAtlasServer({ settings: fileServerSettings(config, argv, env, cwd), ...serverOptionsOf(config, backends, line => io.out(`${JSON.stringify(line)}\n`)), ...(site ? { site } : {}) });
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 });