@aopslabs/aops 0.2.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/LICENSE +19 -0
- package/NOTICE +7 -0
- package/README.md +203 -0
- package/assets/agent-assets/core/SKILL.md +49 -0
- package/assets/agent-assets/core/references/agentspace/SKILL.md +78 -0
- package/assets/agent-assets/core/references/aops-cli-core/SKILL.md +108 -0
- package/assets/agent-assets/core/references/chatv3/SKILL.md +48 -0
- package/assets/agent-assets/core/references/collaborative-work/SKILL.md +85 -0
- package/assets/agent-assets/core/references/discuss/SKILL.md +66 -0
- package/assets/agent-assets/core/references/docman/SKILL.md +69 -0
- package/assets/agent-assets/core/references/hosted-chat/SKILL.md +64 -0
- package/assets/agent-assets/core/references/mission/SKILL.md +54 -0
- package/assets/agent-assets/core/references/projectman/SKILL.md +86 -0
- package/assets/agent-assets/core/references/sys/SKILL.md +36 -0
- package/assets/agent-assets/core/references/view/SKILL.md +58 -0
- package/assets/agent-assets/core/references/working-disciplines/SKILL.md +69 -0
- package/assets/agent-assets/core/user-guides/agent-assets.md +81 -0
- package/assets/agent-assets/core/user-guides/agentspace.md +1098 -0
- package/assets/agent-assets/core/user-guides/aops-cli.md +1107 -0
- package/assets/agent-assets/core/user-guides/aops-system.md +63 -0
- package/assets/agent-assets/core/user-guides/chatv3.md +113 -0
- package/assets/agent-assets/core/user-guides/docman.md +162 -0
- package/assets/agent-assets/core/user-guides/projectman.md +212 -0
- package/assets/agent-assets/core/user-guides/sys.md +94 -0
- package/assets/agent-assets/core/user-guides/working-disciplines.md +588 -0
- package/assets/agent-assets/gateway/aops/SKILL.md +14 -0
- package/assets/r6-remote-session-baseline.json +19 -0
- package/assets/skills/aops-install/SKILL.md +231 -0
- package/assets/skills/aops-install/agents/openai.yaml +4 -0
- package/dist/commands/activity.js +352 -0
- package/dist/commands/agent-profile.js +653 -0
- package/dist/commands/agent.js +710 -0
- package/dist/commands/agents-md.js +125 -0
- package/dist/commands/api.js +84 -0
- package/dist/commands/archive.js +873 -0
- package/dist/commands/artifact.js +535 -0
- package/dist/commands/assets.js +1414 -0
- package/dist/commands/auth/device-login.js +1 -0
- package/dist/commands/auth/legacy-export.js +177 -0
- package/dist/commands/auth/login.js +426 -0
- package/dist/commands/chat.js +1363 -0
- package/dist/commands/chatv3.js +1684 -0
- package/dist/commands/checkpoint.js +549 -0
- package/dist/commands/commercial-artifact.js +475 -0
- package/dist/commands/commercial-license.js +594 -0
- package/dist/commands/community-auth.js +62 -0
- package/dist/commands/community-cockpit.js +185 -0
- package/dist/commands/community-console.js +109 -0
- package/dist/commands/community-doctor.js +335 -0
- package/dist/commands/community-server.js +2062 -0
- package/dist/commands/community-setup.js +284 -0
- package/dist/commands/discuss.js +1448 -0
- package/dist/commands/doc.js +5219 -0
- package/dist/commands/experience.js +922 -0
- package/dist/commands/host.js +1127 -0
- package/dist/commands/init.js +118 -0
- package/dist/commands/memory.js +3129 -0
- package/dist/commands/mission.js +562 -0
- package/dist/commands/official-catalog.js +134 -0
- package/dist/commands/plan.js +84 -0
- package/dist/commands/playbook.js +415 -0
- package/dist/commands/pm/context.js +6 -0
- package/dist/commands/pm/index.js +701 -0
- package/dist/commands/pm/projectman.js +6266 -0
- package/dist/commands/project.js +1080 -0
- package/dist/commands/prompt.js +1024 -0
- package/dist/commands/repo-sync.js +1907 -0
- package/dist/commands/resource.js +527 -0
- package/dist/commands/skill.js +1211 -0
- package/dist/commands/start-disciplines.js +510 -0
- package/dist/commands/start.js +1343 -0
- package/dist/commands/target.js +309 -0
- package/dist/commands/version.js +42 -0
- package/dist/commands/view.js +1852 -0
- package/dist/launchers/cockpit.js +28 -0
- package/dist/launchers/options.js +74 -0
- package/dist/launchers/server.js +41 -0
- package/dist/lib/agent-assets/envelope.js +83 -0
- package/dist/lib/agent-assets/gateway.js +15 -0
- package/dist/lib/agent-assets/guards.js +23 -0
- package/dist/lib/agent-assets/hosted-discovery.js +148 -0
- package/dist/lib/agent-assets/hosted-package-input.js +154 -0
- package/dist/lib/agent-assets/legacy-pointer-migration.js +677 -0
- package/dist/lib/agent-assets/native-fs.js +589 -0
- package/dist/lib/agent-assets/package-manifest.js +324 -0
- package/dist/lib/agent-assets/portable-path.js +67 -0
- package/dist/lib/agent-assets/release-input.js +177 -0
- package/dist/lib/agent-assets/roots.js +45 -0
- package/dist/lib/agent-assets/runtime-binding-reader.js +545 -0
- package/dist/lib/agent-assets/runtime-targets.js +50 -0
- package/dist/lib/agent-assets/store-reader.js +1212 -0
- package/dist/lib/agent-assets/store-types.js +1 -0
- package/dist/lib/agent-assets/store-writer.js +1484 -0
- package/dist/lib/agent-assets/types.js +1 -0
- package/dist/lib/agent-assets/unicode-case-folding-v15-1.generated.js +1548 -0
- package/dist/lib/commercial-final-readiness.js +504 -0
- package/dist/lib/commercial-lifecycle-smoke-isolation.js +236 -0
- package/dist/lib/commercial-managed-postgres-docker-adapter.js +274 -0
- package/dist/lib/commercial-setup-readiness-adapter.js +179 -0
- package/dist/lib/community-client-contract.js +164 -0
- package/dist/lib/community-cockpit-lifecycle.js +601 -0
- package/dist/lib/community-command-abort.js +59 -0
- package/dist/lib/community-diagnostic.js +237 -0
- package/dist/lib/community-docker-adapter.js +498 -0
- package/dist/lib/community-home.js +31 -0
- package/dist/lib/community-instance-contract.js +148 -0
- package/dist/lib/community-instance-layout.js +97 -0
- package/dist/lib/community-lifecycle.js +1631 -0
- package/dist/lib/community-migration-snapshot.js +770 -0
- package/dist/lib/community-native-application-recovery.js +425 -0
- package/dist/lib/community-native-child.js +240 -0
- package/dist/lib/community-native-database-recovery.js +456 -0
- package/dist/lib/community-native-lifecycle.js +2458 -0
- package/dist/lib/community-native-migration.js +339 -0
- package/dist/lib/community-native-postgres.js +596 -0
- package/dist/lib/community-operation-journal.js +773 -0
- package/dist/lib/community-operation-lock.js +596 -0
- package/dist/lib/community-release-resolver.js +291 -0
- package/dist/lib/community-release-verifier.js +475 -0
- package/dist/lib/community-repo-discovery.js +252 -0
- package/dist/lib/community-setup-server-env.js +203 -0
- package/dist/lib/external-url.js +110 -0
- package/dist/lib/official-catalog-gateway.js +108 -0
- package/dist/lib/official-catalog.js +364 -0
- package/dist/lib/server-bootstrap.js +240 -0
- package/dist/lib/setup-agent-assets-bridge.js +176 -0
- package/dist/lib/setup-agent-assets-release.js +52 -0
- package/dist/lib/setup-external-postgres.js +108 -0
- package/dist/lib/setup-init-orchestrator.js +990 -0
- package/dist/lib/setup-install-guide.js +52 -0
- package/dist/lib/setup-installer-release.js +47 -0
- package/dist/lib/setup-local-postgres.js +329 -0
- package/dist/lib/setup-official-catalog-bridge.js +148 -0
- package/dist/lib/setup-readiness.js +888 -0
- package/dist/lib/tui-launcher.js +59 -0
- package/dist/main.js +150 -0
- package/dist/seeds/agents-md.js +54 -0
- package/dist/utils/agent-gateway.js +275 -0
- package/dist/utils/agents-md.js +281 -0
- package/dist/utils/api.js +255 -0
- package/dist/utils/chatv3-session-store.js +185 -0
- package/dist/utils/command.js +67 -0
- package/dist/utils/community-host-runtime.js +376 -0
- package/dist/utils/config.js +1015 -0
- package/dist/utils/discussion-workspace.js +552 -0
- package/dist/utils/experience-workspace.js +326 -0
- package/dist/utils/guide-paths.js +67 -0
- package/dist/utils/hosted-sugar.js +56 -0
- package/dist/utils/hosted-workspace.js +229 -0
- package/dist/utils/memory-workspace.js +537 -0
- package/dist/utils/operator-runtime.js +199 -0
- package/dist/utils/password-crypto.js +39 -0
- package/dist/utils/playbook-workspace.js +479 -0
- package/dist/utils/project-context.js +255 -0
- package/dist/utils/prompts.js +70 -0
- package/dist/utils/refresh-race.js +28 -0
- package/dist/utils/repo-config.js +109 -0
- package/dist/utils/repo-first-projectman.js +194 -0
- package/dist/utils/repo-first-storage.js +13 -0
- package/dist/utils/repo-first-view.js +305 -0
- package/dist/utils/session-guidance.js +278 -0
- package/dist/utils/session-state.js +137 -0
- package/dist/utils/table.js +39 -0
- package/dist/utils/transient-fs.js +49 -0
- package/dist/utils/workspace-root.js +1 -0
- package/launchers/aops-cockpit.ps1 +15 -0
- package/launchers/aops-cockpit.sh +16 -0
- package/launchers/aops-server.ps1 +15 -0
- package/launchers/aops-server.sh +16 -0
- package/native/tui/win32-x64/aops-tui.exe +0 -0
- package/package.json +81 -0
|
@@ -0,0 +1,1631 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, existsSync, createReadStream, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync, } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { throwIfCommunityCommandAborted } from './community-command-abort.js';
|
|
6
|
+
import { assertCommunityOperationJournalFence } from './community-operation-journal.js';
|
|
7
|
+
import { runCommunityJournaledPromotion, } from './community-operation-journal.js';
|
|
8
|
+
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
9
|
+
const INSTANCE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
10
|
+
const RELEASE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
11
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
12
|
+
async function runLifecyclePromotion(params) {
|
|
13
|
+
if (params.journal) {
|
|
14
|
+
return runCommunityJournaledPromotion({
|
|
15
|
+
handle: params.journal,
|
|
16
|
+
step: params.step,
|
|
17
|
+
signal: params.signal,
|
|
18
|
+
promote: params.promote,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
throwIfCommunityCommandAborted(params.signal);
|
|
22
|
+
const result = await params.promote();
|
|
23
|
+
throwIfCommunityCommandAborted(params.signal);
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
function sha256(content) {
|
|
27
|
+
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
28
|
+
}
|
|
29
|
+
function redactCommunityDiagnostic(error) {
|
|
30
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
31
|
+
return raw
|
|
32
|
+
.replace(/(postgres(?:ql)?:\/\/[^:\s/@]+:)[^@\s/]+@/gi, '$1[REDACTED]@')
|
|
33
|
+
.replace(/((?:password|passwd|secret|token|api[_-]?key|authorization)\s*[=:]\s*)[^\s,;]+/gi, '$1[REDACTED]')
|
|
34
|
+
.replace(/(--(?:password|passwd|secret|token|api[_-]?key)\s+)[^\s]+/gi, '$1[REDACTED]')
|
|
35
|
+
.replace(/(\bBearer\s+)[A-Za-z0-9._~+/-]+/gi, '$1[REDACTED]')
|
|
36
|
+
.replace(/("(?:password|passwd|secret|token|apiKey|authorization)"\s*:\s*")[^"]*(")/gi, '$1[REDACTED]$2')
|
|
37
|
+
.slice(0, 1000);
|
|
38
|
+
}
|
|
39
|
+
function escapeRegex(value) {
|
|
40
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
}
|
|
42
|
+
export function assertCommunityPostgresVolumeName(state, volumeName) {
|
|
43
|
+
if (typeof volumeName !== 'string' || volumeName.length > 180) {
|
|
44
|
+
throw new Error('community_postgres_volume_identity_invalid');
|
|
45
|
+
}
|
|
46
|
+
const project = escapeRegex(state.composeProjectName);
|
|
47
|
+
const allowed = new RegExp(`^${project}-pg-(?:1|restore-[a-f0-9]{32}|(?:recover|rollback)-[a-f0-9]{32}-[a-f0-9]{32})$`);
|
|
48
|
+
if (!allowed.test(volumeName))
|
|
49
|
+
throw new Error('community_postgres_volume_identity_invalid');
|
|
50
|
+
}
|
|
51
|
+
function assertCommunityPostgresVolumeClaim(state, claim) {
|
|
52
|
+
if (claim?.schemaVersion !== 1 || claim.installId !== state.installId || !UUID.test(claim.operationId)) {
|
|
53
|
+
throw new Error('community_postgres_volume_claim_invalid');
|
|
54
|
+
}
|
|
55
|
+
assertDigest(claim.claimTokenSha256, 'community_postgres_volume_claim_invalid');
|
|
56
|
+
assertCommunityPostgresVolumeName(state, claim.name);
|
|
57
|
+
}
|
|
58
|
+
function createPostgresVolumeClaim(state, name, operationId) {
|
|
59
|
+
if (!UUID.test(operationId))
|
|
60
|
+
throw new Error('community_postgres_volume_claim_invalid');
|
|
61
|
+
const claim = {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
name,
|
|
64
|
+
installId: state.installId,
|
|
65
|
+
operationId,
|
|
66
|
+
claimTokenSha256: sha256(randomBytes(32)),
|
|
67
|
+
};
|
|
68
|
+
assertCommunityPostgresVolumeClaim(state, claim);
|
|
69
|
+
return claim;
|
|
70
|
+
}
|
|
71
|
+
function nowIso(now) {
|
|
72
|
+
return now().toISOString();
|
|
73
|
+
}
|
|
74
|
+
function assertAbsolute(value, code) {
|
|
75
|
+
if (!path.isAbsolute(value))
|
|
76
|
+
throw new Error(code);
|
|
77
|
+
return path.resolve(value);
|
|
78
|
+
}
|
|
79
|
+
function isErrno(error, code) {
|
|
80
|
+
return error?.code === code;
|
|
81
|
+
}
|
|
82
|
+
function isSamePhysicalPath(left, right) {
|
|
83
|
+
return path.relative(left, right) === '' && path.relative(right, left) === '';
|
|
84
|
+
}
|
|
85
|
+
function tryLstat(candidate) {
|
|
86
|
+
try {
|
|
87
|
+
return lstatSync(candidate);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (isErrno(error, 'ENOENT'))
|
|
91
|
+
return undefined;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function assertCanonicalDirectory(directoryPath, code) {
|
|
96
|
+
const resolved = path.resolve(directoryPath);
|
|
97
|
+
const stat = tryLstat(resolved);
|
|
98
|
+
if (!stat || stat.isSymbolicLink() || !stat.isDirectory())
|
|
99
|
+
throw new Error(code);
|
|
100
|
+
const canonical = realpathSync.native(resolved);
|
|
101
|
+
if (!isSamePhysicalPath(resolved, canonical))
|
|
102
|
+
throw new Error(code);
|
|
103
|
+
return canonical;
|
|
104
|
+
}
|
|
105
|
+
function ensureCanonicalDirectoryChain(directoryPath, code) {
|
|
106
|
+
const resolvedTarget = path.resolve(directoryPath);
|
|
107
|
+
const missingSegments = [];
|
|
108
|
+
let existingAncestor = resolvedTarget;
|
|
109
|
+
let stat = tryLstat(existingAncestor);
|
|
110
|
+
while (!stat) {
|
|
111
|
+
const parent = path.dirname(existingAncestor);
|
|
112
|
+
if (isSamePhysicalPath(parent, existingAncestor))
|
|
113
|
+
throw new Error(code);
|
|
114
|
+
missingSegments.unshift(path.basename(existingAncestor));
|
|
115
|
+
existingAncestor = parent;
|
|
116
|
+
stat = tryLstat(existingAncestor);
|
|
117
|
+
}
|
|
118
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
119
|
+
throw new Error(code);
|
|
120
|
+
let canonicalParent = realpathSync.native(existingAncestor);
|
|
121
|
+
if (!isSamePhysicalPath(existingAncestor, canonicalParent))
|
|
122
|
+
throw new Error(code);
|
|
123
|
+
for (const segment of missingSegments) {
|
|
124
|
+
const child = path.join(canonicalParent, segment);
|
|
125
|
+
if (!isSamePhysicalPath(path.dirname(child), canonicalParent))
|
|
126
|
+
throw new Error(code);
|
|
127
|
+
try {
|
|
128
|
+
mkdirSync(child, { mode: 0o700 });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (!isErrno(error, 'EEXIST'))
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
const childStat = tryLstat(child);
|
|
135
|
+
if (!childStat || childStat.isSymbolicLink() || !childStat.isDirectory())
|
|
136
|
+
throw new Error(code);
|
|
137
|
+
const canonicalChild = realpathSync.native(child);
|
|
138
|
+
if (!isSamePhysicalPath(child, canonicalChild) ||
|
|
139
|
+
!isSamePhysicalPath(path.dirname(canonicalChild), canonicalParent)) {
|
|
140
|
+
throw new Error(code);
|
|
141
|
+
}
|
|
142
|
+
canonicalParent = canonicalChild;
|
|
143
|
+
}
|
|
144
|
+
if (!isSamePhysicalPath(canonicalParent, resolvedTarget))
|
|
145
|
+
throw new Error(code);
|
|
146
|
+
return canonicalParent;
|
|
147
|
+
}
|
|
148
|
+
function ensureCanonicalDirectChildDirectory(parentPath, childPath, code) {
|
|
149
|
+
const canonicalParent = assertCanonicalDirectory(parentPath, code);
|
|
150
|
+
const resolvedChild = path.resolve(childPath);
|
|
151
|
+
if (!isSamePhysicalPath(path.dirname(resolvedChild), canonicalParent))
|
|
152
|
+
throw new Error(code);
|
|
153
|
+
let stat = tryLstat(resolvedChild);
|
|
154
|
+
if (!stat) {
|
|
155
|
+
try {
|
|
156
|
+
mkdirSync(resolvedChild, { mode: 0o700 });
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (!isErrno(error, 'EEXIST'))
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
stat = tryLstat(resolvedChild);
|
|
163
|
+
}
|
|
164
|
+
if (!stat || stat.isSymbolicLink() || !stat.isDirectory())
|
|
165
|
+
throw new Error(code);
|
|
166
|
+
const canonicalChild = realpathSync.native(resolvedChild);
|
|
167
|
+
if (!isSamePhysicalPath(resolvedChild, canonicalChild) ||
|
|
168
|
+
!isSamePhysicalPath(path.dirname(canonicalChild), canonicalParent)) {
|
|
169
|
+
throw new Error(code);
|
|
170
|
+
}
|
|
171
|
+
return canonicalChild;
|
|
172
|
+
}
|
|
173
|
+
function assertCanonicalDirectChildRegularFile(parentPath, filePath, code) {
|
|
174
|
+
const canonicalParent = assertCanonicalDirectory(parentPath, code);
|
|
175
|
+
const resolvedFile = path.resolve(filePath);
|
|
176
|
+
if (!isSamePhysicalPath(path.dirname(resolvedFile), canonicalParent))
|
|
177
|
+
throw new Error(code);
|
|
178
|
+
const stat = tryLstat(resolvedFile);
|
|
179
|
+
if (!stat || stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1)
|
|
180
|
+
throw new Error(code);
|
|
181
|
+
const canonicalFile = realpathSync.native(resolvedFile);
|
|
182
|
+
if (!isSamePhysicalPath(resolvedFile, canonicalFile) ||
|
|
183
|
+
!isSamePhysicalPath(path.dirname(canonicalFile), canonicalParent)) {
|
|
184
|
+
throw new Error(code);
|
|
185
|
+
}
|
|
186
|
+
return canonicalFile;
|
|
187
|
+
}
|
|
188
|
+
function writeCanonicalDirectChildFileCreateOnce(parentPath, filePath, content, mode, code) {
|
|
189
|
+
const canonicalParent = assertCanonicalDirectory(parentPath, code);
|
|
190
|
+
const resolvedFile = path.resolve(filePath);
|
|
191
|
+
if (!isSamePhysicalPath(path.dirname(resolvedFile), canonicalParent))
|
|
192
|
+
throw new Error(code);
|
|
193
|
+
writeFileSync(resolvedFile, content, { encoding: 'utf8', flag: 'wx', mode });
|
|
194
|
+
assertCanonicalDirectChildRegularFile(canonicalParent, resolvedFile, code);
|
|
195
|
+
}
|
|
196
|
+
function assertOwnedBackupFile(paths, filePath) {
|
|
197
|
+
const absolute = assertAbsolute(filePath, 'community_backup_absolute_path_required');
|
|
198
|
+
const canonicalRoot = assertCanonicalDirectory(paths.backupRoot, 'community_backup_root_unsafe');
|
|
199
|
+
if (!isSamePhysicalPath(path.dirname(absolute), canonicalRoot)) {
|
|
200
|
+
throw new Error('community_backup_outside_instance');
|
|
201
|
+
}
|
|
202
|
+
return assertCanonicalDirectChildRegularFile(canonicalRoot, absolute, 'community_backup_file_invalid');
|
|
203
|
+
}
|
|
204
|
+
function fileDescriptorIdentity(fd) {
|
|
205
|
+
const stat = fstatSync(fd, { bigint: true });
|
|
206
|
+
if (!stat.isFile() || stat.nlink !== 1n || stat.size <= 0n || stat.size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
207
|
+
throw new Error('community_backup_file_invalid');
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
device: stat.dev.toString(),
|
|
211
|
+
inode: stat.ino.toString(),
|
|
212
|
+
byteLength: Number(stat.size),
|
|
213
|
+
modifiedNs: stat.mtimeNs.toString(),
|
|
214
|
+
changedNs: stat.ctimeNs.toString(),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function pathIdentity(filePath) {
|
|
218
|
+
const stat = lstatSync(filePath, { bigint: true });
|
|
219
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1n ||
|
|
220
|
+
stat.size <= 0n || stat.size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
221
|
+
throw new Error('community_backup_file_invalid');
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
device: stat.dev.toString(),
|
|
225
|
+
inode: stat.ino.toString(),
|
|
226
|
+
byteLength: Number(stat.size),
|
|
227
|
+
modifiedNs: stat.mtimeNs.toString(),
|
|
228
|
+
changedNs: stat.ctimeNs.toString(),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function sameStableFileIdentity(left, right) {
|
|
232
|
+
return left.device === right.device
|
|
233
|
+
&& left.inode === right.inode
|
|
234
|
+
&& left.byteLength === right.byteLength
|
|
235
|
+
&& left.modifiedNs === right.modifiedNs
|
|
236
|
+
&& left.changedNs === right.changedNs;
|
|
237
|
+
}
|
|
238
|
+
function createVerifiedBackupSnapshot(paths, backup, operationId, runtime = {}) {
|
|
239
|
+
const sourcePath = assertOwnedBackupFile(paths, backup.path);
|
|
240
|
+
if (!UUID.test(operationId))
|
|
241
|
+
throw new Error('community_backup_snapshot_operation_id_invalid');
|
|
242
|
+
const snapshotPath = path.join(paths.backupRoot, `.restore-${operationId.replace(/-/g, '')}.snapshot`);
|
|
243
|
+
const sourceFd = openSync(sourcePath, constants.O_RDONLY);
|
|
244
|
+
let destinationFd;
|
|
245
|
+
let createdSnapshotNode;
|
|
246
|
+
try {
|
|
247
|
+
const sourceBefore = fileDescriptorIdentity(sourceFd);
|
|
248
|
+
if (!sameStableFileIdentity(sourceBefore, pathIdentity(sourcePath))) {
|
|
249
|
+
throw new Error('community_backup_path_identity_changed');
|
|
250
|
+
}
|
|
251
|
+
destinationFd = openSync(snapshotPath, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR, 0o600);
|
|
252
|
+
const createdStat = fstatSync(destinationFd, { bigint: true });
|
|
253
|
+
createdSnapshotNode = { device: createdStat.dev.toString(), inode: createdStat.ino.toString() };
|
|
254
|
+
const hash = createHash('sha256');
|
|
255
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
256
|
+
let readPosition = 0;
|
|
257
|
+
while (true) {
|
|
258
|
+
const readLength = readSync(sourceFd, buffer, 0, buffer.length, readPosition);
|
|
259
|
+
if (readLength === 0)
|
|
260
|
+
break;
|
|
261
|
+
hash.update(buffer.subarray(0, readLength));
|
|
262
|
+
let written = 0;
|
|
263
|
+
while (written < readLength) {
|
|
264
|
+
written += writeSync(destinationFd, buffer, written, readLength - written, readPosition + written);
|
|
265
|
+
}
|
|
266
|
+
readPosition += readLength;
|
|
267
|
+
}
|
|
268
|
+
fsyncSync(destinationFd);
|
|
269
|
+
const sourceAfter = fileDescriptorIdentity(sourceFd);
|
|
270
|
+
const sourceAtPath = pathIdentity(sourcePath);
|
|
271
|
+
if (!sameStableFileIdentity(sourceBefore, sourceAfter) ||
|
|
272
|
+
!sameStableFileIdentity(sourceAfter, sourceAtPath)) {
|
|
273
|
+
throw new Error('community_backup_path_identity_changed');
|
|
274
|
+
}
|
|
275
|
+
const observedDigest = `sha256:${hash.digest('hex')}`;
|
|
276
|
+
if (readPosition !== backup.byteLength || sourceAfter.byteLength !== backup.byteLength) {
|
|
277
|
+
throw new Error('community_backup_snapshot_length_mismatch');
|
|
278
|
+
}
|
|
279
|
+
if (observedDigest !== backup.sha256)
|
|
280
|
+
throw new Error('community_backup_snapshot_digest_mismatch');
|
|
281
|
+
const snapshotIdentity = fileDescriptorIdentity(destinationFd);
|
|
282
|
+
if (snapshotIdentity.device !== createdSnapshotNode.device ||
|
|
283
|
+
snapshotIdentity.inode !== createdSnapshotNode.inode ||
|
|
284
|
+
snapshotIdentity.byteLength !== backup.byteLength) {
|
|
285
|
+
throw new Error('community_backup_snapshot_identity_changed');
|
|
286
|
+
}
|
|
287
|
+
const snapshotHash = createHash('sha256');
|
|
288
|
+
let snapshotLength = 0;
|
|
289
|
+
while (true) {
|
|
290
|
+
const readLength = readSync(destinationFd, buffer, 0, buffer.length, snapshotLength);
|
|
291
|
+
if (readLength === 0)
|
|
292
|
+
break;
|
|
293
|
+
snapshotHash.update(buffer.subarray(0, readLength));
|
|
294
|
+
snapshotLength += readLength;
|
|
295
|
+
}
|
|
296
|
+
const snapshotAfterRead = fileDescriptorIdentity(destinationFd);
|
|
297
|
+
if (!sameStableFileIdentity(snapshotIdentity, snapshotAfterRead) ||
|
|
298
|
+
snapshotLength !== backup.byteLength ||
|
|
299
|
+
`sha256:${snapshotHash.digest('hex')}` !== backup.sha256) {
|
|
300
|
+
throw new Error('community_backup_snapshot_digest_mismatch');
|
|
301
|
+
}
|
|
302
|
+
runtime.beforeFinalVerification?.(snapshotPath);
|
|
303
|
+
if (!sameStableFileIdentity(snapshotAfterRead, pathIdentity(snapshotPath))) {
|
|
304
|
+
throw new Error('community_backup_snapshot_identity_changed');
|
|
305
|
+
}
|
|
306
|
+
runtime.afterFinalVerification?.(snapshotPath);
|
|
307
|
+
const snapshotFd = destinationFd;
|
|
308
|
+
destinationFd = undefined;
|
|
309
|
+
return {
|
|
310
|
+
path: snapshotPath,
|
|
311
|
+
sourcePath,
|
|
312
|
+
sha256: observedDigest,
|
|
313
|
+
byteLength: readPosition,
|
|
314
|
+
fd: snapshotFd,
|
|
315
|
+
device: snapshotIdentity.device,
|
|
316
|
+
inode: snapshotIdentity.inode,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (destinationFd !== undefined)
|
|
321
|
+
closeSync(destinationFd);
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
closeSync(sourceFd);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function closeVerifiedBackupSnapshot(snapshot) {
|
|
329
|
+
closeSync(snapshot.fd);
|
|
330
|
+
}
|
|
331
|
+
function assertInstanceName(value) {
|
|
332
|
+
const normalized = value.trim().toLowerCase();
|
|
333
|
+
if (!INSTANCE_NAME.test(normalized))
|
|
334
|
+
throw new Error('community_instance_name_invalid');
|
|
335
|
+
return normalized;
|
|
336
|
+
}
|
|
337
|
+
function assertDigest(value, code) {
|
|
338
|
+
if (typeof value !== 'string' || !SHA256.test(value))
|
|
339
|
+
throw new Error(code);
|
|
340
|
+
}
|
|
341
|
+
function assertReleaseIdentity(value) {
|
|
342
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
343
|
+
throw new Error('community_installed_release_invalid');
|
|
344
|
+
}
|
|
345
|
+
const release = value;
|
|
346
|
+
if (typeof release.releaseVersion !== 'string' || !RELEASE_VERSION.test(release.releaseVersion)) {
|
|
347
|
+
throw new Error('community_installed_release_version_invalid');
|
|
348
|
+
}
|
|
349
|
+
assertDigest(release.imageIndexDigest, 'community_installed_release_index_digest_invalid');
|
|
350
|
+
assertDigest(release.migrationSetDigest, 'community_installed_release_migration_digest_invalid');
|
|
351
|
+
assertDigest(release.manifestSha256, 'community_installed_release_manifest_digest_invalid');
|
|
352
|
+
assertDigest(release.composeSha256, 'community_installed_release_compose_digest_invalid');
|
|
353
|
+
const expectedImageRef = `${String(release.imageRef).split('@')[0]}@${release.imageIndexDigest}`;
|
|
354
|
+
if (release.imageRef !== expectedImageRef || !release.imageRef.startsWith('ghcr.io/')) {
|
|
355
|
+
throw new Error('community_installed_release_image_ref_invalid');
|
|
356
|
+
}
|
|
357
|
+
if (!Array.isArray(release.migrationTags) || release.migrationTags.some((tag) => typeof tag !== 'string' || !tag)) {
|
|
358
|
+
throw new Error('community_installed_release_migration_tags_invalid');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function assertInstalledRelease(value) {
|
|
362
|
+
assertReleaseIdentity(value);
|
|
363
|
+
const release = value;
|
|
364
|
+
if (!isSamePhysicalPath(path.dirname(release.manifestPath), path.dirname(release.composePath))) {
|
|
365
|
+
throw new Error('community_installed_release_cache_layout_invalid');
|
|
366
|
+
}
|
|
367
|
+
for (const [filePath, expectedDigest, code] of [
|
|
368
|
+
[release.manifestPath, release.manifestSha256, 'community_installed_release_manifest_file_invalid'],
|
|
369
|
+
[release.composePath, release.composeSha256, 'community_installed_release_compose_file_invalid'],
|
|
370
|
+
]) {
|
|
371
|
+
if (!path.isAbsolute(filePath))
|
|
372
|
+
throw new Error(code);
|
|
373
|
+
assertCanonicalDirectChildRegularFile(path.dirname(filePath), filePath, code);
|
|
374
|
+
if (sha256(readFileSync(filePath)) !== expectedDigest)
|
|
375
|
+
throw new Error(`${code}_digest_mismatch`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function assertInstalledReleaseLineage(paths, value) {
|
|
379
|
+
assertReleaseIdentity(value);
|
|
380
|
+
const release = value;
|
|
381
|
+
if (typeof release.manifestPath !== 'string' || typeof release.composePath !== 'string' ||
|
|
382
|
+
!path.isAbsolute(release.manifestPath) || !path.isAbsolute(release.composePath)) {
|
|
383
|
+
throw new Error('community_installed_release_lineage_invalid');
|
|
384
|
+
}
|
|
385
|
+
const instanceRoot = assertCanonicalDirectory(paths.instanceRoot, 'community_instance_root_unsafe');
|
|
386
|
+
const releaseCacheRoot = assertCanonicalDirectory(paths.releaseCacheRoot, 'community_release_cache_root_unsafe');
|
|
387
|
+
const expectedReleaseCacheRoot = path.join(instanceRoot, 'releases');
|
|
388
|
+
if (!isSamePhysicalPath(releaseCacheRoot, expectedReleaseCacheRoot) ||
|
|
389
|
+
!isSamePhysicalPath(path.dirname(releaseCacheRoot), instanceRoot)) {
|
|
390
|
+
throw new Error('community_release_cache_root_lineage_invalid');
|
|
391
|
+
}
|
|
392
|
+
const expectedReleaseRoot = path.join(releaseCacheRoot, release.manifestSha256.slice('sha256:'.length));
|
|
393
|
+
const releaseRoot = assertCanonicalDirectory(expectedReleaseRoot, 'community_installed_release_cache_slot_invalid');
|
|
394
|
+
if (!isSamePhysicalPath(path.dirname(releaseRoot), releaseCacheRoot)) {
|
|
395
|
+
throw new Error('community_installed_release_cache_slot_invalid');
|
|
396
|
+
}
|
|
397
|
+
const expectedManifestPath = path.join(releaseRoot, 'release.json');
|
|
398
|
+
const expectedComposePath = path.join(releaseRoot, 'compose.yaml');
|
|
399
|
+
if (release.manifestPath !== expectedManifestPath || release.composePath !== expectedComposePath) {
|
|
400
|
+
throw new Error('community_installed_release_lineage_invalid');
|
|
401
|
+
}
|
|
402
|
+
for (const [filePath, expectedDigest, code] of [
|
|
403
|
+
[expectedManifestPath, release.manifestSha256, 'community_installed_release_manifest_file_invalid'],
|
|
404
|
+
[expectedComposePath, release.composeSha256, 'community_installed_release_compose_file_invalid'],
|
|
405
|
+
]) {
|
|
406
|
+
assertCanonicalDirectChildRegularFile(releaseRoot, filePath, code);
|
|
407
|
+
if (sha256(readFileSync(filePath)) !== expectedDigest)
|
|
408
|
+
throw new Error(`${code}_digest_mismatch`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function assertInstallStateLineage(paths, state) {
|
|
412
|
+
const instanceRoot = assertCanonicalDirectory(paths.instanceRoot, 'community_instance_root_unsafe');
|
|
413
|
+
const selectedInstanceName = path.basename(instanceRoot);
|
|
414
|
+
if (!INSTANCE_NAME.test(selectedInstanceName) || state.instanceName !== selectedInstanceName) {
|
|
415
|
+
throw new Error('community_install_state_instance_mismatch');
|
|
416
|
+
}
|
|
417
|
+
assertInstalledReleaseLineage(paths, state.activeRelease);
|
|
418
|
+
if (state.previousRelease !== null)
|
|
419
|
+
assertInstalledReleaseLineage(paths, state.previousRelease);
|
|
420
|
+
}
|
|
421
|
+
function assertUpdateRecordLineage(paths, record) {
|
|
422
|
+
if (!record || typeof record !== 'object' || !UUID.test(record.id)) {
|
|
423
|
+
throw new Error('community_update_record_id_invalid');
|
|
424
|
+
}
|
|
425
|
+
if (!['started', 'succeeded', 'failed', 'recovery-started', 'recovery-failed', 'recovered', 'rolled-back'].includes(record.status)) {
|
|
426
|
+
throw new Error('community_update_record_status_invalid');
|
|
427
|
+
}
|
|
428
|
+
assertInstalledReleaseLineage(paths, record.priorRelease);
|
|
429
|
+
assertInstalledReleaseLineage(paths, record.targetRelease);
|
|
430
|
+
assertInstalledReleaseLineage(paths, record.backup?.sourceRelease);
|
|
431
|
+
if (record.backup.sourceRelease.manifestSha256 !== record.priorRelease.manifestSha256) {
|
|
432
|
+
throw new Error('community_update_backup_source_release_mismatch');
|
|
433
|
+
}
|
|
434
|
+
if (typeof record.sourcePostgresVolumeName !== 'string' || !record.sourcePostgresVolumeName) {
|
|
435
|
+
throw new Error('community_update_source_volume_invalid');
|
|
436
|
+
}
|
|
437
|
+
if (record.replacementVolumeName !== undefined && !/^[a-z0-9][a-z0-9-]{0,179}$/.test(record.replacementVolumeName)) {
|
|
438
|
+
throw new Error('community_update_replacement_volume_invalid');
|
|
439
|
+
}
|
|
440
|
+
if (record.recoveredUpdateId !== undefined && !UUID.test(record.recoveredUpdateId)) {
|
|
441
|
+
throw new Error('community_update_recovered_id_invalid');
|
|
442
|
+
}
|
|
443
|
+
if (record.volumeClaimSha256 !== undefined) {
|
|
444
|
+
assertDigest(record.volumeClaimSha256, 'community_update_volume_claim_invalid');
|
|
445
|
+
}
|
|
446
|
+
if (record.replacementVolumeName !== undefined) {
|
|
447
|
+
const expectedSuffix = `${record.recoveredUpdateId?.replace(/-/g, '')}-${record.id.replace(/-/g, '')}`;
|
|
448
|
+
if (!record.recoveredUpdateId || !record.volumeClaimSha256 ||
|
|
449
|
+
!new RegExp(`-pg-(?:recover|rollback)-${expectedSuffix}$`).test(record.replacementVolumeName)) {
|
|
450
|
+
throw new Error('community_update_replacement_volume_lineage_invalid');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
else if (record.volumeClaimSha256 !== undefined) {
|
|
454
|
+
throw new Error('community_update_volume_claim_lineage_invalid');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function atomicWrite(filePath, content, mode) {
|
|
458
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
459
|
+
const tempPath = `${filePath}.${process.pid}-${randomUUID()}.tmp`;
|
|
460
|
+
try {
|
|
461
|
+
writeFileSync(tempPath, content, { encoding: 'utf8', flag: 'wx', mode });
|
|
462
|
+
let lastError;
|
|
463
|
+
for (let attempt = 0; attempt < 7; attempt += 1) {
|
|
464
|
+
try {
|
|
465
|
+
renameSync(tempPath, filePath);
|
|
466
|
+
lastError = undefined;
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
lastError = error;
|
|
471
|
+
const code = error?.code;
|
|
472
|
+
if (process.platform !== 'win32' || !['EACCES', 'EBUSY', 'EPERM'].includes(String(code)) || attempt === 6) {
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (lastError)
|
|
479
|
+
throw lastError;
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
rmSync(tempPath, { force: true });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function writeCreateOnce(filePath, content, mode) {
|
|
486
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
487
|
+
writeFileSync(filePath, content, { encoding: 'utf8', flag: 'wx', mode });
|
|
488
|
+
}
|
|
489
|
+
function parseJsonFile(filePath) {
|
|
490
|
+
try {
|
|
491
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
throw new Error(`community_json_read_failed:${filePath}`, { cause: error });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
export function resolveCommunityDataRoot(options = {}) {
|
|
498
|
+
const platform = options.platform ?? process.platform;
|
|
499
|
+
const env = options.env ?? process.env;
|
|
500
|
+
const pathImpl = platform === 'win32' ? path.win32 : path.posix;
|
|
501
|
+
const homeDir = pathImpl.resolve(options.homeDir ?? os.homedir());
|
|
502
|
+
if (env.AOPS_COMMUNITY_HOME) {
|
|
503
|
+
if (!pathImpl.isAbsolute(env.AOPS_COMMUNITY_HOME))
|
|
504
|
+
throw new Error('community_home_absolute_required');
|
|
505
|
+
return pathImpl.resolve(env.AOPS_COMMUNITY_HOME);
|
|
506
|
+
}
|
|
507
|
+
if (platform === 'win32') {
|
|
508
|
+
return pathImpl.join(pathImpl.resolve(env.LOCALAPPDATA ?? pathImpl.join(homeDir, 'AppData', 'Local')), 'AOPS', 'Community');
|
|
509
|
+
}
|
|
510
|
+
if (platform === 'darwin') {
|
|
511
|
+
return pathImpl.join(homeDir, 'Library', 'Application Support', 'AOPS', 'Community');
|
|
512
|
+
}
|
|
513
|
+
return pathImpl.join(pathImpl.resolve(env.XDG_DATA_HOME ?? pathImpl.join(homeDir, '.local', 'share')), 'aops', 'community');
|
|
514
|
+
}
|
|
515
|
+
export function resolveCommunityInstallPaths(options = {}) {
|
|
516
|
+
const dataRoot = options.dataRoot
|
|
517
|
+
? assertAbsolute(options.dataRoot, 'community_data_root_absolute_required')
|
|
518
|
+
: resolveCommunityDataRoot(options);
|
|
519
|
+
const instanceName = assertInstanceName(options.instanceName ?? 'default');
|
|
520
|
+
const instanceRoot = path.join(dataRoot, 'instances', instanceName);
|
|
521
|
+
const runtimeRoot = path.join(instanceRoot, 'runtime');
|
|
522
|
+
return {
|
|
523
|
+
dataRoot,
|
|
524
|
+
instanceRoot,
|
|
525
|
+
runtimeRoot,
|
|
526
|
+
releaseCacheRoot: path.join(instanceRoot, 'releases'),
|
|
527
|
+
backupRoot: path.join(instanceRoot, 'backups'),
|
|
528
|
+
statePath: path.join(instanceRoot, 'install-state.json'),
|
|
529
|
+
envPath: path.join(instanceRoot, 'runtime.env'),
|
|
530
|
+
composePath: path.join(runtimeRoot, 'compose.yaml'),
|
|
531
|
+
releasePath: path.join(runtimeRoot, 'release.json'),
|
|
532
|
+
ledgerPath: path.join(instanceRoot, 'update-ledger.json'),
|
|
533
|
+
recoveryJournalPath: path.join(instanceRoot, 'recovery-commit.json'),
|
|
534
|
+
operationJournalRoot: path.join(dataRoot, 'operation-journals', instanceName),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
export function parseCommunityRelease(manifestContent, composeContent) {
|
|
538
|
+
let manifest;
|
|
539
|
+
try {
|
|
540
|
+
manifest = JSON.parse(manifestContent);
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
throw new Error('community_release_manifest_json_invalid', { cause: error });
|
|
544
|
+
}
|
|
545
|
+
if (manifest?.schemaVersion !== 1 || typeof manifest.releaseVersion !== 'string' || !manifest.releaseVersion) {
|
|
546
|
+
throw new Error('community_release_manifest_invalid');
|
|
547
|
+
}
|
|
548
|
+
assertDigest(manifest.image?.indexDigest, 'community_release_index_digest_invalid');
|
|
549
|
+
assertDigest(manifest.compose?.sha256, 'community_release_compose_digest_invalid');
|
|
550
|
+
assertDigest(manifest.migrations?.setDigest, 'community_release_migration_digest_invalid');
|
|
551
|
+
if (!Array.isArray(manifest.migrations?.tags) || manifest.migrations.tags.some((tag) => typeof tag !== 'string' || !tag)) {
|
|
552
|
+
throw new Error('community_release_migration_tags_invalid');
|
|
553
|
+
}
|
|
554
|
+
if (sha256(composeContent) !== manifest.compose.sha256) {
|
|
555
|
+
throw new Error('community_release_compose_digest_mismatch');
|
|
556
|
+
}
|
|
557
|
+
const release = {
|
|
558
|
+
releaseVersion: manifest.releaseVersion,
|
|
559
|
+
imageRef: `${manifest.image.repository}@${manifest.image.indexDigest}`,
|
|
560
|
+
imageIndexDigest: manifest.image.indexDigest,
|
|
561
|
+
migrationSetDigest: manifest.migrations.setDigest,
|
|
562
|
+
migrationTags: [...manifest.migrations.tags],
|
|
563
|
+
manifestSha256: sha256(manifestContent),
|
|
564
|
+
composeSha256: manifest.compose.sha256,
|
|
565
|
+
};
|
|
566
|
+
assertReleaseIdentity(release);
|
|
567
|
+
return release;
|
|
568
|
+
}
|
|
569
|
+
export function verifyStagedCommunityRelease(release) {
|
|
570
|
+
assertInstalledRelease(release);
|
|
571
|
+
const observed = parseCommunityRelease(readFileSync(release.manifestPath, 'utf8'), readFileSync(release.composePath, 'utf8'));
|
|
572
|
+
for (const key of [
|
|
573
|
+
'releaseVersion',
|
|
574
|
+
'imageRef',
|
|
575
|
+
'imageIndexDigest',
|
|
576
|
+
'migrationSetDigest',
|
|
577
|
+
'manifestSha256',
|
|
578
|
+
'composeSha256',
|
|
579
|
+
]) {
|
|
580
|
+
if (observed[key] !== release[key])
|
|
581
|
+
throw new Error(`community_staged_release_identity_mismatch:${key}`);
|
|
582
|
+
}
|
|
583
|
+
if (JSON.stringify(observed.migrationTags) !== JSON.stringify(release.migrationTags)) {
|
|
584
|
+
throw new Error('community_staged_release_identity_mismatch:migrationTags');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
export function stageCommunityRelease(options) {
|
|
588
|
+
if (options.manifestVerified !== true)
|
|
589
|
+
throw new Error('community_release_verification_required');
|
|
590
|
+
const identity = parseCommunityRelease(options.manifestContent, options.composeContent);
|
|
591
|
+
const releaseCacheRoot = assertCanonicalDirectory(options.paths.releaseCacheRoot, 'community_release_cache_root_unsafe');
|
|
592
|
+
const releaseRoot = ensureCanonicalDirectChildDirectory(releaseCacheRoot, path.join(releaseCacheRoot, identity.manifestSha256.slice('sha256:'.length)), 'community_release_cache_slot_unsafe');
|
|
593
|
+
const manifestPath = path.join(releaseRoot, 'release.json');
|
|
594
|
+
const composePath = path.join(releaseRoot, 'compose.yaml');
|
|
595
|
+
for (const [name, filePath, content, expectedDigest] of [
|
|
596
|
+
['release.json', manifestPath, options.manifestContent, identity.manifestSha256],
|
|
597
|
+
['compose.yaml', composePath, options.composeContent, identity.composeSha256],
|
|
598
|
+
]) {
|
|
599
|
+
if (!tryLstat(filePath)) {
|
|
600
|
+
writeCanonicalDirectChildFileCreateOnce(releaseRoot, filePath, content, 0o600, `community_release_cache_file_unsafe:${name}`);
|
|
601
|
+
}
|
|
602
|
+
assertCanonicalDirectChildRegularFile(releaseRoot, filePath, `community_release_cache_file_unsafe:${name}`);
|
|
603
|
+
if (sha256(readFileSync(filePath)) !== expectedDigest)
|
|
604
|
+
throw new Error('community_release_cache_digest_mismatch');
|
|
605
|
+
}
|
|
606
|
+
const release = { ...identity, manifestPath, composePath };
|
|
607
|
+
assertInstalledReleaseLineage(options.paths, release);
|
|
608
|
+
return release;
|
|
609
|
+
}
|
|
610
|
+
export function readCommunityInstallState(paths) {
|
|
611
|
+
const value = parseJsonFile(paths.statePath);
|
|
612
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
613
|
+
throw new Error('community_install_state_invalid');
|
|
614
|
+
}
|
|
615
|
+
const state = value;
|
|
616
|
+
if (state.schemaVersion !== 1 || !INSTANCE_NAME.test(state.instanceName)) {
|
|
617
|
+
throw new Error('community_install_state_schema_invalid');
|
|
618
|
+
}
|
|
619
|
+
if (!/^[0-9a-f-]{36}$/.test(state.installId))
|
|
620
|
+
throw new Error('community_install_id_invalid');
|
|
621
|
+
if (state.composeProjectName !== `aops-community-${state.installId.replace(/-/g, '').slice(0, 12)}`) {
|
|
622
|
+
throw new Error('community_compose_project_identity_invalid');
|
|
623
|
+
}
|
|
624
|
+
assertCommunityPostgresVolumeName(state, state.postgresVolumeName);
|
|
625
|
+
assertInstallStateLineage(paths, state);
|
|
626
|
+
return state;
|
|
627
|
+
}
|
|
628
|
+
export function inspectCommunityInstall(options = {}) {
|
|
629
|
+
const paths = resolveCommunityInstallPaths(options);
|
|
630
|
+
const required = [paths.statePath, paths.envPath, paths.composePath, paths.releasePath, paths.ledgerPath];
|
|
631
|
+
const presentFiles = required.filter((filePath) => existsSync(filePath));
|
|
632
|
+
const missingFiles = required.filter((filePath) => !existsSync(filePath));
|
|
633
|
+
if (presentFiles.length === 0)
|
|
634
|
+
return { status: 'not-installed', paths, presentFiles, missingFiles };
|
|
635
|
+
if (!existsSync(paths.statePath)) {
|
|
636
|
+
return { status: 'partial', paths, presentFiles, missingFiles, error: 'community_install_state_missing' };
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
const state = readCommunityInstallState(paths);
|
|
640
|
+
if (missingFiles.length > 0) {
|
|
641
|
+
return { status: 'partial', paths, presentFiles, missingFiles, state, error: 'community_install_files_missing' };
|
|
642
|
+
}
|
|
643
|
+
return { status: 'installed', paths, presentFiles, missingFiles, state };
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
return {
|
|
647
|
+
status: 'partial',
|
|
648
|
+
paths,
|
|
649
|
+
presentFiles,
|
|
650
|
+
missingFiles,
|
|
651
|
+
error: error instanceof Error ? error.message : String(error),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
export function readCommunityRuntimePort(paths) {
|
|
656
|
+
const content = readFileSync(paths.envPath, 'utf8');
|
|
657
|
+
const value = /^AOPS_PORT=(\d+)\s*$/m.exec(content)?.[1];
|
|
658
|
+
const port = Number(value);
|
|
659
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
|
|
660
|
+
throw new Error('community_runtime_port_invalid');
|
|
661
|
+
return port;
|
|
662
|
+
}
|
|
663
|
+
function renderRuntimeEnv(params) {
|
|
664
|
+
return [
|
|
665
|
+
'# Managed by aops-cli Community lifecycle. Keep this file private.',
|
|
666
|
+
`COMPOSE_PROJECT_NAME=${params.state.composeProjectName}`,
|
|
667
|
+
`AOPS_INSTALL_ID=${params.state.installId}`,
|
|
668
|
+
`AOPS_IMAGE_REF=${params.state.activeRelease.imageRef}`,
|
|
669
|
+
`AOPS_RELEASE_VERSION=${params.state.activeRelease.releaseVersion}`,
|
|
670
|
+
`AOPS_POSTGRES_VOLUME_NAME=${params.state.postgresVolumeName}`,
|
|
671
|
+
'AOPS_POSTGRES_DB=aops',
|
|
672
|
+
'AOPS_POSTGRES_USER=aops',
|
|
673
|
+
`AOPS_POSTGRES_PASSWORD=${params.postgresPassword}`,
|
|
674
|
+
'CHATV3_SERVER_KEY_ID=k1',
|
|
675
|
+
`CHATV3_SERVER_KEY_SECRET=${params.chatv3ServerKey}`,
|
|
676
|
+
`AOPS_PORT=${params.port}`,
|
|
677
|
+
'',
|
|
678
|
+
].join('\n');
|
|
679
|
+
}
|
|
680
|
+
function renderUpdatedRuntimeEnvBindings(content, state) {
|
|
681
|
+
const managed = new Map([
|
|
682
|
+
['COMPOSE_PROJECT_NAME', state.composeProjectName],
|
|
683
|
+
['AOPS_INSTALL_ID', state.installId],
|
|
684
|
+
['AOPS_IMAGE_REF', state.activeRelease.imageRef],
|
|
685
|
+
['AOPS_RELEASE_VERSION', state.activeRelease.releaseVersion],
|
|
686
|
+
['AOPS_POSTGRES_VOLUME_NAME', state.postgresVolumeName],
|
|
687
|
+
]);
|
|
688
|
+
const seen = new Set();
|
|
689
|
+
const lines = content.split(/\r?\n/).map((line) => {
|
|
690
|
+
const match = /^([A-Z][A-Z0-9_]*)=/.exec(line);
|
|
691
|
+
const key = match?.[1];
|
|
692
|
+
if (!key || !managed.has(key))
|
|
693
|
+
return line;
|
|
694
|
+
seen.add(key);
|
|
695
|
+
return `${key}=${managed.get(key)}`;
|
|
696
|
+
});
|
|
697
|
+
for (const [key, value] of managed) {
|
|
698
|
+
if (!seen.has(key))
|
|
699
|
+
lines.push(`${key}=${value}`);
|
|
700
|
+
}
|
|
701
|
+
return `${lines.join('\n').replace(/\n+$/g, '')}\n`;
|
|
702
|
+
}
|
|
703
|
+
function updateRuntimeEnvBindings(paths, state) {
|
|
704
|
+
atomicWrite(paths.envPath, renderUpdatedRuntimeEnvBindings(readFileSync(paths.envPath, 'utf8'), state), 0o600);
|
|
705
|
+
}
|
|
706
|
+
export function setupCommunityInstall(options) {
|
|
707
|
+
if (options.manifestVerified !== true)
|
|
708
|
+
throw new Error('community_release_verification_required');
|
|
709
|
+
const paths = resolveCommunityInstallPaths({ instanceName: options.instanceName, dataRoot: options.dataRoot });
|
|
710
|
+
const instanceRoot = ensureCanonicalDirectoryChain(paths.instanceRoot, 'community_instance_root_unsafe');
|
|
711
|
+
ensureCanonicalDirectChildDirectory(instanceRoot, paths.runtimeRoot, 'community_runtime_root_unsafe');
|
|
712
|
+
ensureCanonicalDirectChildDirectory(instanceRoot, paths.releaseCacheRoot, 'community_release_cache_root_unsafe');
|
|
713
|
+
ensureCanonicalDirectChildDirectory(instanceRoot, paths.backupRoot, 'community_backup_root_unsafe');
|
|
714
|
+
assertCommunityRecoveryMutationFence(paths, options.operationJournal && options.operationReceipt
|
|
715
|
+
? { handle: options.operationJournal, operation: 'setup', receipt: options.operationReceipt }
|
|
716
|
+
: undefined);
|
|
717
|
+
if (existsSync(paths.statePath)) {
|
|
718
|
+
return { status: 'existing', paths, state: readCommunityInstallState(paths) };
|
|
719
|
+
}
|
|
720
|
+
const now = options.now ?? (() => new Date());
|
|
721
|
+
const installId = (options.createInstallId ?? randomUUID)();
|
|
722
|
+
if (!/^[0-9a-f-]{36}$/.test(installId))
|
|
723
|
+
throw new Error('community_install_id_invalid');
|
|
724
|
+
const port = options.port ?? 5900;
|
|
725
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
|
|
726
|
+
throw new Error('community_port_invalid');
|
|
727
|
+
const composeProjectName = `aops-community-${installId.replace(/-/g, '').slice(0, 12)}`;
|
|
728
|
+
const createdAt = nowIso(now);
|
|
729
|
+
const release = stageCommunityRelease({
|
|
730
|
+
paths,
|
|
731
|
+
manifestContent: options.manifestContent,
|
|
732
|
+
composeContent: options.composeContent,
|
|
733
|
+
manifestVerified: true,
|
|
734
|
+
});
|
|
735
|
+
const state = {
|
|
736
|
+
schemaVersion: 1,
|
|
737
|
+
instanceName: assertInstanceName(options.instanceName ?? 'default'),
|
|
738
|
+
installId,
|
|
739
|
+
composeProjectName,
|
|
740
|
+
postgresVolumeName: `${composeProjectName}-pg-1`,
|
|
741
|
+
createdAt,
|
|
742
|
+
updatedAt: createdAt,
|
|
743
|
+
activeRelease: release,
|
|
744
|
+
previousRelease: null,
|
|
745
|
+
lastSuccessfulUpdateId: null,
|
|
746
|
+
};
|
|
747
|
+
const secret = options.createSecret ?? (() => randomBytes(32).toString('base64url'));
|
|
748
|
+
writeCreateOnce(paths.composePath, options.composeContent);
|
|
749
|
+
writeCreateOnce(paths.releasePath, options.manifestContent);
|
|
750
|
+
writeCreateOnce(paths.envPath, renderRuntimeEnv({
|
|
751
|
+
state,
|
|
752
|
+
postgresPassword: secret(),
|
|
753
|
+
chatv3ServerKey: secret(),
|
|
754
|
+
port,
|
|
755
|
+
}), 0o600);
|
|
756
|
+
writeCreateOnce(paths.ledgerPath, '[]\n', 0o600);
|
|
757
|
+
writeCreateOnce(paths.statePath, `${JSON.stringify(state, null, 2)}\n`, 0o600);
|
|
758
|
+
return { status: 'created', paths, state };
|
|
759
|
+
}
|
|
760
|
+
function readLedger(paths) {
|
|
761
|
+
const value = parseJsonFile(paths.ledgerPath);
|
|
762
|
+
if (!Array.isArray(value))
|
|
763
|
+
throw new Error('community_update_ledger_invalid');
|
|
764
|
+
const records = value;
|
|
765
|
+
for (const record of records)
|
|
766
|
+
assertUpdateRecordLineage(paths, record);
|
|
767
|
+
return records;
|
|
768
|
+
}
|
|
769
|
+
const ABANDONED_RUNTIME_RECONCILIATION_MARKER = 'operator_reconciled_interrupted_attempt_volume_preserved';
|
|
770
|
+
const SOURCE_RUNTIME_RECONCILIATION_MARKER = 'operator_reconciled_source_runtime_restored';
|
|
771
|
+
export function assertNoCommunityRecoveryCommitJournal(paths) {
|
|
772
|
+
assertNoRecoveryCommitJournal(paths);
|
|
773
|
+
}
|
|
774
|
+
export function assertCommunityLedgerHasNoNonterminalOperation(paths) {
|
|
775
|
+
if (!tryLstat(paths.ledgerPath))
|
|
776
|
+
return;
|
|
777
|
+
for (const record of readLedger(paths)) {
|
|
778
|
+
if (record.status === 'started' || record.status === 'recovery-started' ||
|
|
779
|
+
(record.status === 'recovery-failed' && record.failure === ABANDONED_RUNTIME_RECONCILIATION_MARKER)) {
|
|
780
|
+
throw new Error(`community_operation_ledger_reconciliation_required:record_id=${record.id}:status=${record.status}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
export function assertCommunityRecoveryMutationFence(paths, active) {
|
|
785
|
+
assertCommunityOperationJournalFence(paths, active);
|
|
786
|
+
if (tryLstat(paths.recoveryJournalPath)) {
|
|
787
|
+
throw new Error('community_recovery_reconciliation_required:recovery_commit_journal_present');
|
|
788
|
+
}
|
|
789
|
+
if (!tryLstat(paths.ledgerPath))
|
|
790
|
+
return;
|
|
791
|
+
const ledger = readLedger(paths);
|
|
792
|
+
for (let index = ledger.length - 1; index >= 0; index -= 1) {
|
|
793
|
+
const record = ledger[index];
|
|
794
|
+
if (record.status === 'recovery-started') {
|
|
795
|
+
throw new Error(`community_recovery_reconciliation_required:recovery_id=${record.id}`);
|
|
796
|
+
}
|
|
797
|
+
if (record.status === 'recovery-failed' && record.failure === ABANDONED_RUNTIME_RECONCILIATION_MARKER) {
|
|
798
|
+
throw new Error(`community_recovery_runtime_reconciliation_required:recovery_id=${record.id}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function serializeLedger(paths, records) {
|
|
803
|
+
for (const record of records)
|
|
804
|
+
assertUpdateRecordLineage(paths, record);
|
|
805
|
+
return `${JSON.stringify(records, null, 2)}\n`;
|
|
806
|
+
}
|
|
807
|
+
function writeLedger(paths, records) {
|
|
808
|
+
atomicWrite(paths.ledgerPath, serializeLedger(paths, records), 0o600);
|
|
809
|
+
}
|
|
810
|
+
function serializeState(paths, state) {
|
|
811
|
+
assertInstallStateLineage(paths, state);
|
|
812
|
+
return `${JSON.stringify(state, null, 2)}\n`;
|
|
813
|
+
}
|
|
814
|
+
function persistState(paths, state) {
|
|
815
|
+
atomicWrite(paths.statePath, serializeState(paths, state), 0o600);
|
|
816
|
+
}
|
|
817
|
+
function writeRecoveryCommitJournal(paths, journal, createOnce = false) {
|
|
818
|
+
const content = `${JSON.stringify(journal, null, 2)}\n`;
|
|
819
|
+
if (createOnce) {
|
|
820
|
+
writeCanonicalDirectChildFileCreateOnce(paths.instanceRoot, paths.recoveryJournalPath, content, 0o600, 'community_recovery_journal_invalid');
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
assertCanonicalDirectChildRegularFile(paths.instanceRoot, paths.recoveryJournalPath, 'community_recovery_journal_invalid');
|
|
824
|
+
atomicWrite(paths.recoveryJournalPath, content, 0o600);
|
|
825
|
+
}
|
|
826
|
+
function removeRecoveryCommitJournal(paths) {
|
|
827
|
+
assertCanonicalDirectChildRegularFile(paths.instanceRoot, paths.recoveryJournalPath, 'community_recovery_journal_invalid');
|
|
828
|
+
rmSync(paths.recoveryJournalPath);
|
|
829
|
+
}
|
|
830
|
+
function assertNoRecoveryCommitJournal(paths) {
|
|
831
|
+
if (tryLstat(paths.recoveryJournalPath)) {
|
|
832
|
+
throw new Error('community_recovery_reconciliation_required:recovery_commit_journal_present');
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
function commitRecoveredInstallState(params) {
|
|
836
|
+
const journal = {
|
|
837
|
+
schemaVersion: 1,
|
|
838
|
+
updateId: params.updateId,
|
|
839
|
+
recoveryId: params.recoveryId,
|
|
840
|
+
phase: 'prepared',
|
|
841
|
+
createdAt: params.createdAt,
|
|
842
|
+
priorSha256: {
|
|
843
|
+
env: sha256(params.priorEnvContent),
|
|
844
|
+
state: sha256(params.priorStateContent),
|
|
845
|
+
ledger: sha256(params.priorLedgerContent),
|
|
846
|
+
},
|
|
847
|
+
nextSha256: {
|
|
848
|
+
env: sha256(params.nextEnvContent),
|
|
849
|
+
state: sha256(params.nextStateContent),
|
|
850
|
+
ledger: sha256(params.nextLedgerContent),
|
|
851
|
+
},
|
|
852
|
+
nextState: params.nextState,
|
|
853
|
+
nextLedger: params.nextLedger,
|
|
854
|
+
};
|
|
855
|
+
writeRecoveryCommitJournal(params.paths, journal, true);
|
|
856
|
+
const writeEnv = params.runtime?.writeEnv ?? ((filePath, content) => atomicWrite(filePath, content, 0o600));
|
|
857
|
+
const writeState = params.runtime?.writeState ?? ((filePath, content) => atomicWrite(filePath, content, 0o600));
|
|
858
|
+
const writeLedgerContent = params.runtime?.writeLedger ?? ((filePath, content) => atomicWrite(filePath, content, 0o600));
|
|
859
|
+
try {
|
|
860
|
+
writeEnv(params.paths.envPath, params.nextEnvContent);
|
|
861
|
+
journal.phase = 'env-written';
|
|
862
|
+
writeRecoveryCommitJournal(params.paths, journal);
|
|
863
|
+
writeState(params.paths.statePath, params.nextStateContent);
|
|
864
|
+
journal.phase = 'state-written';
|
|
865
|
+
writeRecoveryCommitJournal(params.paths, journal);
|
|
866
|
+
writeLedgerContent(params.paths.ledgerPath, params.nextLedgerContent);
|
|
867
|
+
removeRecoveryCommitJournal(params.paths);
|
|
868
|
+
}
|
|
869
|
+
catch (error) {
|
|
870
|
+
const rollbackErrors = [];
|
|
871
|
+
for (const [filePath, content] of [
|
|
872
|
+
[params.paths.envPath, params.priorEnvContent],
|
|
873
|
+
[params.paths.statePath, params.priorStateContent],
|
|
874
|
+
[params.paths.ledgerPath, params.priorLedgerContent],
|
|
875
|
+
]) {
|
|
876
|
+
try {
|
|
877
|
+
atomicWrite(filePath, content, 0o600);
|
|
878
|
+
}
|
|
879
|
+
catch (rollbackError) {
|
|
880
|
+
rollbackErrors.push(redactCommunityDiagnostic(rollbackError));
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (rollbackErrors.length === 0) {
|
|
884
|
+
removeRecoveryCommitJournal(params.paths);
|
|
885
|
+
throw new Error(`community_recovery_commit_failed:${redactCommunityDiagnostic(error)}`, { cause: error });
|
|
886
|
+
}
|
|
887
|
+
journal.phase = 'rollback-failed';
|
|
888
|
+
try {
|
|
889
|
+
writeRecoveryCommitJournal(params.paths, journal);
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
}
|
|
893
|
+
throw new Error(`community_recovery_reconciliation_required:commit=${redactCommunityDiagnostic(error)}:rollback=${rollbackErrors.join('|')}`, { cause: error });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
function readRecoveryCommitJournal(paths) {
|
|
897
|
+
assertCanonicalDirectChildRegularFile(paths.instanceRoot, paths.recoveryJournalPath, 'community_recovery_journal_invalid');
|
|
898
|
+
const value = parseJsonFile(paths.recoveryJournalPath);
|
|
899
|
+
if (value.schemaVersion !== 1 || !UUID.test(String(value.updateId)) || !UUID.test(String(value.recoveryId)) ||
|
|
900
|
+
!['prepared', 'env-written', 'state-written', 'rollback-failed'].includes(String(value.phase)) ||
|
|
901
|
+
typeof value.createdAt !== 'string' || new Date(value.createdAt).toISOString() !== value.createdAt ||
|
|
902
|
+
!value.priorSha256 || !value.nextSha256 || !value.nextState || !Array.isArray(value.nextLedger)) {
|
|
903
|
+
throw new Error('community_recovery_journal_invalid');
|
|
904
|
+
}
|
|
905
|
+
for (const digestValue of [
|
|
906
|
+
value.priorSha256.env,
|
|
907
|
+
value.priorSha256.state,
|
|
908
|
+
value.priorSha256.ledger,
|
|
909
|
+
value.nextSha256.env,
|
|
910
|
+
value.nextSha256.state,
|
|
911
|
+
value.nextSha256.ledger,
|
|
912
|
+
]) {
|
|
913
|
+
assertDigest(digestValue, 'community_recovery_journal_invalid');
|
|
914
|
+
}
|
|
915
|
+
const journal = value;
|
|
916
|
+
if (sha256(serializeState(paths, journal.nextState)) !== journal.nextSha256.state ||
|
|
917
|
+
sha256(serializeLedger(paths, journal.nextLedger)) !== journal.nextSha256.ledger) {
|
|
918
|
+
throw new Error('community_recovery_journal_payload_mismatch');
|
|
919
|
+
}
|
|
920
|
+
const recovery = journal.nextLedger.find((record) => record.id === journal.recoveryId);
|
|
921
|
+
if (!recovery || recovery.recoveredUpdateId !== journal.updateId ||
|
|
922
|
+
(recovery.status !== 'recovered' && recovery.status !== 'rolled-back')) {
|
|
923
|
+
throw new Error('community_recovery_journal_payload_mismatch');
|
|
924
|
+
}
|
|
925
|
+
return journal;
|
|
926
|
+
}
|
|
927
|
+
export function reconcileCommunityRecoveryCommit(options) {
|
|
928
|
+
if (options.confirm !== true)
|
|
929
|
+
throw new Error('community_recovery_reconciliation_confirmation_required');
|
|
930
|
+
if (!UUID.test(options.updateId) || !UUID.test(options.recoveryId)) {
|
|
931
|
+
throw new Error('community_recovery_reconciliation_id_invalid');
|
|
932
|
+
}
|
|
933
|
+
const journal = readRecoveryCommitJournal(options.paths);
|
|
934
|
+
if (journal.updateId !== options.updateId || journal.recoveryId !== options.recoveryId) {
|
|
935
|
+
throw new Error('community_recovery_reconciliation_record_mismatch');
|
|
936
|
+
}
|
|
937
|
+
const currentEnv = readFileSync(options.paths.envPath, 'utf8');
|
|
938
|
+
const currentState = readFileSync(options.paths.statePath, 'utf8');
|
|
939
|
+
const currentLedger = readFileSync(options.paths.ledgerPath, 'utf8');
|
|
940
|
+
for (const [name, content] of [
|
|
941
|
+
['env', currentEnv],
|
|
942
|
+
['state', currentState],
|
|
943
|
+
['ledger', currentLedger],
|
|
944
|
+
]) {
|
|
945
|
+
const observed = sha256(content);
|
|
946
|
+
if (observed !== journal.priorSha256[name] && observed !== journal.nextSha256[name]) {
|
|
947
|
+
throw new Error(`community_recovery_reconciliation_drift:${name}`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const nextEnvContent = renderUpdatedRuntimeEnvBindings(currentEnv, journal.nextState);
|
|
951
|
+
if (sha256(nextEnvContent) !== journal.nextSha256.env) {
|
|
952
|
+
throw new Error('community_recovery_reconciliation_env_mismatch');
|
|
953
|
+
}
|
|
954
|
+
try {
|
|
955
|
+
atomicWrite(options.paths.envPath, nextEnvContent, 0o600);
|
|
956
|
+
journal.phase = 'env-written';
|
|
957
|
+
writeRecoveryCommitJournal(options.paths, journal);
|
|
958
|
+
persistState(options.paths, journal.nextState);
|
|
959
|
+
journal.phase = 'state-written';
|
|
960
|
+
writeRecoveryCommitJournal(options.paths, journal);
|
|
961
|
+
writeLedger(options.paths, journal.nextLedger);
|
|
962
|
+
removeRecoveryCommitJournal(options.paths);
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
throw new Error(`community_recovery_reconciliation_failed:${redactCommunityDiagnostic(error)}`, { cause: error });
|
|
966
|
+
}
|
|
967
|
+
return journal.nextLedger.find((record) => record.id === journal.recoveryId);
|
|
968
|
+
}
|
|
969
|
+
export function abandonInterruptedCommunityRecovery(options) {
|
|
970
|
+
if (options.confirm !== true)
|
|
971
|
+
throw new Error('community_recovery_reconciliation_confirmation_required');
|
|
972
|
+
if (!UUID.test(options.updateId) || !UUID.test(options.recoveryId)) {
|
|
973
|
+
throw new Error('community_recovery_reconciliation_id_invalid');
|
|
974
|
+
}
|
|
975
|
+
assertNoRecoveryCommitJournal(options.paths);
|
|
976
|
+
const state = readCommunityInstallState(options.paths);
|
|
977
|
+
const ledger = readLedger(options.paths);
|
|
978
|
+
const interrupted = ledger.at(-1);
|
|
979
|
+
const update = ledger.find((record) => record.id === options.updateId);
|
|
980
|
+
if (!interrupted || interrupted.id !== options.recoveryId || interrupted.status !== 'recovery-started' ||
|
|
981
|
+
interrupted.recoveredUpdateId !== options.updateId || !update ||
|
|
982
|
+
(update.status !== 'failed' && update.status !== 'succeeded')) {
|
|
983
|
+
throw new Error('community_recovery_reconciliation_record_mismatch');
|
|
984
|
+
}
|
|
985
|
+
if (state.postgresVolumeName !== update.sourcePostgresVolumeName) {
|
|
986
|
+
throw new Error('community_recovery_reconciliation_state_drift');
|
|
987
|
+
}
|
|
988
|
+
if (update.status === 'failed' && !sameInstalledRelease(state.activeRelease, update.priorRelease)) {
|
|
989
|
+
throw new Error('community_recovery_reconciliation_state_drift');
|
|
990
|
+
}
|
|
991
|
+
if (update.status === 'succeeded' && (!sameInstalledRelease(state.activeRelease, update.targetRelease)
|
|
992
|
+
|| !sameInstalledRelease(state.previousRelease, update.priorRelease)
|
|
993
|
+
|| state.lastSuccessfulUpdateId !== update.id)) {
|
|
994
|
+
throw new Error('community_recovery_reconciliation_state_drift');
|
|
995
|
+
}
|
|
996
|
+
const reconciled = {
|
|
997
|
+
...interrupted,
|
|
998
|
+
status: 'recovery-failed',
|
|
999
|
+
finishedAt: nowIso(options.now ?? (() => new Date())),
|
|
1000
|
+
failure: ABANDONED_RUNTIME_RECONCILIATION_MARKER,
|
|
1001
|
+
};
|
|
1002
|
+
writeLedger(options.paths, [...ledger.slice(0, -1), reconciled]);
|
|
1003
|
+
return reconciled;
|
|
1004
|
+
}
|
|
1005
|
+
export async function verifyCommunityBackupRecord(record) {
|
|
1006
|
+
if (!record || typeof record !== 'object' || typeof record.path !== 'string' ||
|
|
1007
|
+
record.verified !== true || !path.isAbsolute(record.path) || !existsSync(record.path)) {
|
|
1008
|
+
throw new Error('community_backup_verification_required');
|
|
1009
|
+
}
|
|
1010
|
+
const resolved = path.resolve(record.path);
|
|
1011
|
+
const stat = tryLstat(resolved);
|
|
1012
|
+
if (!stat || stat.isSymbolicLink() || !stat.isFile() ||
|
|
1013
|
+
stat.nlink !== 1 ||
|
|
1014
|
+
!isSamePhysicalPath(resolved, realpathSync.native(resolved)) ||
|
|
1015
|
+
stat.size <= 0 || stat.size !== record.byteLength) {
|
|
1016
|
+
throw new Error('community_backup_file_invalid');
|
|
1017
|
+
}
|
|
1018
|
+
const hash = createHash('sha256');
|
|
1019
|
+
for await (const chunk of createReadStream(resolved))
|
|
1020
|
+
hash.update(chunk);
|
|
1021
|
+
if (`sha256:${hash.digest('hex')}` !== record.sha256) {
|
|
1022
|
+
throw new Error('community_backup_digest_mismatch');
|
|
1023
|
+
}
|
|
1024
|
+
assertInstalledRelease(record.sourceRelease);
|
|
1025
|
+
}
|
|
1026
|
+
export function writeCommunityBackupReceipt(paths, record) {
|
|
1027
|
+
const backupPath = assertOwnedBackupFile(paths, record.path);
|
|
1028
|
+
assertInstalledReleaseLineage(paths, record.sourceRelease);
|
|
1029
|
+
const receiptPath = `${backupPath}.json`;
|
|
1030
|
+
if (tryLstat(receiptPath))
|
|
1031
|
+
throw new Error('community_backup_receipt_already_exists');
|
|
1032
|
+
writeCanonicalDirectChildFileCreateOnce(paths.backupRoot, receiptPath, `${JSON.stringify(record, null, 2)}\n`, 0o600, 'community_backup_receipt_invalid');
|
|
1033
|
+
return receiptPath;
|
|
1034
|
+
}
|
|
1035
|
+
export async function readCommunityBackupReceipt(paths, backupPath) {
|
|
1036
|
+
const absolute = assertAbsolute(backupPath, 'community_backup_absolute_path_required');
|
|
1037
|
+
const ownedBackupPath = assertOwnedBackupFile(paths, absolute);
|
|
1038
|
+
const receiptPath = `${ownedBackupPath}.json`;
|
|
1039
|
+
assertCanonicalDirectChildRegularFile(paths.backupRoot, receiptPath, 'community_backup_receipt_invalid');
|
|
1040
|
+
const record = parseJsonFile(receiptPath);
|
|
1041
|
+
if (!record || typeof record !== 'object' || typeof record.path !== 'string') {
|
|
1042
|
+
throw new Error('community_backup_receipt_invalid');
|
|
1043
|
+
}
|
|
1044
|
+
if (!isSamePhysicalPath(path.resolve(record.path), ownedBackupPath)) {
|
|
1045
|
+
throw new Error('community_backup_receipt_path_mismatch');
|
|
1046
|
+
}
|
|
1047
|
+
assertOwnedBackupFile(paths, record.path);
|
|
1048
|
+
assertInstalledReleaseLineage(paths, record.sourceRelease);
|
|
1049
|
+
await verifyCommunityBackupRecord(record);
|
|
1050
|
+
return record;
|
|
1051
|
+
}
|
|
1052
|
+
export async function restoreCommunityBackup(options) {
|
|
1053
|
+
if (options.confirmDataRewind !== true)
|
|
1054
|
+
throw new Error('community_restore_data_rewind_confirmation_required');
|
|
1055
|
+
assertCommunityRecoveryMutationFence(options.paths, options.operationJournal && options.operationReceipt
|
|
1056
|
+
? { handle: options.operationJournal, operation: 'restore', receipt: options.operationReceipt }
|
|
1057
|
+
: undefined);
|
|
1058
|
+
const state = readCommunityInstallState(options.paths);
|
|
1059
|
+
assertOwnedBackupFile(options.paths, options.backup.path);
|
|
1060
|
+
assertInstalledReleaseLineage(options.paths, options.backup.sourceRelease);
|
|
1061
|
+
await verifyCommunityBackupRecord(options.backup);
|
|
1062
|
+
if (options.backup.sourceRelease.manifestSha256 !== state.activeRelease.manifestSha256) {
|
|
1063
|
+
throw new Error('community_restore_source_release_mismatch');
|
|
1064
|
+
}
|
|
1065
|
+
await options.adapter.verifyRelease(state.activeRelease);
|
|
1066
|
+
const operationId = (options.createId ?? randomUUID)();
|
|
1067
|
+
if (!UUID.test(operationId))
|
|
1068
|
+
throw new Error('community_restore_operation_id_invalid');
|
|
1069
|
+
const suffix = operationId.replace(/-/g, '');
|
|
1070
|
+
const replacementVolumeName = `${state.composeProjectName}-pg-restore-${suffix}`;
|
|
1071
|
+
const volumeClaim = createPostgresVolumeClaim(state, replacementVolumeName, operationId);
|
|
1072
|
+
const snapshot = createVerifiedBackupSnapshot(options.paths, options.backup, operationId, options.snapshotRuntime);
|
|
1073
|
+
try {
|
|
1074
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1075
|
+
await options.adapter.claimFreshPostgresVolume({ paths: options.paths, state, claim: volumeClaim });
|
|
1076
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1077
|
+
await options.adapter.stop({ paths: options.paths, state });
|
|
1078
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1079
|
+
await options.adapter.restoreBackup({
|
|
1080
|
+
paths: options.paths,
|
|
1081
|
+
state,
|
|
1082
|
+
backup: options.backup,
|
|
1083
|
+
snapshot,
|
|
1084
|
+
volumeClaim,
|
|
1085
|
+
});
|
|
1086
|
+
await options.adapter.pull({ paths: options.paths, state, release: state.activeRelease });
|
|
1087
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1088
|
+
await options.adapter.start({
|
|
1089
|
+
paths: options.paths,
|
|
1090
|
+
state,
|
|
1091
|
+
release: state.activeRelease,
|
|
1092
|
+
postgresVolumeName: replacementVolumeName,
|
|
1093
|
+
});
|
|
1094
|
+
const validationState = { ...state, postgresVolumeName: replacementVolumeName };
|
|
1095
|
+
await options.adapter.health({ paths: options.paths, state: validationState });
|
|
1096
|
+
await options.adapter.dataSmoke({ paths: options.paths, state: validationState });
|
|
1097
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1098
|
+
await runLifecyclePromotion({
|
|
1099
|
+
journal: options.operationJournal,
|
|
1100
|
+
signal: options.signal,
|
|
1101
|
+
step: 'promote-restored-state',
|
|
1102
|
+
promote: () => {
|
|
1103
|
+
state.postgresVolumeName = replacementVolumeName;
|
|
1104
|
+
state.updatedAt = nowIso(options.now ?? (() => new Date()));
|
|
1105
|
+
updateRuntimeEnvBindings(options.paths, state);
|
|
1106
|
+
persistState(options.paths, state);
|
|
1107
|
+
},
|
|
1108
|
+
});
|
|
1109
|
+
return { status: 'restored', backup: options.backup, replacementVolumeName };
|
|
1110
|
+
}
|
|
1111
|
+
finally {
|
|
1112
|
+
closeVerifiedBackupSnapshot(snapshot);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
export function buildCommunityComposeInvocation(params) {
|
|
1116
|
+
const release = params.release ?? params.state.activeRelease;
|
|
1117
|
+
const prefix = buildCommunityComposeBaseInvocation({
|
|
1118
|
+
paths: params.paths,
|
|
1119
|
+
state: params.state,
|
|
1120
|
+
release,
|
|
1121
|
+
postgresVolumeName: params.postgresVolumeName,
|
|
1122
|
+
});
|
|
1123
|
+
const actionArgs = {
|
|
1124
|
+
pull: ['pull'],
|
|
1125
|
+
up: ['up', '--no-build', '--detach', '--wait'],
|
|
1126
|
+
down: ['down'],
|
|
1127
|
+
stop: ['stop'],
|
|
1128
|
+
restart: ['restart'],
|
|
1129
|
+
status: ['ps'],
|
|
1130
|
+
logs: ['logs', '--tail', String(params.logsTail ?? 100)],
|
|
1131
|
+
};
|
|
1132
|
+
return { ...prefix, args: [...prefix.args, ...actionArgs[params.action]] };
|
|
1133
|
+
}
|
|
1134
|
+
export function buildCommunityComposeBaseInvocation(params) {
|
|
1135
|
+
assertInstallStateLineage(params.paths, params.state);
|
|
1136
|
+
const release = params.release ?? params.state.activeRelease;
|
|
1137
|
+
assertInstalledReleaseLineage(params.paths, release);
|
|
1138
|
+
const postgresVolumeName = params.postgresVolumeName ?? params.state.postgresVolumeName;
|
|
1139
|
+
assertCommunityPostgresVolumeName(params.state, postgresVolumeName);
|
|
1140
|
+
const args = [
|
|
1141
|
+
'compose',
|
|
1142
|
+
'--project-name', params.state.composeProjectName,
|
|
1143
|
+
'--env-file', params.paths.envPath,
|
|
1144
|
+
'--file', release.composePath,
|
|
1145
|
+
];
|
|
1146
|
+
return {
|
|
1147
|
+
command: 'docker',
|
|
1148
|
+
args,
|
|
1149
|
+
env: {
|
|
1150
|
+
AOPS_IMAGE_REF: release.imageRef,
|
|
1151
|
+
AOPS_RELEASE_VERSION: release.releaseVersion,
|
|
1152
|
+
AOPS_POSTGRES_VOLUME_NAME: postgresVolumeName,
|
|
1153
|
+
COMPOSE_PROJECT_NAME: params.state.composeProjectName,
|
|
1154
|
+
},
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
export async function updateCommunityInstall(options) {
|
|
1158
|
+
assertCommunityRecoveryMutationFence(options.paths, options.operationJournal && options.operationReceipt
|
|
1159
|
+
? { handle: options.operationJournal, operation: 'update', receipt: options.operationReceipt }
|
|
1160
|
+
: undefined);
|
|
1161
|
+
const now = options.now ?? (() => new Date());
|
|
1162
|
+
const state = readCommunityInstallState(options.paths);
|
|
1163
|
+
assertInstalledReleaseLineage(options.paths, options.targetRelease);
|
|
1164
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1165
|
+
await options.adapter.verifyRelease(options.targetRelease);
|
|
1166
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1167
|
+
const backup = await options.adapter.createBackup({ paths: options.paths, state });
|
|
1168
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1169
|
+
assertOwnedBackupFile(options.paths, backup.path);
|
|
1170
|
+
assertInstalledReleaseLineage(options.paths, backup.sourceRelease);
|
|
1171
|
+
await verifyCommunityBackupRecord(backup);
|
|
1172
|
+
if (backup.sourceRelease.manifestSha256 !== state.activeRelease.manifestSha256) {
|
|
1173
|
+
throw new Error('community_backup_source_release_mismatch');
|
|
1174
|
+
}
|
|
1175
|
+
await runLifecyclePromotion({
|
|
1176
|
+
journal: options.operationJournal,
|
|
1177
|
+
signal: options.signal,
|
|
1178
|
+
step: 'write-backup-receipt',
|
|
1179
|
+
promote: () => writeCommunityBackupReceipt(options.paths, backup),
|
|
1180
|
+
});
|
|
1181
|
+
const updateId = (options.createId ?? randomUUID)();
|
|
1182
|
+
if (!UUID.test(updateId))
|
|
1183
|
+
throw new Error('community_update_id_invalid');
|
|
1184
|
+
const record = {
|
|
1185
|
+
id: updateId,
|
|
1186
|
+
status: 'started',
|
|
1187
|
+
startedAt: nowIso(now),
|
|
1188
|
+
priorRelease: state.activeRelease,
|
|
1189
|
+
targetRelease: options.targetRelease,
|
|
1190
|
+
backup,
|
|
1191
|
+
sourcePostgresVolumeName: state.postgresVolumeName,
|
|
1192
|
+
};
|
|
1193
|
+
const ledger = readLedger(options.paths);
|
|
1194
|
+
if (ledger.some((entry) => entry.id === record.id))
|
|
1195
|
+
throw new Error('community_update_id_conflict');
|
|
1196
|
+
await runLifecyclePromotion({
|
|
1197
|
+
journal: options.operationJournal,
|
|
1198
|
+
signal: options.signal,
|
|
1199
|
+
step: 'record-update-started',
|
|
1200
|
+
promote: () => writeLedger(options.paths, [...ledger, record]),
|
|
1201
|
+
});
|
|
1202
|
+
let migrationMayHaveStarted = false;
|
|
1203
|
+
try {
|
|
1204
|
+
await options.adapter.stop({ paths: options.paths, state });
|
|
1205
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1206
|
+
await options.adapter.pull({ paths: options.paths, state, release: options.targetRelease });
|
|
1207
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1208
|
+
migrationMayHaveStarted = true;
|
|
1209
|
+
await options.adapter.start({
|
|
1210
|
+
paths: options.paths,
|
|
1211
|
+
state,
|
|
1212
|
+
release: options.targetRelease,
|
|
1213
|
+
postgresVolumeName: state.postgresVolumeName,
|
|
1214
|
+
});
|
|
1215
|
+
const validationState = { ...state, activeRelease: options.targetRelease };
|
|
1216
|
+
await options.adapter.health({ paths: options.paths, state: validationState });
|
|
1217
|
+
await options.adapter.dataSmoke({ paths: options.paths, state: validationState });
|
|
1218
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1219
|
+
record.status = 'succeeded';
|
|
1220
|
+
record.finishedAt = nowIso(now);
|
|
1221
|
+
const nextState = {
|
|
1222
|
+
...state,
|
|
1223
|
+
updatedAt: record.finishedAt,
|
|
1224
|
+
activeRelease: options.targetRelease,
|
|
1225
|
+
previousRelease: state.activeRelease,
|
|
1226
|
+
lastSuccessfulUpdateId: record.id,
|
|
1227
|
+
};
|
|
1228
|
+
await runLifecyclePromotion({
|
|
1229
|
+
journal: options.operationJournal,
|
|
1230
|
+
signal: options.signal,
|
|
1231
|
+
step: 'promote-update-success',
|
|
1232
|
+
promote: () => {
|
|
1233
|
+
updateRuntimeEnvBindings(options.paths, nextState);
|
|
1234
|
+
persistState(options.paths, nextState);
|
|
1235
|
+
writeLedger(options.paths, [...ledger, record]);
|
|
1236
|
+
},
|
|
1237
|
+
});
|
|
1238
|
+
return record;
|
|
1239
|
+
}
|
|
1240
|
+
catch (error) {
|
|
1241
|
+
if (options.signal?.aborted)
|
|
1242
|
+
throw new Error('community_operation_aborted', { cause: error });
|
|
1243
|
+
record.status = 'failed';
|
|
1244
|
+
record.finishedAt = nowIso(now);
|
|
1245
|
+
record.failure = redactCommunityDiagnostic(error);
|
|
1246
|
+
record.migrationMayHaveStarted = migrationMayHaveStarted;
|
|
1247
|
+
await runLifecyclePromotion({
|
|
1248
|
+
journal: options.operationJournal,
|
|
1249
|
+
step: 'record-update-failure',
|
|
1250
|
+
promote: () => writeLedger(options.paths, [...ledger, record]),
|
|
1251
|
+
});
|
|
1252
|
+
const suffix = migrationMayHaveStarted ? 'restore_from_backup_required' : 'pre_migration_failure';
|
|
1253
|
+
throw new Error(`community_update_failed:${suffix}:update_id=${record.id}:recover_with=aops-cli_server_recover:${record.failure}`, { cause: error });
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
function sameInstalledRelease(left, right) {
|
|
1257
|
+
return left?.manifestSha256 === right.manifestSha256
|
|
1258
|
+
&& left.manifestPath === right.manifestPath
|
|
1259
|
+
&& left.composePath === right.composePath;
|
|
1260
|
+
}
|
|
1261
|
+
function sameBackupRecord(left, right) {
|
|
1262
|
+
return left.path === right.path
|
|
1263
|
+
&& left.sha256 === right.sha256
|
|
1264
|
+
&& left.byteLength === right.byteLength
|
|
1265
|
+
&& left.verified === right.verified
|
|
1266
|
+
&& left.createdAt === right.createdAt
|
|
1267
|
+
&& sameInstalledRelease(left.sourceRelease, right.sourceRelease);
|
|
1268
|
+
}
|
|
1269
|
+
function reconcileLedgerForSourceRuntime(params) {
|
|
1270
|
+
const ledger = readLedger(params.paths);
|
|
1271
|
+
const startedAt = Date.parse(params.operationStartedAt);
|
|
1272
|
+
if (!Number.isFinite(startedAt))
|
|
1273
|
+
throw new Error('community_source_runtime_reconciliation_started_at_invalid');
|
|
1274
|
+
let candidateIndex = -1;
|
|
1275
|
+
if (params.operation === 'update') {
|
|
1276
|
+
const candidates = ledger
|
|
1277
|
+
.map((record, index) => ({ record, index }))
|
|
1278
|
+
.filter(({ record }) => !record.recoveredUpdateId && Date.parse(record.startedAt) >= startedAt);
|
|
1279
|
+
if (params.expectedUpdateId) {
|
|
1280
|
+
const exact = candidates.filter(({ record }) => record.id === params.expectedUpdateId);
|
|
1281
|
+
if (exact.length !== 1)
|
|
1282
|
+
throw new Error('community_source_runtime_reconciliation_update_mismatch');
|
|
1283
|
+
candidateIndex = exact[0].index;
|
|
1284
|
+
}
|
|
1285
|
+
else if (candidates.length > 1) {
|
|
1286
|
+
throw new Error('community_source_runtime_reconciliation_update_ambiguous');
|
|
1287
|
+
}
|
|
1288
|
+
else if (candidates.length === 1) {
|
|
1289
|
+
candidateIndex = candidates[0].index;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
else if (params.operation === 'recover' || params.operation === 'rollback') {
|
|
1293
|
+
const candidates = ledger
|
|
1294
|
+
.map((record, index) => ({ record, index }))
|
|
1295
|
+
.filter(({ record }) => Boolean(record.recoveredUpdateId) && Date.parse(record.startedAt) >= startedAt);
|
|
1296
|
+
const exact = params.expectedRecoveryId
|
|
1297
|
+
? candidates.filter(({ record }) => record.id === params.expectedRecoveryId && record.recoveredUpdateId === params.expectedUpdateId)
|
|
1298
|
+
: candidates;
|
|
1299
|
+
if (exact.length > 1)
|
|
1300
|
+
throw new Error('community_source_runtime_reconciliation_recovery_ambiguous');
|
|
1301
|
+
if (params.expectedRecoveryId && exact.length !== 1) {
|
|
1302
|
+
throw new Error('community_source_runtime_reconciliation_recovery_mismatch');
|
|
1303
|
+
}
|
|
1304
|
+
if (exact.length === 1)
|
|
1305
|
+
candidateIndex = exact[0].index;
|
|
1306
|
+
}
|
|
1307
|
+
if (candidateIndex < 0) {
|
|
1308
|
+
assertCommunityLedgerHasNoNonterminalOperation(params.paths);
|
|
1309
|
+
return { records: ledger };
|
|
1310
|
+
}
|
|
1311
|
+
if (candidateIndex !== ledger.length - 1) {
|
|
1312
|
+
throw new Error('community_source_runtime_reconciliation_record_not_latest');
|
|
1313
|
+
}
|
|
1314
|
+
const candidate = ledger[candidateIndex];
|
|
1315
|
+
if (params.operation === 'update') {
|
|
1316
|
+
if (!['started', 'failed', 'succeeded'].includes(candidate.status)) {
|
|
1317
|
+
throw new Error('community_source_runtime_reconciliation_update_status_invalid');
|
|
1318
|
+
}
|
|
1319
|
+
const reconciledRecord = {
|
|
1320
|
+
...candidate,
|
|
1321
|
+
status: 'failed',
|
|
1322
|
+
finishedAt: nowIso(params.now),
|
|
1323
|
+
failure: SOURCE_RUNTIME_RECONCILIATION_MARKER,
|
|
1324
|
+
migrationMayHaveStarted: true,
|
|
1325
|
+
};
|
|
1326
|
+
return { records: [...ledger.slice(0, candidateIndex), reconciledRecord], reconciledRecord };
|
|
1327
|
+
}
|
|
1328
|
+
if (!['recovery-started', 'recovery-failed', 'recovered', 'rolled-back'].includes(candidate.status) ||
|
|
1329
|
+
!candidate.recoveredUpdateId) {
|
|
1330
|
+
throw new Error('community_source_runtime_reconciliation_recovery_status_invalid');
|
|
1331
|
+
}
|
|
1332
|
+
const update = ledger.find((record) => record.id === candidate.recoveredUpdateId);
|
|
1333
|
+
if (!update || (update.status !== 'failed' && update.status !== 'succeeded')) {
|
|
1334
|
+
throw new Error('community_source_runtime_reconciliation_update_mismatch');
|
|
1335
|
+
}
|
|
1336
|
+
const reconciledRecord = {
|
|
1337
|
+
...candidate,
|
|
1338
|
+
status: 'recovery-failed',
|
|
1339
|
+
finishedAt: nowIso(params.now),
|
|
1340
|
+
failure: SOURCE_RUNTIME_RECONCILIATION_MARKER,
|
|
1341
|
+
};
|
|
1342
|
+
return { records: [...ledger.slice(0, candidateIndex), reconciledRecord], reconciledRecord };
|
|
1343
|
+
}
|
|
1344
|
+
function commitRestoredCommunitySourceMetadata(params) {
|
|
1345
|
+
const priorEnv = readFileSync(params.paths.envPath, 'utf8');
|
|
1346
|
+
const priorState = readFileSync(params.paths.statePath, 'utf8');
|
|
1347
|
+
const priorLedger = readFileSync(params.paths.ledgerPath, 'utf8');
|
|
1348
|
+
const nextEnv = renderUpdatedRuntimeEnvBindings(priorEnv, params.sourceState);
|
|
1349
|
+
const nextState = serializeState(params.paths, params.sourceState);
|
|
1350
|
+
const nextLedger = serializeLedger(params.paths, params.nextLedger);
|
|
1351
|
+
try {
|
|
1352
|
+
atomicWrite(params.paths.envPath, nextEnv, 0o600);
|
|
1353
|
+
persistState(params.paths, params.sourceState);
|
|
1354
|
+
writeLedger(params.paths, params.nextLedger);
|
|
1355
|
+
}
|
|
1356
|
+
catch (error) {
|
|
1357
|
+
const rollbackErrors = [];
|
|
1358
|
+
for (const [filePath, content] of [
|
|
1359
|
+
[params.paths.envPath, priorEnv],
|
|
1360
|
+
[params.paths.statePath, priorState],
|
|
1361
|
+
[params.paths.ledgerPath, priorLedger],
|
|
1362
|
+
]) {
|
|
1363
|
+
try {
|
|
1364
|
+
atomicWrite(filePath, content, 0o600);
|
|
1365
|
+
}
|
|
1366
|
+
catch (rollbackError) {
|
|
1367
|
+
rollbackErrors.push(redactCommunityDiagnostic(rollbackError));
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
if (rollbackErrors.length > 0) {
|
|
1371
|
+
throw new Error(`community_source_runtime_metadata_reconciliation_required:commit=${redactCommunityDiagnostic(error)}:rollback=${rollbackErrors.join('|')}`, { cause: error });
|
|
1372
|
+
}
|
|
1373
|
+
throw new Error(`community_source_runtime_metadata_commit_failed:${redactCommunityDiagnostic(error)}`, { cause: error });
|
|
1374
|
+
}
|
|
1375
|
+
if (readFileSync(params.paths.envPath, 'utf8') !== nextEnv ||
|
|
1376
|
+
readFileSync(params.paths.statePath, 'utf8') !== nextState ||
|
|
1377
|
+
readFileSync(params.paths.ledgerPath, 'utf8') !== nextLedger) {
|
|
1378
|
+
throw new Error('community_source_runtime_metadata_postcondition_failed');
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
export async function restoreCommunitySourceRuntime(options) {
|
|
1382
|
+
if (options.confirmDataRewind !== true) {
|
|
1383
|
+
throw new Error('community_source_runtime_data_rewind_confirmation_required');
|
|
1384
|
+
}
|
|
1385
|
+
if ((options.expectedUpdateId && !UUID.test(options.expectedUpdateId)) ||
|
|
1386
|
+
(options.expectedRecoveryId && !UUID.test(options.expectedRecoveryId))) {
|
|
1387
|
+
throw new Error('community_source_runtime_reconciliation_id_invalid');
|
|
1388
|
+
}
|
|
1389
|
+
assertNoRecoveryCommitJournal(options.paths);
|
|
1390
|
+
assertInstallStateLineage(options.paths, options.sourceState);
|
|
1391
|
+
const next = reconcileLedgerForSourceRuntime({
|
|
1392
|
+
paths: options.paths,
|
|
1393
|
+
operation: options.operation,
|
|
1394
|
+
operationStartedAt: options.operationStartedAt,
|
|
1395
|
+
expectedUpdateId: options.expectedUpdateId,
|
|
1396
|
+
expectedRecoveryId: options.expectedRecoveryId,
|
|
1397
|
+
now: options.now ?? (() => new Date()),
|
|
1398
|
+
});
|
|
1399
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1400
|
+
await options.adapter.stop({ paths: options.paths, state: options.sourceState });
|
|
1401
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1402
|
+
await options.adapter.verifyRelease(options.sourceState.activeRelease);
|
|
1403
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1404
|
+
await options.adapter.pull({ paths: options.paths, state: options.sourceState, release: options.sourceState.activeRelease });
|
|
1405
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1406
|
+
await options.adapter.start({
|
|
1407
|
+
paths: options.paths,
|
|
1408
|
+
state: options.sourceState,
|
|
1409
|
+
release: options.sourceState.activeRelease,
|
|
1410
|
+
postgresVolumeName: options.sourceState.postgresVolumeName,
|
|
1411
|
+
});
|
|
1412
|
+
await options.adapter.health({ paths: options.paths, state: options.sourceState });
|
|
1413
|
+
await options.adapter.dataSmoke({ paths: options.paths, state: options.sourceState });
|
|
1414
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1415
|
+
commitRestoredCommunitySourceMetadata({
|
|
1416
|
+
paths: options.paths,
|
|
1417
|
+
sourceState: options.sourceState,
|
|
1418
|
+
nextLedger: next.records,
|
|
1419
|
+
});
|
|
1420
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1421
|
+
assertCommunityLedgerHasNoNonterminalOperation(options.paths);
|
|
1422
|
+
return { state: options.sourceState, reconciledRecord: next.reconciledRecord };
|
|
1423
|
+
}
|
|
1424
|
+
export async function recoverCommunityUpdate(options) {
|
|
1425
|
+
const mode = options.mode ?? 'recover';
|
|
1426
|
+
if (options.confirmDataRewind !== true)
|
|
1427
|
+
throw new Error('community_recover_data_rewind_confirmation_required');
|
|
1428
|
+
if (!UUID.test(options.updateId))
|
|
1429
|
+
throw new Error('community_recover_update_id_invalid');
|
|
1430
|
+
assertCommunityRecoveryMutationFence(options.paths, options.operationJournal && options.operationReceipt
|
|
1431
|
+
? { handle: options.operationJournal, operation: options.mode === 'rollback' ? 'rollback' : 'recover', receipt: options.operationReceipt }
|
|
1432
|
+
: undefined);
|
|
1433
|
+
const now = options.now ?? (() => new Date());
|
|
1434
|
+
const state = readCommunityInstallState(options.paths);
|
|
1435
|
+
const ledger = readLedger(options.paths);
|
|
1436
|
+
const priorAttempts = ledger.filter((entry) => entry.recoveredUpdateId === options.updateId);
|
|
1437
|
+
const priorAttempt = priorAttempts.at(-1);
|
|
1438
|
+
if (priorAttempt?.status === 'recovery-failed') {
|
|
1439
|
+
if (options.retryRecoveryId !== priorAttempt.id) {
|
|
1440
|
+
throw new Error(`community_recover_retry_confirmation_required:retry_recovery_id=${priorAttempt.id}`);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
else if (priorAttempt?.status === 'recovery-started') {
|
|
1444
|
+
throw new Error(`community_recovery_reconciliation_required:recovery_id=${priorAttempt.id}`);
|
|
1445
|
+
}
|
|
1446
|
+
else if (priorAttempt) {
|
|
1447
|
+
throw new Error(`community_recover_attempt_already_recorded:${priorAttempt.status}:recovery_id=${priorAttempt.id}`);
|
|
1448
|
+
}
|
|
1449
|
+
const matches = ledger.filter((entry) => entry.id === options.updateId);
|
|
1450
|
+
if (matches.length !== 1)
|
|
1451
|
+
throw new Error('community_recover_update_record_missing_or_ambiguous');
|
|
1452
|
+
const update = matches[0];
|
|
1453
|
+
if (update.status !== 'failed' && update.status !== 'succeeded') {
|
|
1454
|
+
throw new Error(`community_recover_update_status_invalid:${update.status}`);
|
|
1455
|
+
}
|
|
1456
|
+
if (mode === 'rollback' && update.status !== 'succeeded') {
|
|
1457
|
+
throw new Error('community_rollback_update_record_missing');
|
|
1458
|
+
}
|
|
1459
|
+
const expectedLatestId = priorAttempt?.status === 'recovery-failed' ? priorAttempt.id : update.id;
|
|
1460
|
+
if (update.status === 'failed' && ledger.at(-1)?.id !== expectedLatestId) {
|
|
1461
|
+
throw new Error('community_recover_failed_update_not_latest');
|
|
1462
|
+
}
|
|
1463
|
+
assertUpdateRecordLineage(options.paths, update);
|
|
1464
|
+
if (state.postgresVolumeName !== update.sourcePostgresVolumeName) {
|
|
1465
|
+
throw new Error('community_recover_source_volume_mismatch');
|
|
1466
|
+
}
|
|
1467
|
+
if (update.status === 'failed') {
|
|
1468
|
+
if (!sameInstalledRelease(state.activeRelease, update.priorRelease) || state.lastSuccessfulUpdateId === update.id) {
|
|
1469
|
+
throw new Error('community_recover_failed_update_state_mismatch');
|
|
1470
|
+
}
|
|
1471
|
+
if (typeof update.migrationMayHaveStarted !== 'boolean') {
|
|
1472
|
+
throw new Error('community_recover_failed_update_phase_unknown');
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
else if (!sameInstalledRelease(state.activeRelease, update.targetRelease)
|
|
1476
|
+
|| !sameInstalledRelease(state.previousRelease, update.priorRelease)
|
|
1477
|
+
|| state.lastSuccessfulUpdateId !== update.id) {
|
|
1478
|
+
throw new Error('community_recover_succeeded_update_state_mismatch');
|
|
1479
|
+
}
|
|
1480
|
+
const receipt = await readCommunityBackupReceipt(options.paths, update.backup.path);
|
|
1481
|
+
if (!sameBackupRecord(receipt, update.backup)) {
|
|
1482
|
+
throw new Error('community_recover_backup_receipt_mismatch');
|
|
1483
|
+
}
|
|
1484
|
+
if (!sameInstalledRelease(receipt.sourceRelease, update.priorRelease)) {
|
|
1485
|
+
throw new Error('community_recover_backup_release_mismatch');
|
|
1486
|
+
}
|
|
1487
|
+
const requiresRestore = update.status === 'succeeded' || update.migrationMayHaveStarted === true;
|
|
1488
|
+
const createId = options.createId ?? randomUUID;
|
|
1489
|
+
const purpose = mode === 'rollback' ? 'rollback' : 'recover';
|
|
1490
|
+
const updateSuffix = update.id.replace(/-/g, '');
|
|
1491
|
+
const recoveryId = createId();
|
|
1492
|
+
if (!UUID.test(recoveryId) || ledger.some((entry) => entry.id === recoveryId)) {
|
|
1493
|
+
throw new Error('community_recover_record_id_invalid');
|
|
1494
|
+
}
|
|
1495
|
+
const recoverySuffix = recoveryId.replace(/-/g, '');
|
|
1496
|
+
const replacementVolumeName = requiresRestore
|
|
1497
|
+
? `${state.composeProjectName}-pg-${purpose}-${updateSuffix}-${recoverySuffix}`
|
|
1498
|
+
: state.postgresVolumeName;
|
|
1499
|
+
const volumeClaim = requiresRestore
|
|
1500
|
+
? createPostgresVolumeClaim(state, replacementVolumeName, recoveryId)
|
|
1501
|
+
: undefined;
|
|
1502
|
+
const startedAt = nowIso(now);
|
|
1503
|
+
const recoveryRecord = {
|
|
1504
|
+
...update,
|
|
1505
|
+
id: recoveryId,
|
|
1506
|
+
status: 'recovery-started',
|
|
1507
|
+
startedAt,
|
|
1508
|
+
finishedAt: undefined,
|
|
1509
|
+
recoveredUpdateId: update.id,
|
|
1510
|
+
...(requiresRestore ? { replacementVolumeName } : {}),
|
|
1511
|
+
...(volumeClaim ? { volumeClaimSha256: volumeClaim.claimTokenSha256 } : {}),
|
|
1512
|
+
};
|
|
1513
|
+
let snapshot;
|
|
1514
|
+
try {
|
|
1515
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1516
|
+
await options.adapter.verifyRelease(update.priorRelease);
|
|
1517
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1518
|
+
if (requiresRestore) {
|
|
1519
|
+
snapshot = createVerifiedBackupSnapshot(options.paths, receipt, recoveryId, options.snapshotRuntime);
|
|
1520
|
+
await options.adapter.claimFreshPostgresVolume({
|
|
1521
|
+
paths: options.paths,
|
|
1522
|
+
state,
|
|
1523
|
+
claim: volumeClaim,
|
|
1524
|
+
});
|
|
1525
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1526
|
+
}
|
|
1527
|
+
await runLifecyclePromotion({
|
|
1528
|
+
journal: options.operationJournal,
|
|
1529
|
+
signal: options.signal,
|
|
1530
|
+
step: 'record-recovery-started',
|
|
1531
|
+
promote: () => writeLedger(options.paths, [...ledger, recoveryRecord]),
|
|
1532
|
+
});
|
|
1533
|
+
await options.adapter.stop({ paths: options.paths, state });
|
|
1534
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1535
|
+
if (requiresRestore) {
|
|
1536
|
+
await options.adapter.restoreBackup({
|
|
1537
|
+
paths: options.paths,
|
|
1538
|
+
state,
|
|
1539
|
+
backup: receipt,
|
|
1540
|
+
snapshot: snapshot,
|
|
1541
|
+
volumeClaim: volumeClaim,
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
await options.adapter.pull({ paths: options.paths, state, release: update.priorRelease });
|
|
1545
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1546
|
+
await options.adapter.start({
|
|
1547
|
+
paths: options.paths,
|
|
1548
|
+
state,
|
|
1549
|
+
release: update.priorRelease,
|
|
1550
|
+
postgresVolumeName: replacementVolumeName,
|
|
1551
|
+
});
|
|
1552
|
+
const validationState = {
|
|
1553
|
+
...state,
|
|
1554
|
+
activeRelease: update.priorRelease,
|
|
1555
|
+
postgresVolumeName: replacementVolumeName,
|
|
1556
|
+
};
|
|
1557
|
+
await options.adapter.health({ paths: options.paths, state: validationState });
|
|
1558
|
+
await options.adapter.dataSmoke({ paths: options.paths, state: validationState });
|
|
1559
|
+
throwIfCommunityCommandAborted(options.signal);
|
|
1560
|
+
const finishedAt = nowIso(now);
|
|
1561
|
+
recoveryRecord.status = mode === 'rollback' ? 'rolled-back' : 'recovered';
|
|
1562
|
+
recoveryRecord.finishedAt = finishedAt;
|
|
1563
|
+
const nextState = {
|
|
1564
|
+
...state,
|
|
1565
|
+
postgresVolumeName: replacementVolumeName,
|
|
1566
|
+
updatedAt: finishedAt,
|
|
1567
|
+
activeRelease: update.priorRelease,
|
|
1568
|
+
previousRelease: update.status === 'succeeded' ? update.targetRelease : state.previousRelease,
|
|
1569
|
+
lastSuccessfulUpdateId: update.status === 'succeeded' ? null : state.lastSuccessfulUpdateId,
|
|
1570
|
+
};
|
|
1571
|
+
const priorEnvContent = readFileSync(options.paths.envPath, 'utf8');
|
|
1572
|
+
const priorStateContent = readFileSync(options.paths.statePath, 'utf8');
|
|
1573
|
+
const priorLedgerContent = readFileSync(options.paths.ledgerPath, 'utf8');
|
|
1574
|
+
const nextLedger = [...ledger, recoveryRecord];
|
|
1575
|
+
await runLifecyclePromotion({
|
|
1576
|
+
journal: options.operationJournal,
|
|
1577
|
+
signal: options.signal,
|
|
1578
|
+
step: 'promote-recovery-success',
|
|
1579
|
+
promote: () => commitRecoveredInstallState({
|
|
1580
|
+
paths: options.paths,
|
|
1581
|
+
updateId: update.id,
|
|
1582
|
+
recoveryId: recoveryRecord.id,
|
|
1583
|
+
createdAt: finishedAt,
|
|
1584
|
+
priorEnvContent,
|
|
1585
|
+
priorStateContent,
|
|
1586
|
+
priorLedgerContent,
|
|
1587
|
+
nextEnvContent: renderUpdatedRuntimeEnvBindings(priorEnvContent, nextState),
|
|
1588
|
+
nextStateContent: serializeState(options.paths, nextState),
|
|
1589
|
+
nextLedgerContent: serializeLedger(options.paths, nextLedger),
|
|
1590
|
+
nextState,
|
|
1591
|
+
nextLedger,
|
|
1592
|
+
runtime: options.commitRuntime,
|
|
1593
|
+
}),
|
|
1594
|
+
});
|
|
1595
|
+
return recoveryRecord;
|
|
1596
|
+
}
|
|
1597
|
+
catch (error) {
|
|
1598
|
+
if (options.signal?.aborted)
|
|
1599
|
+
throw new Error('community_operation_aborted', { cause: error });
|
|
1600
|
+
if (tryLstat(options.paths.recoveryJournalPath)) {
|
|
1601
|
+
throw new Error(redactCommunityDiagnostic(error), { cause: error });
|
|
1602
|
+
}
|
|
1603
|
+
recoveryRecord.status = 'recovery-failed';
|
|
1604
|
+
recoveryRecord.finishedAt = nowIso(now);
|
|
1605
|
+
recoveryRecord.failure = redactCommunityDiagnostic(error);
|
|
1606
|
+
await runLifecyclePromotion({
|
|
1607
|
+
journal: options.operationJournal,
|
|
1608
|
+
step: 'record-recovery-failure',
|
|
1609
|
+
promote: () => writeLedger(options.paths, [...ledger, recoveryRecord]),
|
|
1610
|
+
});
|
|
1611
|
+
throw new Error(`community_recover_failed:update_id=${update.id}:recovery_id=${recoveryRecord.id}:target_volume=${replacementVolumeName}:${recoveryRecord.failure}`, { cause: error });
|
|
1612
|
+
}
|
|
1613
|
+
finally {
|
|
1614
|
+
if (snapshot)
|
|
1615
|
+
closeVerifiedBackupSnapshot(snapshot);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
export async function rollbackCommunityInstall(options) {
|
|
1619
|
+
if (options.confirmDataRewind !== true)
|
|
1620
|
+
throw new Error('community_rollback_data_rewind_confirmation_required');
|
|
1621
|
+
const state = readCommunityInstallState(options.paths);
|
|
1622
|
+
if (!state.previousRelease || !state.lastSuccessfulUpdateId) {
|
|
1623
|
+
throw new Error('community_rollback_prior_release_missing');
|
|
1624
|
+
}
|
|
1625
|
+
return recoverCommunityUpdate({
|
|
1626
|
+
...options,
|
|
1627
|
+
updateId: state.lastSuccessfulUpdateId,
|
|
1628
|
+
confirmDataRewind: true,
|
|
1629
|
+
mode: 'rollback',
|
|
1630
|
+
});
|
|
1631
|
+
}
|