-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathConfigService.ts
More file actions
232 lines (211 loc) · 8.57 KB
/
ConfigService.ts
File metadata and controls
232 lines (211 loc) · 8.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import { Injectable } from '@nestjs/common';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { logger } from '../logger.js';
import { EMBEDDED_TFSTATE } from '../generated/tfstate.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
// dist/services/ → 4 hops up = app root (repo: app/, Docker: /workspace/app/)
// 5 hops up = repo/workspace root, where terraform/ lives
const APP_ROOT = join(__dirname, '..', '..', '..', '..');
const TF_STATE_PATH =
process.env['TF_STATE_PATH'] ??
join(APP_ROOT, '..', 'terraform', 'terraform.tfstate');
const SERVER_CONFIG_PATH =
process.env['SERVER_CONFIG_PATH'] ??
join(APP_ROOT, 'server_config.json');
/**
* Shape of the subset of Terraform root outputs the management app consumes.
* Mirrors the `output` blocks in `terraform/*.tf`; add fields here (and in
* `getTfOutputs()` below) when a new output becomes a dependency.
*/
export interface TfOutputs {
aws_region: string;
ecs_cluster_name: string;
ecs_cluster_arn: string;
subnet_ids: string;
security_group_id: string;
file_manager_security_group_id: string;
efs_file_system_id: string;
efs_access_points: Record<string, string>;
domain_name: string;
game_names: string[];
alb_dns_name: string | null;
acm_certificate_arn: string | null;
discord_table_name: string;
discord_bot_token_secret_arn: string;
discord_public_key_secret_arn: string;
interactions_invoke_url: string | null;
}
/**
* User-editable watchdog tuning knobs persisted to `server_config.json`.
* Consumed by the watchdog Lambda via Terraform variables; the UI only
* displays/edits them — changes require `terraform apply` to take effect.
*/
export interface WatchdogConfig {
watchdog_interval_minutes: number;
watchdog_idle_checks: number;
watchdog_min_packets: number;
}
const DEFAULT_CONFIG: WatchdogConfig = {
watchdog_interval_minutes: 15,
watchdog_idle_checks: 4,
watchdog_min_packets: 100,
};
/**
* Owns every runtime configuration source the management app reads:
* - `terraform.tfstate` (outputs of the last `terraform apply`) — parsed
* lazily and cached in-memory until {@link ConfigService.invalidateCache}
* is called.
* - `server_config.json` — the user-editable file holding the watchdog
* tunables and the optional API bearer token. Path resolved via
* `SERVER_CONFIG_PATH` env var, defaulting to `<app-root>/server_config.json`.
* - A handful of process env vars (`AWS_DEFAULT_REGION`, `API_TOKEN`).
*
* Every other service injects this one instead of touching `process.env` or
* reading files directly, so tests can stub env/file access cleanly.
*/
@Injectable()
export class ConfigService {
private tfCache: TfOutputs | null = null;
/**
* Drop the cached tfstate parse. Called from the `/api/games` and
* `/api/status` handlers so a fresh `terraform apply` is picked up without
* a server restart; tests also call it between scenarios.
*/
invalidateCache(): void {
this.tfCache = null;
}
/**
* Parse `terraform/terraform.tfstate` (once, then memoised) and project the
* pieces the app cares about. Falls back to the state embedded at build time
* by `scripts/embed-tfstate.mjs` when the runtime file is absent. Returns
* `null` when neither source is available — callers treat that as "infra not
* deployed yet" and degrade gracefully.
*/
getTfOutputs(): TfOutputs | null {
if (this.tfCache) return this.tfCache;
type RawState = { outputs?: Record<string, { value: unknown }> };
let raw: RawState;
if (existsSync(TF_STATE_PATH)) {
try {
raw = JSON.parse(readFileSync(TF_STATE_PATH, 'utf-8')) as RawState;
} catch (err) {
logger.error('Failed to parse Terraform state', { err, path: TF_STATE_PATH });
return null;
}
} else if (EMBEDDED_TFSTATE) {
logger.debug('Using build-time embedded Terraform state');
raw = EMBEDDED_TFSTATE as unknown as RawState;
} else {
logger.warn('Terraform state not found', { path: TF_STATE_PATH });
return null;
}
try {
const out = raw.outputs ?? {};
const get = <T>(key: string, fallback: T): T =>
key in out ? (out[key]!.value as T) : fallback;
this.tfCache = {
aws_region: get('aws_region', 'us-east-1'),
ecs_cluster_name: get('ecs_cluster_name', ''),
ecs_cluster_arn: get('ecs_cluster_arn', ''),
subnet_ids: get('subnet_ids', ''),
security_group_id: get('security_group_id', ''),
file_manager_security_group_id: get('file_manager_security_group_id', ''),
efs_file_system_id: get('efs_file_system_id', ''),
efs_access_points: get('efs_access_points', {}),
domain_name: get('domain_name', ''),
game_names: get('game_names', []),
alb_dns_name: get('alb_dns_name', null),
acm_certificate_arn: get('acm_certificate_arn', null),
discord_table_name: get('discord_table_name', ''),
discord_bot_token_secret_arn: get('discord_bot_token_secret_arn', ''),
discord_public_key_secret_arn: get('discord_public_key_secret_arn', ''),
interactions_invoke_url: get('interactions_invoke_url', null),
};
logger.debug('Loaded Terraform outputs', { games: this.tfCache.game_names });
return this.tfCache;
} catch (err) {
logger.error('Failed to parse Terraform state', { err });
return null;
}
}
/**
* Read the AWS region hint from the process environment.
* Extracted so tests can stub env access via `vi.spyOn` instead of
* mutating `process.env` directly (which is flaky across tests).
*/
readEnvRegion(): string | undefined {
return process.env['AWS_DEFAULT_REGION'];
}
/** Read the API bearer token from `API_TOKEN`. Extracted for test-stubbing. */
readEnvApiToken(): string | undefined {
return process.env['API_TOKEN'];
}
/**
* Token required on every `/api/*` request's `Authorization: Bearer <token>` header.
*
* Resolution order:
* 1. Env var `API_TOKEN` — wins when set, including when explicitly set to an
* empty string. Empty is normalized to `null` (treated as "no token
* configured") so setting `API_TOKEN=""` does not fall back to the file.
* 2. `api_token` field in `server_config.json`.
*
* Returns `null` when no token is configured. The auth middleware + startup
* check interpret null differently depending on environment:
* - `NODE_ENV=production` → `index.ts` refuses to start. An empty env var
* is therefore NOT a supported "auth disabled" mode in production.
* - development → the middleware logs a warning and allows unauthenticated
* requests so local iteration isn't blocked.
*/
getApiToken(): string | null {
const env = this.readEnvApiToken();
if (env !== undefined) {
return env.length > 0 ? env : null;
}
if (!existsSync(SERVER_CONFIG_PATH)) return null;
try {
const raw = JSON.parse(readFileSync(SERVER_CONFIG_PATH, 'utf-8')) as { api_token?: unknown };
return typeof raw.api_token === 'string' && raw.api_token.length > 0 ? raw.api_token : null;
} catch (err) {
logger.warn('Could not read api_token from config file', { err });
return null;
}
}
/**
* Resolve the AWS region for SDK clients. Prefers the region Terraform
* provisioned into (so the app always points at the real infra), falls
* back to `AWS_DEFAULT_REGION`, then to `us-east-1`.
*/
getRegion(): string {
return (
this.getTfOutputs()?.aws_region ??
this.readEnvRegion() ??
'us-east-1'
);
}
/**
* Load the watchdog tunables from `server_config.json`, merged over the
* built-in defaults so partially-populated files still work. Returns a
* fresh object on every call — safe for callers to mutate.
*/
getConfig(): WatchdogConfig {
if (!existsSync(SERVER_CONFIG_PATH)) return { ...DEFAULT_CONFIG };
try {
const saved = JSON.parse(readFileSync(SERVER_CONFIG_PATH, 'utf-8')) as Partial<WatchdogConfig>;
return { ...DEFAULT_CONFIG, ...saved };
} catch (err) {
logger.warn('Could not read config file, using defaults', { err });
return { ...DEFAULT_CONFIG };
}
}
/**
* Persist the full watchdog config to `server_config.json`. Note: the
* watchdog Lambda only reads these values via Terraform variables, so a
* save here is not effective until the next `terraform apply`.
*/
saveConfig(config: WatchdogConfig): void {
writeFileSync(SERVER_CONFIG_PATH, JSON.stringify(config, null, 2));
logger.info('Config saved', config);
}
}