@aopslabs/aops 0.2.0 → 0.3.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/README.md +29 -23
- package/assets/agent-assets/core/references/aops-cli-core/SKILL.md +9 -7
- package/assets/agent-assets/core/references/chatv3/SKILL.md +2 -1
- package/assets/agent-assets/core/user-guides/aops-cli.md +15 -16
- package/assets/skills/aops-install/SKILL.md +24 -23
- package/dist/commands/agent.js +1 -5
- package/dist/commands/api.js +1 -5
- package/dist/commands/assets.js +2 -134
- package/dist/commands/community-auth.js +1 -1
- package/dist/commands/community-setup.js +91 -68
- package/dist/commands/init.js +5 -17
- package/dist/commands/start.js +5 -76
- package/dist/lib/community-setup-server-env.js +2 -28
- package/dist/lib/setup-docker-server.js +666 -0
- package/dist/lib/setup-init-orchestrator.js +14 -267
- package/dist/lib/setup-install-guide.js +1 -1
- package/dist/lib/tui-launcher.js +0 -4
- package/dist/main.js +0 -2
- package/native/tui/win32-x64/aops-tui.exe +0 -0
- package/package.json +4 -4
- package/dist/commands/community-console.js +0 -109
- package/dist/utils/prompts.js +0 -70
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { resolveCommunityInstanceLayout } from './community-instance-layout.js';
|
|
8
|
+
import { resolveCommunityNativeDefaultSourceRoot } from './community-native-lifecycle.js';
|
|
9
|
+
import { openExternalUrl } from './external-url.js';
|
|
10
|
+
const DEFAULT_PORT = 5900;
|
|
11
|
+
const SERVER_PACKAGE_NAME = '@aopslabs/aops-server';
|
|
12
|
+
const POSTGRES_IMAGE = 'postgres:17-bookworm@sha256:4f736ae292687621d4dbe0d499ffd024a36bd2ee7d8ca6f2ccd4c800f047b394';
|
|
13
|
+
const MANAGED_LABEL_KEY = 'io.aopslab.aops-platform.managed-by';
|
|
14
|
+
const MANAGED_LABEL_VALUE = 'aops-cli';
|
|
15
|
+
const MANAGED_LABEL = `${MANAGED_LABEL_KEY}=${MANAGED_LABEL_VALUE}`;
|
|
16
|
+
function runDockerDefault(args) {
|
|
17
|
+
return spawnSync('docker', [...args], { encoding: 'utf8', windowsHide: true });
|
|
18
|
+
}
|
|
19
|
+
function runDockerRequired(runDocker, args, step) {
|
|
20
|
+
const result = runDocker(args);
|
|
21
|
+
if (result.status !== 0)
|
|
22
|
+
throw new Error(`setup_docker_${step}_failed`);
|
|
23
|
+
return output(result);
|
|
24
|
+
}
|
|
25
|
+
function atomicPrivateEnvironment(target, content) {
|
|
26
|
+
const parent = path.dirname(target);
|
|
27
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
28
|
+
writeFileSync(target, content, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
29
|
+
chmodSync(target, 0o600);
|
|
30
|
+
}
|
|
31
|
+
function removePrivateEnvironment(target) {
|
|
32
|
+
rmSync(target, { force: true });
|
|
33
|
+
}
|
|
34
|
+
function delay(milliseconds) {
|
|
35
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
36
|
+
}
|
|
37
|
+
async function probeHealth(origin) {
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(`${origin}/api/health`, {
|
|
40
|
+
method: 'GET',
|
|
41
|
+
signal: AbortSignal.timeout(5_000),
|
|
42
|
+
});
|
|
43
|
+
return response.ok;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function waitFor(timeoutMilliseconds, sleep, probe, code) {
|
|
50
|
+
const deadline = Date.now() + timeoutMilliseconds;
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
if (await probe())
|
|
53
|
+
return;
|
|
54
|
+
await sleep(1_000);
|
|
55
|
+
}
|
|
56
|
+
throw new Error(code);
|
|
57
|
+
}
|
|
58
|
+
function probePort(port) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
const server = net.createServer();
|
|
61
|
+
server.unref();
|
|
62
|
+
server.once('error', () => resolve(false));
|
|
63
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {
|
|
64
|
+
server.close(() => resolve(true));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function readServerVersion() {
|
|
69
|
+
const packageManifest = fileURLToPath(new URL('../../package.json', import.meta.url));
|
|
70
|
+
try {
|
|
71
|
+
const manifest = JSON.parse(readFileSync(packageManifest, 'utf8'));
|
|
72
|
+
if (typeof manifest.aopsDockerServerVersion === 'string'
|
|
73
|
+
&& /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(manifest.aopsDockerServerVersion))
|
|
74
|
+
return manifest.aopsDockerServerVersion;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
}
|
|
78
|
+
const root = resolveCommunityNativeDefaultSourceRoot();
|
|
79
|
+
const candidates = [
|
|
80
|
+
path.join(root, 'package.json'),
|
|
81
|
+
path.join(root, 'apps', 'aops-server', 'package.json'),
|
|
82
|
+
];
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
try {
|
|
85
|
+
const manifest = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
86
|
+
if (manifest.name === SERVER_PACKAGE_NAME
|
|
87
|
+
&& typeof manifest.version === 'string'
|
|
88
|
+
&& /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(manifest.version))
|
|
89
|
+
return manifest.version;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw new Error('setup_docker_server_package_identity_unavailable');
|
|
95
|
+
}
|
|
96
|
+
function normalizeArchitecture(arch) {
|
|
97
|
+
if (arch === 'x64')
|
|
98
|
+
return 'amd64';
|
|
99
|
+
if (arch === 'arm64')
|
|
100
|
+
return 'arm64';
|
|
101
|
+
throw new Error('setup_docker_architecture_unsupported');
|
|
102
|
+
}
|
|
103
|
+
function validatePort(value) {
|
|
104
|
+
const port = value ?? DEFAULT_PORT;
|
|
105
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
|
106
|
+
throw new Error('setup_docker_port_invalid');
|
|
107
|
+
}
|
|
108
|
+
return port;
|
|
109
|
+
}
|
|
110
|
+
function output(result) {
|
|
111
|
+
return String(result.stdout ?? '').trim();
|
|
112
|
+
}
|
|
113
|
+
function readEnvironmentPort(environmentPath, readEnvironment) {
|
|
114
|
+
try {
|
|
115
|
+
const content = readEnvironment(environmentPath);
|
|
116
|
+
const line = content.split(/\r?\n/).find((candidate) => candidate.startsWith('AOPS_PUBLIC_PORT='));
|
|
117
|
+
if (!line)
|
|
118
|
+
return undefined;
|
|
119
|
+
const value = Number.parseInt(line.slice('AOPS_PUBLIC_PORT='.length), 10);
|
|
120
|
+
return Number.isSafeInteger(value) && value >= 1 && value <= 65_535 ? value : undefined;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function inspectDockerResource(runDocker, kind, name) {
|
|
127
|
+
const result = runDocker([kind, 'inspect', name]);
|
|
128
|
+
if (result.status !== 0) {
|
|
129
|
+
const inspection = detail(result, 'inspection failed');
|
|
130
|
+
if (/no such|not found/i.test(inspection)) {
|
|
131
|
+
return { state: 'absent', owned: false, present: false, inspectable: true };
|
|
132
|
+
}
|
|
133
|
+
return { state: 'unknown', owned: false, present: false, inspectable: false };
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const values = JSON.parse(output(result));
|
|
137
|
+
const first = values[0];
|
|
138
|
+
const labels = kind === 'container' ? first?.Config?.Labels : first?.Labels;
|
|
139
|
+
const owned = labels?.[MANAGED_LABEL_KEY] === MANAGED_LABEL_VALUE;
|
|
140
|
+
const state = kind === 'container' && typeof first?.State?.Status === 'string'
|
|
141
|
+
? first.State.Status
|
|
142
|
+
: 'present';
|
|
143
|
+
return { state, owned, present: true, inspectable: true };
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return { state: 'unknown', owned: false, present: true, inspectable: false };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function dockerSetupIdentity(options, dependencies) {
|
|
150
|
+
const platform = dependencies.platform ?? process.platform;
|
|
151
|
+
if (!['win32', 'linux', 'darwin'].includes(platform))
|
|
152
|
+
throw new Error('setup_docker_platform_unsupported');
|
|
153
|
+
const architecture = normalizeArchitecture(dependencies.arch ?? process.arch);
|
|
154
|
+
const layout = resolveCommunityInstanceLayout({
|
|
155
|
+
instanceId: options.instance,
|
|
156
|
+
platform,
|
|
157
|
+
environment: dependencies.environment,
|
|
158
|
+
homeDirectory: dependencies.homeDirectory,
|
|
159
|
+
});
|
|
160
|
+
const environmentPath = path.join(layout.configDir, 'docker.env');
|
|
161
|
+
const environmentPort = (dependencies.pathExists ?? existsSync)(environmentPath)
|
|
162
|
+
? readEnvironmentPort(environmentPath, dependencies.readPrivateEnvironment ?? ((target) => readFileSync(target, 'utf8')))
|
|
163
|
+
: undefined;
|
|
164
|
+
const port = validatePort(options.port ?? environmentPort);
|
|
165
|
+
const serverVersion = (dependencies.resolveServerVersion ?? readServerVersion)();
|
|
166
|
+
const image = options.image ?? `aops-platform:registry-${serverVersion}-${architecture}`;
|
|
167
|
+
const pullImage = options.pullImage ?? `ghcr.io/eeemzs/aops-platform:registry-${serverVersion}-${architecture}`;
|
|
168
|
+
if ([image, pullImage].some((candidate) => candidate.trim() !== candidate || candidate.length === 0 || /[\r\n\0]/.test(candidate))) {
|
|
169
|
+
throw new Error('setup_docker_image_invalid');
|
|
170
|
+
}
|
|
171
|
+
const prefix = `aops-${layout.instanceId}`;
|
|
172
|
+
return Object.freeze({
|
|
173
|
+
instance: layout.instanceId,
|
|
174
|
+
image,
|
|
175
|
+
pullImage,
|
|
176
|
+
port,
|
|
177
|
+
origin: `http://127.0.0.1:${port}`,
|
|
178
|
+
environmentPath,
|
|
179
|
+
resources: Object.freeze({
|
|
180
|
+
serverContainer: `${prefix}-server`,
|
|
181
|
+
postgresContainer: `${prefix}-postgres`,
|
|
182
|
+
network: `${prefix}-network`,
|
|
183
|
+
serverDataVolume: `${prefix}-server-data`,
|
|
184
|
+
postgresDataVolume: `${prefix}-postgres-data`,
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function detail(result, fallback) {
|
|
189
|
+
return String(result.stderr ?? result.stdout ?? fallback).trim() || fallback;
|
|
190
|
+
}
|
|
191
|
+
function resourceCheck(runDocker, kind, name) {
|
|
192
|
+
const result = runDocker([kind, 'inspect', name]);
|
|
193
|
+
if (result.status === 0) {
|
|
194
|
+
return { id: `${kind}:${name}`, status: 'fail', summary: `${name} already exists; choose repair or another instance.` };
|
|
195
|
+
}
|
|
196
|
+
const inspection = detail(result, 'inspection failed');
|
|
197
|
+
return /no such|not found/i.test(inspection)
|
|
198
|
+
? { id: `${kind}:${name}`, status: 'pass', summary: `${name} is available.` }
|
|
199
|
+
: { id: `${kind}:${name}`, status: 'fail', summary: `${name} could not be inspected: ${inspection}` };
|
|
200
|
+
}
|
|
201
|
+
function imageCheck(runDocker, image, serverVersion, architecture, pullImage) {
|
|
202
|
+
const result = runDocker(['image', 'inspect', image]);
|
|
203
|
+
if (result.status !== 0) {
|
|
204
|
+
return {
|
|
205
|
+
id: 'image',
|
|
206
|
+
status: 'info',
|
|
207
|
+
summary: `${image} is not on this computer yet; setup will download ${pullImage}.`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const inspected = JSON.parse(output(result));
|
|
212
|
+
const first = inspected[0];
|
|
213
|
+
const labels = first?.Config?.Labels ?? {};
|
|
214
|
+
const valid = first?.Os === 'linux'
|
|
215
|
+
&& first.Architecture === architecture
|
|
216
|
+
&& labels['io.aopslab.aops-platform.install-mode'] === 'registry'
|
|
217
|
+
&& labels['io.aopslab.aops-platform.server-version'] === serverVersion
|
|
218
|
+
&& labels['org.opencontainers.image.title'] === 'AOPS Server';
|
|
219
|
+
return valid
|
|
220
|
+
? { id: 'image', status: 'pass', summary: `${image} is the expected server-only image.` }
|
|
221
|
+
: { id: 'image', status: 'fail', summary: `${image} does not match the AOPS Server ${serverVersion} contract.` };
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return { id: 'image', status: 'fail', summary: `${image} returned an unreadable inspection result.` };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export async function inspectDockerServerSetupPreview(options = {}, dependencies = {}) {
|
|
228
|
+
const platform = dependencies.platform ?? process.platform;
|
|
229
|
+
if (!['win32', 'linux', 'darwin'].includes(platform)) {
|
|
230
|
+
throw new Error('setup_docker_platform_unsupported');
|
|
231
|
+
}
|
|
232
|
+
const architecture = normalizeArchitecture(dependencies.arch ?? process.arch);
|
|
233
|
+
const port = validatePort(options.port);
|
|
234
|
+
const layout = resolveCommunityInstanceLayout({
|
|
235
|
+
instanceId: options.instance,
|
|
236
|
+
platform,
|
|
237
|
+
environment: dependencies.environment,
|
|
238
|
+
homeDirectory: dependencies.homeDirectory,
|
|
239
|
+
});
|
|
240
|
+
const serverVersion = (dependencies.resolveServerVersion ?? readServerVersion)();
|
|
241
|
+
const image = options.image ?? `aops-platform:registry-${serverVersion}-${architecture}`;
|
|
242
|
+
const pullImage = options.pullImage
|
|
243
|
+
?? `ghcr.io/eeemzs/aops-platform:registry-${serverVersion}-${architecture}`;
|
|
244
|
+
if ([image, pullImage].some((candidate) => candidate.trim() !== candidate || candidate.length === 0 || /[\r\n\0]/.test(candidate))) {
|
|
245
|
+
throw new Error('setup_docker_image_invalid');
|
|
246
|
+
}
|
|
247
|
+
const prefix = `aops-${layout.instanceId}`;
|
|
248
|
+
const resources = Object.freeze({
|
|
249
|
+
serverContainer: `${prefix}-server`,
|
|
250
|
+
postgresContainer: `${prefix}-postgres`,
|
|
251
|
+
network: `${prefix}-network`,
|
|
252
|
+
serverDataVolume: `${prefix}-server-data`,
|
|
253
|
+
postgresDataVolume: `${prefix}-postgres-data`,
|
|
254
|
+
});
|
|
255
|
+
const environmentPath = path.join(layout.configDir, 'docker.env');
|
|
256
|
+
const runDocker = dependencies.runDocker ?? runDockerDefault;
|
|
257
|
+
const checks = [];
|
|
258
|
+
const daemon = runDocker(['info', '--format', '{{json .ServerVersion}}']);
|
|
259
|
+
checks.push(daemon.status === 0
|
|
260
|
+
? { id: 'docker', status: 'pass', summary: `Docker is ready (${output(daemon) || 'server available'}).` }
|
|
261
|
+
: { id: 'docker', status: 'fail', summary: `Docker is not ready: ${detail(daemon, 'start Docker Desktop')}` });
|
|
262
|
+
if (daemon.status === 0) {
|
|
263
|
+
checks.push(imageCheck(runDocker, image, serverVersion, architecture, pullImage));
|
|
264
|
+
checks.push(resourceCheck(runDocker, 'container', resources.serverContainer));
|
|
265
|
+
checks.push(resourceCheck(runDocker, 'container', resources.postgresContainer));
|
|
266
|
+
checks.push(resourceCheck(runDocker, 'network', resources.network));
|
|
267
|
+
checks.push(resourceCheck(runDocker, 'volume', resources.serverDataVolume));
|
|
268
|
+
checks.push(resourceCheck(runDocker, 'volume', resources.postgresDataVolume));
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
checks.push({ id: 'image', status: 'fail', summary: 'Start Docker to inspect the local AOPS image.' });
|
|
272
|
+
}
|
|
273
|
+
const available = await (dependencies.portAvailable ?? probePort)(port);
|
|
274
|
+
checks.push(available
|
|
275
|
+
? { id: 'port', status: 'pass', summary: `127.0.0.1:${port} is available.` }
|
|
276
|
+
: { id: 'port', status: 'fail', summary: `127.0.0.1:${port} is already in use.` });
|
|
277
|
+
checks.push((dependencies.pathExists ?? existsSync)(environmentPath)
|
|
278
|
+
? { id: 'environment', status: 'fail', summary: `${environmentPath} already exists; use settings or repair.` }
|
|
279
|
+
: { id: 'environment', status: 'pass', summary: `${environmentPath} is available.` });
|
|
280
|
+
const failed = checks.filter((check) => check.status === 'fail');
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
schemaVersion: 1,
|
|
283
|
+
command: 'setup.docker.preview',
|
|
284
|
+
mutationFree: true,
|
|
285
|
+
status: failed.length === 0 ? 'ready' : 'action-required',
|
|
286
|
+
method: Object.freeze({
|
|
287
|
+
id: 'docker-managed',
|
|
288
|
+
title: 'Docker (recommended)',
|
|
289
|
+
recommended: true,
|
|
290
|
+
description: 'AOPS Server and PostgreSQL run in isolated Docker resources.',
|
|
291
|
+
}),
|
|
292
|
+
generated: Object.freeze({
|
|
293
|
+
instance: layout.instanceId,
|
|
294
|
+
image,
|
|
295
|
+
pullImage,
|
|
296
|
+
port,
|
|
297
|
+
origin: `http://127.0.0.1:${port}`,
|
|
298
|
+
environmentPath,
|
|
299
|
+
postgresPassword: 'generated-during-apply',
|
|
300
|
+
resources,
|
|
301
|
+
}),
|
|
302
|
+
checks: Object.freeze(checks),
|
|
303
|
+
nextActions: Object.freeze(failed.map((check) => check.summary)),
|
|
304
|
+
applyAvailable: failed.length === 0,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
export async function applyDockerServerSetup(options = {}, dependencies = {}) {
|
|
308
|
+
const preview = await inspectDockerServerSetupPreview(options, dependencies);
|
|
309
|
+
if (!preview.applyAvailable) {
|
|
310
|
+
throw new Error(`setup_docker_not_ready:${preview.nextActions[0] ?? 'review_preview'}`);
|
|
311
|
+
}
|
|
312
|
+
const runDocker = dependencies.runDocker ?? runDockerDefault;
|
|
313
|
+
const sleep = dependencies.sleep ?? delay;
|
|
314
|
+
const writeEnvironment = dependencies.writePrivateEnvironment ?? atomicPrivateEnvironment;
|
|
315
|
+
const removeEnvironment = dependencies.removePrivateEnvironment ?? removePrivateEnvironment;
|
|
316
|
+
const password = (dependencies.createSecret ?? (() => randomBytes(32).toString('base64url')))();
|
|
317
|
+
if (!/^[A-Za-z0-9_-]{32,128}$/.test(password)) {
|
|
318
|
+
throw new Error('setup_docker_generated_secret_invalid');
|
|
319
|
+
}
|
|
320
|
+
const { resources } = preview.generated;
|
|
321
|
+
const created = [];
|
|
322
|
+
const steps = [];
|
|
323
|
+
let environmentWritten = false;
|
|
324
|
+
try {
|
|
325
|
+
const imageState = preview.checks.find((check) => check.id === 'image');
|
|
326
|
+
if (imageState?.status === 'info') {
|
|
327
|
+
runDockerRequired(runDocker, ['pull', preview.generated.pullImage], 'image_pull');
|
|
328
|
+
runDockerRequired(runDocker, ['image', 'tag', preview.generated.pullImage, preview.generated.image], 'image_tag');
|
|
329
|
+
}
|
|
330
|
+
const verifiedImage = imageCheck(runDocker, preview.generated.image, (dependencies.resolveServerVersion ?? readServerVersion)(), normalizeArchitecture(dependencies.arch ?? process.arch), preview.generated.pullImage);
|
|
331
|
+
if (verifiedImage.status !== 'pass')
|
|
332
|
+
throw new Error('setup_docker_image_contract_invalid');
|
|
333
|
+
steps.push({ id: imageState?.status === 'info' ? 'image-pull' : 'image-check', status: 'completed' });
|
|
334
|
+
const encodedPassword = encodeURIComponent(password);
|
|
335
|
+
const environmentContent = [
|
|
336
|
+
'POSTGRES_DB=aops',
|
|
337
|
+
'POSTGRES_USER=aops',
|
|
338
|
+
`POSTGRES_PASSWORD=${password}`,
|
|
339
|
+
`AOPS_PG_URL=postgresql://aops:${encodedPassword}@postgres:5432/aops`,
|
|
340
|
+
'AOPS_POSTGRES_TLS=disable',
|
|
341
|
+
`AOPS_PUBLIC_PORT=${preview.generated.port}`,
|
|
342
|
+
'AOPS_AUTH_PROVIDER=trusted-local',
|
|
343
|
+
'',
|
|
344
|
+
].join('\n');
|
|
345
|
+
writeEnvironment(preview.generated.environmentPath, environmentContent);
|
|
346
|
+
environmentWritten = true;
|
|
347
|
+
steps.push({ id: 'environment', status: 'completed' });
|
|
348
|
+
runDockerRequired(runDocker, ['network', 'create', '--label', MANAGED_LABEL, resources.network], 'network_create');
|
|
349
|
+
created.push({ kind: 'network', name: resources.network });
|
|
350
|
+
for (const name of [resources.serverDataVolume, resources.postgresDataVolume]) {
|
|
351
|
+
runDockerRequired(runDocker, ['volume', 'create', '--label', MANAGED_LABEL, name], 'volume_create');
|
|
352
|
+
created.push({ kind: 'volume', name });
|
|
353
|
+
}
|
|
354
|
+
runDockerRequired(runDocker, [
|
|
355
|
+
'run', '--detach', '--name', resources.postgresContainer,
|
|
356
|
+
'--label', MANAGED_LABEL,
|
|
357
|
+
'--restart', 'unless-stopped',
|
|
358
|
+
'--network', resources.network,
|
|
359
|
+
'--network-alias', 'postgres',
|
|
360
|
+
'--env-file', preview.generated.environmentPath,
|
|
361
|
+
'--volume', `${resources.postgresDataVolume}:/var/lib/postgresql/data`,
|
|
362
|
+
POSTGRES_IMAGE,
|
|
363
|
+
], 'postgres_create');
|
|
364
|
+
created.push({ kind: 'container', name: resources.postgresContainer });
|
|
365
|
+
await waitFor(90_000, sleep, () => {
|
|
366
|
+
const result = runDocker([
|
|
367
|
+
'exec', resources.postgresContainer,
|
|
368
|
+
'pg_isready', '-U', 'aops', '-d', 'aops',
|
|
369
|
+
]);
|
|
370
|
+
return result.status === 0;
|
|
371
|
+
}, 'setup_docker_postgres_timeout');
|
|
372
|
+
steps.push({ id: 'postgres', status: 'completed' });
|
|
373
|
+
runDockerRequired(runDocker, [
|
|
374
|
+
'run', '--detach', '--name', resources.serverContainer,
|
|
375
|
+
'--label', MANAGED_LABEL,
|
|
376
|
+
'--restart', 'unless-stopped',
|
|
377
|
+
'--network', resources.network,
|
|
378
|
+
'--publish', `127.0.0.1:${preview.generated.port}:5900`,
|
|
379
|
+
'--env-file', preview.generated.environmentPath,
|
|
380
|
+
'--volume', `${resources.serverDataVolume}:/var/lib/aops`,
|
|
381
|
+
preview.generated.image,
|
|
382
|
+
], 'server_create');
|
|
383
|
+
created.push({ kind: 'container', name: resources.serverContainer });
|
|
384
|
+
steps.push({ id: 'server', status: 'completed' });
|
|
385
|
+
await waitFor(180_000, sleep, () => (dependencies.healthProbe ?? probeHealth)(preview.generated.origin), 'setup_docker_health_timeout');
|
|
386
|
+
steps.push({ id: 'migrations-health', status: 'completed' });
|
|
387
|
+
return Object.freeze({
|
|
388
|
+
schemaVersion: 1,
|
|
389
|
+
command: 'setup.docker.apply',
|
|
390
|
+
status: 'installed',
|
|
391
|
+
method: 'docker-managed',
|
|
392
|
+
instance: preview.generated.instance,
|
|
393
|
+
image: preview.generated.image,
|
|
394
|
+
origin: preview.generated.origin,
|
|
395
|
+
environmentPath: preview.generated.environmentPath,
|
|
396
|
+
resources: preview.generated.resources,
|
|
397
|
+
steps: Object.freeze(steps),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
for (const resource of [...created].reverse()) {
|
|
402
|
+
if (resource.kind === 'container')
|
|
403
|
+
runDocker(['container', 'rm', '--force', resource.name]);
|
|
404
|
+
else if (resource.kind === 'volume')
|
|
405
|
+
runDocker(['volume', 'rm', '--force', resource.name]);
|
|
406
|
+
else
|
|
407
|
+
runDocker(['network', 'rm', resource.name]);
|
|
408
|
+
}
|
|
409
|
+
if (environmentWritten)
|
|
410
|
+
removeEnvironment(preview.generated.environmentPath);
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
export async function inspectDockerServerSetupStatus(options = {}, dependencies = {}) {
|
|
415
|
+
const identity = dockerSetupIdentity(options, dependencies);
|
|
416
|
+
const runDocker = dependencies.runDocker ?? runDockerDefault;
|
|
417
|
+
const environmentPresent = (dependencies.pathExists ?? existsSync)(identity.environmentPath);
|
|
418
|
+
const daemon = runDocker(['info', '--format', '{{json .ServerVersion}}']);
|
|
419
|
+
if (daemon.status !== 0) {
|
|
420
|
+
const status = environmentPresent ? 'blocked' : 'absent';
|
|
421
|
+
return Object.freeze({
|
|
422
|
+
schemaVersion: 1,
|
|
423
|
+
command: 'setup.docker.status',
|
|
424
|
+
mutationFree: true,
|
|
425
|
+
status,
|
|
426
|
+
method: 'docker-managed',
|
|
427
|
+
instance: identity.instance,
|
|
428
|
+
origin: identity.origin,
|
|
429
|
+
environmentPath: identity.environmentPath,
|
|
430
|
+
resources: identity.resources,
|
|
431
|
+
components: Object.freeze({
|
|
432
|
+
server: Object.freeze({ state: 'unknown', owned: false }),
|
|
433
|
+
database: Object.freeze({ state: 'unknown', owned: false }),
|
|
434
|
+
network: Object.freeze({ state: 'unknown', owned: false }),
|
|
435
|
+
serverData: Object.freeze({ state: 'unknown', owned: false }),
|
|
436
|
+
databaseData: Object.freeze({ state: 'unknown', owned: false }),
|
|
437
|
+
environment: Object.freeze({ state: environmentPresent ? 'present' : 'absent' }),
|
|
438
|
+
}),
|
|
439
|
+
health: 'not-checked',
|
|
440
|
+
actions: Object.freeze({ start: false, stop: false, restart: false, openCockpit: false, repair: false }),
|
|
441
|
+
nextAction: environmentPresent
|
|
442
|
+
? 'Start Docker Desktop, then refresh.'
|
|
443
|
+
: 'Choose Docker setup to install AOPS.',
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const observed = Object.freeze({
|
|
447
|
+
server: inspectDockerResource(runDocker, 'container', identity.resources.serverContainer),
|
|
448
|
+
database: inspectDockerResource(runDocker, 'container', identity.resources.postgresContainer),
|
|
449
|
+
network: inspectDockerResource(runDocker, 'network', identity.resources.network),
|
|
450
|
+
serverData: inspectDockerResource(runDocker, 'volume', identity.resources.serverDataVolume),
|
|
451
|
+
databaseData: inspectDockerResource(runDocker, 'volume', identity.resources.postgresDataVolume),
|
|
452
|
+
});
|
|
453
|
+
const resources = Object.values(observed);
|
|
454
|
+
const anyPresent = resources.some((resource) => resource.present);
|
|
455
|
+
const allPresent = resources.every((resource) => resource.present);
|
|
456
|
+
const allInspectable = resources.every((resource) => resource.inspectable);
|
|
457
|
+
const allOwned = resources.filter((resource) => resource.present).every((resource) => resource.owned);
|
|
458
|
+
const prefix = [
|
|
459
|
+
observed.network.present,
|
|
460
|
+
observed.serverData.present,
|
|
461
|
+
observed.databaseData.present,
|
|
462
|
+
observed.database.present,
|
|
463
|
+
observed.server.present,
|
|
464
|
+
];
|
|
465
|
+
const validInterruptedPrefix = prefix.every((present, index) => present || prefix.slice(index + 1).every((later) => !later));
|
|
466
|
+
const complete = allPresent && environmentPresent && allInspectable && allOwned;
|
|
467
|
+
const containersRunning = observed.server.state === 'running' && observed.database.state === 'running';
|
|
468
|
+
const containersStopped = ['created', 'exited'].includes(observed.server.state)
|
|
469
|
+
&& ['created', 'exited'].includes(observed.database.state);
|
|
470
|
+
const health = containersRunning
|
|
471
|
+
? (await (dependencies.healthProbe ?? probeHealth)(identity.origin) ? 'healthy' : 'unreachable')
|
|
472
|
+
: 'not-checked';
|
|
473
|
+
let status;
|
|
474
|
+
let nextAction;
|
|
475
|
+
if (!anyPresent && !environmentPresent) {
|
|
476
|
+
status = 'absent';
|
|
477
|
+
nextAction = 'Choose Docker setup to install AOPS.';
|
|
478
|
+
}
|
|
479
|
+
else if (!allInspectable || !allOwned) {
|
|
480
|
+
status = 'blocked';
|
|
481
|
+
nextAction = 'These Docker resources are not safely owned by this AOPS installation.';
|
|
482
|
+
}
|
|
483
|
+
else if (!complete) {
|
|
484
|
+
status = environmentPresent && validInterruptedPrefix ? 'repair-required' : 'blocked';
|
|
485
|
+
nextAction = status === 'repair-required'
|
|
486
|
+
? 'Setup was interrupted. Choose Repair setup to continue without deleting database data.'
|
|
487
|
+
: 'The installation layout is incomplete and cannot be repaired automatically.';
|
|
488
|
+
}
|
|
489
|
+
else if (containersRunning && health === 'healthy') {
|
|
490
|
+
status = 'ready';
|
|
491
|
+
nextAction = null;
|
|
492
|
+
}
|
|
493
|
+
else if (containersStopped) {
|
|
494
|
+
status = 'stopped';
|
|
495
|
+
nextAction = 'Choose Start AOPS.';
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
status = 'attention';
|
|
499
|
+
nextAction = 'Choose Restart AOPS, then refresh.';
|
|
500
|
+
}
|
|
501
|
+
return Object.freeze({
|
|
502
|
+
schemaVersion: 1,
|
|
503
|
+
command: 'setup.docker.status',
|
|
504
|
+
mutationFree: true,
|
|
505
|
+
status,
|
|
506
|
+
method: 'docker-managed',
|
|
507
|
+
instance: identity.instance,
|
|
508
|
+
origin: identity.origin,
|
|
509
|
+
environmentPath: identity.environmentPath,
|
|
510
|
+
resources: identity.resources,
|
|
511
|
+
components: Object.freeze({
|
|
512
|
+
server: Object.freeze({ state: observed.server.state, owned: observed.server.owned }),
|
|
513
|
+
database: Object.freeze({ state: observed.database.state, owned: observed.database.owned }),
|
|
514
|
+
network: Object.freeze({ state: observed.network.state, owned: observed.network.owned }),
|
|
515
|
+
serverData: Object.freeze({ state: observed.serverData.state, owned: observed.serverData.owned }),
|
|
516
|
+
databaseData: Object.freeze({ state: observed.databaseData.state, owned: observed.databaseData.owned }),
|
|
517
|
+
environment: Object.freeze({ state: environmentPresent ? 'present' : 'absent' }),
|
|
518
|
+
}),
|
|
519
|
+
health,
|
|
520
|
+
actions: Object.freeze({
|
|
521
|
+
start: complete && status === 'stopped',
|
|
522
|
+
stop: complete && (observed.server.state === 'running' || observed.database.state === 'running'),
|
|
523
|
+
restart: complete,
|
|
524
|
+
openCockpit: status === 'ready',
|
|
525
|
+
repair: status === 'repair-required',
|
|
526
|
+
}),
|
|
527
|
+
nextAction,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function ensureRepairImage(identity, dependencies, runDocker) {
|
|
531
|
+
let checked = imageCheck(runDocker, identity.image, (dependencies.resolveServerVersion ?? readServerVersion)(), normalizeArchitecture(dependencies.arch ?? process.arch), identity.pullImage);
|
|
532
|
+
if (checked.status === 'info') {
|
|
533
|
+
runDockerRequired(runDocker, ['pull', identity.pullImage], 'image_pull');
|
|
534
|
+
runDockerRequired(runDocker, ['image', 'tag', identity.pullImage, identity.image], 'image_tag');
|
|
535
|
+
checked = imageCheck(runDocker, identity.image, (dependencies.resolveServerVersion ?? readServerVersion)(), normalizeArchitecture(dependencies.arch ?? process.arch), identity.pullImage);
|
|
536
|
+
}
|
|
537
|
+
if (checked.status !== 'pass')
|
|
538
|
+
throw new Error('setup_docker_image_contract_invalid');
|
|
539
|
+
}
|
|
540
|
+
export async function runDockerServerLifecycle(action, options = {}, dependencies = {}) {
|
|
541
|
+
const before = await inspectDockerServerSetupStatus(options, dependencies);
|
|
542
|
+
const allowed = action === 'start' ? before.actions.start
|
|
543
|
+
: action === 'stop' ? before.actions.stop
|
|
544
|
+
: action === 'restart' ? before.actions.restart
|
|
545
|
+
: action === 'open-cockpit' ? before.actions.openCockpit
|
|
546
|
+
: before.actions.repair;
|
|
547
|
+
if (!allowed)
|
|
548
|
+
throw new Error(`setup_docker_${action}_not_available:${before.status}`);
|
|
549
|
+
if (action === 'open-cockpit') {
|
|
550
|
+
await (dependencies.openUrl ?? openExternalUrl)(before.origin);
|
|
551
|
+
return Object.freeze({
|
|
552
|
+
schemaVersion: 1,
|
|
553
|
+
command: 'setup.docker.lifecycle',
|
|
554
|
+
status: 'completed',
|
|
555
|
+
action,
|
|
556
|
+
changed: false,
|
|
557
|
+
instance: before.instance,
|
|
558
|
+
origin: before.origin,
|
|
559
|
+
after: before,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
const identity = dockerSetupIdentity(options, dependencies);
|
|
563
|
+
const runDocker = dependencies.runDocker ?? runDockerDefault;
|
|
564
|
+
const sleep = dependencies.sleep ?? delay;
|
|
565
|
+
let changed = false;
|
|
566
|
+
const waitForDatabase = () => waitFor(90_000, sleep, () => {
|
|
567
|
+
const result = runDocker([
|
|
568
|
+
'exec', identity.resources.postgresContainer,
|
|
569
|
+
'pg_isready', '-U', 'aops', '-d', 'aops',
|
|
570
|
+
]);
|
|
571
|
+
return result.status === 0;
|
|
572
|
+
}, 'setup_docker_postgres_timeout');
|
|
573
|
+
const waitForHealth = () => waitFor(180_000, sleep, () => (dependencies.healthProbe ?? probeHealth)(identity.origin), 'setup_docker_health_timeout');
|
|
574
|
+
if (action === 'repair') {
|
|
575
|
+
ensureRepairImage(identity, dependencies, runDocker);
|
|
576
|
+
if (before.components.network.state === 'absent') {
|
|
577
|
+
runDockerRequired(runDocker, ['network', 'create', '--label', MANAGED_LABEL, identity.resources.network], 'network_create');
|
|
578
|
+
changed = true;
|
|
579
|
+
}
|
|
580
|
+
for (const [state, name] of [
|
|
581
|
+
[before.components.serverData.state, identity.resources.serverDataVolume],
|
|
582
|
+
[before.components.databaseData.state, identity.resources.postgresDataVolume],
|
|
583
|
+
]) {
|
|
584
|
+
if (state === 'absent') {
|
|
585
|
+
runDockerRequired(runDocker, ['volume', 'create', '--label', MANAGED_LABEL, name], 'volume_create');
|
|
586
|
+
changed = true;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (before.components.database.state === 'absent') {
|
|
590
|
+
runDockerRequired(runDocker, [
|
|
591
|
+
'run', '--detach', '--name', identity.resources.postgresContainer,
|
|
592
|
+
'--label', MANAGED_LABEL,
|
|
593
|
+
'--restart', 'unless-stopped',
|
|
594
|
+
'--network', identity.resources.network,
|
|
595
|
+
'--network-alias', 'postgres',
|
|
596
|
+
'--env-file', identity.environmentPath,
|
|
597
|
+
'--volume', `${identity.resources.postgresDataVolume}:/var/lib/postgresql/data`,
|
|
598
|
+
POSTGRES_IMAGE,
|
|
599
|
+
], 'postgres_create');
|
|
600
|
+
changed = true;
|
|
601
|
+
}
|
|
602
|
+
else if (before.components.database.state !== 'running') {
|
|
603
|
+
runDockerRequired(runDocker, ['start', identity.resources.postgresContainer], 'postgres_start');
|
|
604
|
+
changed = true;
|
|
605
|
+
}
|
|
606
|
+
await waitForDatabase();
|
|
607
|
+
if (before.components.server.state === 'absent') {
|
|
608
|
+
runDockerRequired(runDocker, [
|
|
609
|
+
'run', '--detach', '--name', identity.resources.serverContainer,
|
|
610
|
+
'--label', MANAGED_LABEL,
|
|
611
|
+
'--restart', 'unless-stopped',
|
|
612
|
+
'--network', identity.resources.network,
|
|
613
|
+
'--publish', `127.0.0.1:${identity.port}:5900`,
|
|
614
|
+
'--env-file', identity.environmentPath,
|
|
615
|
+
'--volume', `${identity.resources.serverDataVolume}:/var/lib/aops`,
|
|
616
|
+
identity.image,
|
|
617
|
+
], 'server_create');
|
|
618
|
+
changed = true;
|
|
619
|
+
}
|
|
620
|
+
else if (before.components.server.state !== 'running') {
|
|
621
|
+
runDockerRequired(runDocker, ['start', identity.resources.serverContainer], 'server_start');
|
|
622
|
+
changed = true;
|
|
623
|
+
}
|
|
624
|
+
await waitForHealth();
|
|
625
|
+
}
|
|
626
|
+
else if (action === 'stop') {
|
|
627
|
+
if (before.components.server.state === 'running') {
|
|
628
|
+
runDockerRequired(runDocker, ['stop', identity.resources.serverContainer], 'server_stop');
|
|
629
|
+
changed = true;
|
|
630
|
+
}
|
|
631
|
+
if (before.components.database.state === 'running') {
|
|
632
|
+
runDockerRequired(runDocker, ['stop', identity.resources.postgresContainer], 'postgres_stop');
|
|
633
|
+
changed = true;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
else if (action === 'start') {
|
|
637
|
+
if (before.components.database.state !== 'running') {
|
|
638
|
+
runDockerRequired(runDocker, ['start', identity.resources.postgresContainer], 'postgres_start');
|
|
639
|
+
changed = true;
|
|
640
|
+
}
|
|
641
|
+
await waitForDatabase();
|
|
642
|
+
if (before.components.server.state !== 'running') {
|
|
643
|
+
runDockerRequired(runDocker, ['start', identity.resources.serverContainer], 'server_start');
|
|
644
|
+
changed = true;
|
|
645
|
+
}
|
|
646
|
+
await waitForHealth();
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
runDockerRequired(runDocker, ['restart', identity.resources.postgresContainer], 'postgres_restart');
|
|
650
|
+
await waitForDatabase();
|
|
651
|
+
runDockerRequired(runDocker, ['restart', identity.resources.serverContainer], 'server_restart');
|
|
652
|
+
await waitForHealth();
|
|
653
|
+
changed = true;
|
|
654
|
+
}
|
|
655
|
+
const after = await inspectDockerServerSetupStatus(options, dependencies);
|
|
656
|
+
return Object.freeze({
|
|
657
|
+
schemaVersion: 1,
|
|
658
|
+
command: 'setup.docker.lifecycle',
|
|
659
|
+
status: 'completed',
|
|
660
|
+
action,
|
|
661
|
+
changed,
|
|
662
|
+
instance: after.instance,
|
|
663
|
+
origin: after.origin,
|
|
664
|
+
after,
|
|
665
|
+
});
|
|
666
|
+
}
|