fix: make server discovery and audits read-only
This commit is contained in:
+12
-10
@@ -15,9 +15,11 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
|
||||
5f4aca19a35cbcaffa1a6993ce96b7d66052ec2b286022f2af74594e8a310568 15712 CHANGELOG.md
|
||||
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
|
||||
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
|
||||
72e846f591c47a0291e7466e58e052d3d5afcf551c4e6c848632ac3c552a1244 3043 docs/CURRENT_STATE.md
|
||||
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
|
||||
eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md
|
||||
a0cd06a96f23a94e118feb012be0fa1ac51345951cb2ba8e67fb8c889c4c342a 5007 docs/LUMAOPS_SERVER_AUDIT.md
|
||||
648dd6bca6b45668fb86eb3e1f6c5898dd8da0291b990f1bb5105cfd79421343 1301 docs/MUTATION_MODEL.md
|
||||
f79908fb3dad98c38030c6e6be7c79a1999e0478ed9c2496923891954438daa1 4581 docs/RELEASE_AUDIT_0.6.0.md
|
||||
979a0b8e129979be6b265e8571d0a3c1e9ddd4ddb6b0bf55ae748d3478e51854 2296 docs/RELEASE_NOTES_0.10.0.md
|
||||
a0c00ff76acd1682bb5e0e8dcf6589c9480da436c9c6d30780a1ed58b4dad94f 1770 docs/RELEASE_NOTES_0.2.0.md
|
||||
@@ -80,8 +82,8 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
|
||||
3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs
|
||||
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
|
||||
efe2f75ec8bfdbad8e2ee68d0f2c4b412ba460c6b841163d7853f56d5b7b63ea 130468 package-lock.json
|
||||
605514833fe95a59cbfd5cee6a509ce8a0228a8b79c1fcc5bdb4e8fea92084b7 4364 package.json
|
||||
e95be3f3736f2c1ef249fdc9a083fffa46c07a0b885979878a552a4846995aa6 10160 preload.cjs
|
||||
f4ac31dd2266494085409ad5fc8bc440aa871296f7c64042495b6b14dec49084 4427 package.json
|
||||
3fb463572c62c94bec2d0675c73f8258e02d52cd9632b9fc99d4f24d137caf4e 10392 preload.cjs
|
||||
abe5dd6fd68f2970cd19ef134094907c67219061d8fe9a1a08324c78de4ad437 484 PUBLISH-AND-ENABLE-UPDATE.cmd
|
||||
f018383f755352ca448e2ebb1e19b1dba412a3eb793d61e64b02953e300754fd 10538 Publish-ForgeFlow-Release.ps1
|
||||
688fff7d2c989adb97ebb7fae38962656b70304a0aa5d27433c56adf7f136de0 4196 Publish-Missing-Binary-Release.ps1
|
||||
@@ -96,11 +98,11 @@ f6f89e893195b9c8ef0ff01e256005b9b3cd7d4a278722979a5e0e616c86a89f 1733
|
||||
403a64db5069595a83006a4e293d7e5ceeaefcfb74e820ed3e899864a0f182d2 6066 scripts/publish-binary-release.cjs
|
||||
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
|
||||
4393f7dc5f417e6d601a68238f4e26791799a3634acec228fe4d79deaee85eb5 3109 scripts/validate-installed-connections.cjs
|
||||
43e84c3cced1e23ba5b070d87051a235cd7e2d8c02e2e5ddc1e5e0ff2afabace 16487 scripts/verify.mjs
|
||||
20cc93c86efaf4d23851225e031639b093cafb23dabb083e1804cade17651e82 16542 scripts/verify.mjs
|
||||
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
|
||||
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
|
||||
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
|
||||
c9dee05857b6eb9475dace885579291b9ce6830023ab54acafaa146d15962a7a 27754 src/main/config-store.cjs
|
||||
80e0afcfa77dbc617c425cd603e5c165ea425b5f47046538657bc1dc51d7b1e7 28670 src/main/config-store.cjs
|
||||
2fb04b1494b39f5d7c0720fa5fd298cd46fa85dc1b696d77657592347fcf1819 2731 src/main/configuration-backup.cjs
|
||||
9d0af5074093108a5248d0dde0ff70a666748e61f1954b630886a81e8f34072c 24079 src/main/deployment-service.cjs
|
||||
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
|
||||
@@ -108,7 +110,7 @@ a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974
|
||||
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
|
||||
3ce45837099ac7bddc024974bd839575b4b765a7df9055e7d45ef889dc85bf7f 15623 src/main/git-validator-service.cjs
|
||||
d85d5b1abb35e8bd7f1273a697914eeaf96567d3d4ce3f55f364765d35ea4ad9 19900 src/main/gitea-service.cjs
|
||||
8adeebd08aeafaf79ed8fc7bcd2c1e110d3dc17c14596fb623b9d2c7255fcd11 49662 src/main/ipc.cjs
|
||||
127678735714595472c4a62188404284c6ae9cab43412b178c71836c9f34e767 50922 src/main/ipc.cjs
|
||||
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
|
||||
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
|
||||
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
|
||||
@@ -116,15 +118,15 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508
|
||||
17e2a53f61cd7faba461b9f332967143087eaac95b72001462292976278ca305 7782 src/main/repository-service.cjs
|
||||
52b6d88ed1f5c904a13cdde92e5f96d1e2b5971ceef49862152197353cdc6490 27928 src/main/server-inventory.cjs
|
||||
afef3841a3948b2121f8fba809aae4ea3da71bd2fda86973ba50200a5b1f89b2 14894 src/main/ssh-service.cjs
|
||||
75f4135bc068d6c615fc317cfda81f33a4387c4ff44fbb9799a055f302cc154d 137170 src/main/unraid-deployment-service.cjs
|
||||
d389e195eb48c5b1b801163c9bcf15e289cf312324aaaa8355b487834503a0fe 141995 src/main/unraid-deployment-service.cjs
|
||||
b654a9e45044ad32c61fabe4a6d897288615ec83739b53e3241ff881e32f56bd 21677 src/main/update-service.cjs
|
||||
07822cdaf34745678b82b7eb0f20fedb16bdbcd6ab2b9b774ae0cf9c6069032d 234703 src/renderer/app.js
|
||||
ea82d232b6a31218aeea3a807e4d490341474aa72d5d9c08db99cc3355fdbeb6 239190 src/renderer/app.js
|
||||
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
|
||||
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
|
||||
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
|
||||
fb7ed47f9aac50d9259d7d3c3bb2010c7bfdd2fe8e8e47ca2744bb22f0057d54 830 src/renderer/index.html
|
||||
b63786e7e4f70eac8b65d29484c0040aec530a410da5f554574d8a056cd90003 60621 src/renderer/mock-bridge.js
|
||||
81a15afced5b8782346d13a6ccbf3ba576a284d0e756eec6c704735026ee4b4c 61728 src/renderer/mock-bridge.js
|
||||
34ee56ed08dcd1c2295985b9bb419b981598a2699ee8e65394d86f4177f4797b 77647 src/renderer/styles.css
|
||||
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
|
||||
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
|
||||
@@ -140,7 +142,7 @@ f8853dce6fdf360d5df2fbe2b6df3e5687630c807fee5ba8436679b34ec737ea 2436
|
||||
f5b0ea887fcdeadec78c1ad49b0ec7979723562f5c0b730703acb77a37281ee0 1009 tests/acceptance.test.mjs
|
||||
a4e5947204ff6878e601e32477bc85b53cd0153baf95a161c8935b6e5466c257 1155 tests/audit-service.test.mjs
|
||||
454edeaccb2bd41043bc918d3e3a6127db14339031d6a1c1562ac855e90455d2 4318 tests/clone-target.test.mjs
|
||||
a984ddd5a29a4ccf55d78ba71202390e0bf1f0925a6a96f03ee74edbbf3bb2f7 1505 tests/configuration-backup.test.mjs
|
||||
f1463326aee79842d265687ae628189ce54e92544600f2bd14073780287cfb14 2502 tests/configuration-backup.test.mjs
|
||||
1dc6477bd07de78be189e6e8195ec339eb9d75820c4dbd5b073b8520ee21f6b5 1938 tests/deployment-policy.test.mjs
|
||||
bf68c4dc91a2604235c6a7848088bcd9566fbeaa089b86ec1e0a4fcfc54ca9d2 7677 tests/deployment-status.test.mjs
|
||||
fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 tests/diagnostics.test.mjs
|
||||
@@ -163,7 +165,7 @@ bab853feb0e22aa25af17989baaa632c01efa636533ea67407fecfdd973c7024 627
|
||||
020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs
|
||||
2571128f0b8e650071df17755baa09c4dfc441af0c20a7a4e9aa445b59e87d11 1654 tests/ssh-service.test.mjs
|
||||
8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs
|
||||
5634e82a3a3c782cd6d7cd4df42b3102c22b81c5566fb072d7cd30de65c1f395 40003 tests/unraid-deployment.test.mjs
|
||||
80e951961f015417160f8ba40b2e5a2fd85177b025cbae66ae482b962e58e8c4 40498 tests/unraid-deployment.test.mjs
|
||||
4abe7b2fc113c486f35f15c2d629c5b4f24589eada718c4ea58f93551c77d5eb 17777 tests/update-service.test.mjs
|
||||
9cea5c1d5ba3e0972a0b5c7236cf1f7c5616373e0a39ea4a492ecebf70452e40 948 tests/validation.test.mjs
|
||||
7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# ForgeFlow current state
|
||||
|
||||
## Baseline
|
||||
|
||||
- Baseline version: **0.10.0**
|
||||
- Baseline commit: `56efd1a00c2e76251a0b2e7a7a424d94200ae33c`
|
||||
- Baseline branch: `main`
|
||||
- Desktop runtime: Electron 43 with Node.js 22+ required by the source project
|
||||
- Primary supported packaged updater: Windows installer and portable executable
|
||||
|
||||
The baseline was recorded before the 1.0 professionalization programme. It is the comparison point for functional, deployment and renderer regressions.
|
||||
|
||||
## Known baseline evidence
|
||||
|
||||
- `npm run check`: 155 tests, 152 passed, 3 environment-dependent Bash checks skipped, 0 failed.
|
||||
- OS-backed secure storage, encrypted Gitea token, Gitea API, repository and Actions access were available.
|
||||
- Unraid exposed Docker, Compose, Git, tar and SHA-256 tooling.
|
||||
- The server inventory contained active repository workloads plus a large number of historical or unrelated definitions that require backend classification.
|
||||
- Windows release artifacts were checksum-protected but not Authenticode-signed.
|
||||
|
||||
No secret values, passwords, private keys or tokens are stored in this document.
|
||||
|
||||
## Operation classes
|
||||
|
||||
| Class | Default | Examples |
|
||||
| --- | --- | --- |
|
||||
| Read-only inspection | Allowed without confirmation | repository refresh, server inventory, deploy-key probe, preflight, audit export |
|
||||
| Reconciliation | Preview required | adopt an exact workload, refresh a profile from server truth |
|
||||
| Configuration mutation | Explicit action and audit record | save profile, rotate deploy key, change server settings |
|
||||
| Deployment | Fresh preflight and confirmation | server pull, Direct Copy, Gitea Actions dispatch |
|
||||
| Destructive maintenance | Recovery evidence and explicit confirmation | unlink, prune, key revocation, rollback |
|
||||
|
||||
Discovery and audit never belong to a mutating class. Ambiguous evidence cannot be promoted automatically.
|
||||
|
||||
## Recovery model
|
||||
|
||||
ForgeFlow writes its configuration atomically. Explicit server reconciliation additionally creates a private recovery snapshot before changing profiles or deployment state. Encrypted user-created `.ffbackup` files remain the portable restore mechanism; recovery snapshots are local operational safeguards and can contain OS-encrypted credential material.
|
||||
|
||||
## Issue priorities
|
||||
|
||||
- **P0:** active data loss, credential disclosure, arbitrary execution or uncontrolled production mutation.
|
||||
- **P1:** release-blocking incorrect deployment, unsafe implicit mutation, broken recovery or material security gap.
|
||||
- **P2:** important functional, accessibility, performance or maintainability defect with a safe workaround.
|
||||
- **P3:** polish, documentation or low-risk improvement.
|
||||
|
||||
## 1.0 constraints
|
||||
|
||||
- No force-push or implicit repository history rewrite.
|
||||
- No automatic deployment deletion.
|
||||
- No desktop Gitea token on a server.
|
||||
- Read-only repository-scoped deploy keys for server pull.
|
||||
- SSH host-key changes fail closed.
|
||||
- Live commit, remote commit and runtime health remain separate evidence.
|
||||
- Production signing must fail closed when the required external certificate is absent.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Mutation and reconciliation model
|
||||
|
||||
ForgeFlow separates observation from state changes at the API boundary.
|
||||
|
||||
## Discovery
|
||||
|
||||
`scanServerInventory()` and `discoverServerWorkloads()` collect Docker, Compose, DockerMan and Git evidence. They may write diagnostic logs, but they do not save, update or delete deployment profiles and do not change containers.
|
||||
|
||||
## Reconciliation planning
|
||||
|
||||
`planServerInventoryReconciliation()` returns a content-addressed plan containing:
|
||||
|
||||
- exact links that may be added;
|
||||
- existing profiles whose observed metadata may be refreshed;
|
||||
- stale profiles that require review;
|
||||
- ambiguous workloads that block automatic application.
|
||||
|
||||
The plan identifier changes whenever its proposed scope changes.
|
||||
|
||||
## Reconciliation application
|
||||
|
||||
`reconcileServerInventory()` requires the exact reviewed plan identifier. It rescans the server and refuses a stale plan. Before writing configuration it creates a private recovery snapshot. Stale profiles are reported but never removed automatically.
|
||||
|
||||
## Direct mutations
|
||||
|
||||
Manual linking, unlinking, deploy-key rotation, deployment and rollback remain separate explicit commands. Each must append an audit event with repository, profile, operation identifier and result. Destructive commands need a dedicated confirmation flow and recovery path.
|
||||
+3
-1
@@ -92,7 +92,9 @@
|
||||
"docs/RELEASE_NOTES_0.9.3.md",
|
||||
"docs/RELEASE_NOTES_0.9.4.md",
|
||||
"docs/RELEASE_NOTES_0.9.5.md",
|
||||
"docs/RELEASE_NOTES_0.10.0.md"
|
||||
"docs/RELEASE_NOTES_0.10.0.md",
|
||||
"docs/CURRENT_STATE.md",
|
||||
"docs/MUTATION_MODEL.md"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"scripts/apply-binary-update.ps1"
|
||||
|
||||
@@ -121,6 +121,8 @@ contextBridge.exposeInMainWorld(
|
||||
healthcheck: (url) => invoke('deployment:health', { url }),
|
||||
refreshProfileState: (fullName, profileId) => invoke('deployment:profile-state', { fullName, profileId }),
|
||||
discoverServerDeployments: () => invoke('deployment:discover-server-workloads'),
|
||||
planServerReconciliation: (serverId) => invoke('deployment:plan-server-reconciliation', { serverId }),
|
||||
applyServerReconciliation: (serverId, planId) => invoke('deployment:apply-server-reconciliation', { serverId, planId }),
|
||||
linkServerWorkload: (repository, serverId, workloadId, deploymentMode = 'server-git', remoteFolder = '') => invoke('deployment:link-server-workload', { repository, serverId, workloadId, deploymentMode, remoteFolder }),
|
||||
configureServerGitAccess: (repository, profileId) => invoke('deployment:configure-server-git-access', { repository, profileId }),
|
||||
applyDockerManMetadata: (repository, profileId) => invoke('deployment:apply-dockerman-metadata', { repository, profileId }),
|
||||
|
||||
@@ -57,6 +57,8 @@ const required = [
|
||||
"scripts/apply-source-update.ps1",
|
||||
"scripts/apply-binary-update.ps1",
|
||||
"docs/ARCHITECTURE.md",
|
||||
"docs/CURRENT_STATE.md",
|
||||
"docs/MUTATION_MODEL.md",
|
||||
"docs/SECURITY.md",
|
||||
"docs/ROADMAP.md",
|
||||
"docs/SETUP_GUIDE.md",
|
||||
|
||||
@@ -142,6 +142,19 @@ class ConfigStore {
|
||||
return this.saveQueue;
|
||||
}
|
||||
|
||||
async createRecoverySnapshot(reason = 'configuration-change') {
|
||||
await this.saveQueue.catch(() => {});
|
||||
const safeReason = String(reason || 'configuration-change').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'configuration-change';
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const snapshotDirectory = path.join(path.dirname(this.filePath), 'snapshots');
|
||||
const snapshotPath = path.join(snapshotDirectory, `${timestamp}-${safeReason}.json`);
|
||||
await fs.mkdir(snapshotDirectory, { recursive: true });
|
||||
await fs.writeFile(snapshotPath, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
||||
try { await fs.chmod(snapshotDirectory, 0o700); } catch {}
|
||||
try { await fs.chmod(snapshotPath, 0o600); } catch {}
|
||||
return { filePath: snapshotPath, reason: safeReason, createdAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
setToken(token, { preserveExisting = false } = {}) {
|
||||
const value = String(token || '').trim();
|
||||
if (!value && preserveExisting && this.getToken()) return { persistent: Boolean(this.data.gitea.encryptedToken), preserved: true };
|
||||
|
||||
@@ -1244,6 +1244,31 @@ function registerIpc({
|
||||
}
|
||||
return results;
|
||||
});
|
||||
register("deployment:plan-server-reconciliation", async ({ serverId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
|
||||
const result = await unraid.planServerInventoryReconciliation(serverId, remoteRepositories, { autoLink: true });
|
||||
await audit.append("deployment.server-reconciliation-planned", {
|
||||
serverId,
|
||||
planId: result.plan.id,
|
||||
summary: result.plan.summary,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
register("deployment:apply-server-reconciliation", async ({ serverId, planId }) => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter((repository) => repository.owner?.login !== "local");
|
||||
const result = await unraid.reconcileServerInventory(serverId, remoteRepositories, { autoLink: true, expectedPlanId: planId });
|
||||
await audit.append("deployment.server-reconciliation-applied", {
|
||||
serverId,
|
||||
planId,
|
||||
adopted: result.adopted,
|
||||
refreshed: result.refreshed,
|
||||
retired: result.retired,
|
||||
recoverySnapshot: result.recoverySnapshot?.filePath || null,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:profile-state", async ({ fullName, profileId }) => {
|
||||
const profile = store.getDeploymentProfile(fullName, profileId);
|
||||
if (profile?.provider === "ssh-unraid") {
|
||||
|
||||
@@ -1128,7 +1128,7 @@ $extra"
|
||||
});
|
||||
}
|
||||
|
||||
async scanServerInventory(serverId, repositories, { autoLink = true } = {}) {
|
||||
async collectServerInventory(serverId, repositories) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error("The deployment server no longer exists.");
|
||||
const result = await this.ssh.exec(server.id, bash(this.inventoryScript(server)), {
|
||||
@@ -1162,9 +1162,123 @@ $extra"
|
||||
workload.link = null;
|
||||
workload.status = workload.candidates?.length ? "suggested" : "unmatched";
|
||||
}
|
||||
return { server, inventory, workloads };
|
||||
}
|
||||
|
||||
inventoryResponse(server, inventory, workloads, changes = {}) {
|
||||
const summary = {
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
detected: workloads.length,
|
||||
adopted: Number(changes.adopted || 0),
|
||||
refreshed: Number(changes.refreshed || 0),
|
||||
retired: Number(changes.retired || 0),
|
||||
staleProfiles: Array.isArray(changes.staleProfiles) ? changes.staleProfiles : [],
|
||||
verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length,
|
||||
linked: workloads.filter((item) => item.status === "linked").length,
|
||||
unmatched: workloads.filter((item) => !item.link).length,
|
||||
needsReview: workloads.filter((item) => ["suggested", "ambiguous", "unmatched"].includes(item.status)).length,
|
||||
running: workloads.filter((item) => item.runtime.running).length,
|
||||
stopped: workloads.filter((item) => !item.runtime.running).length,
|
||||
};
|
||||
return {
|
||||
...summary,
|
||||
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
|
||||
capabilities: inventory.capabilities,
|
||||
warnings: inventory.warnings,
|
||||
workloads,
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async scanServerInventory(serverId, repositories) {
|
||||
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
||||
const response = this.inventoryResponse(server, inventory, workloads);
|
||||
await this.diagnostics?.info("unraid.workloads.scanned", {
|
||||
serverId,
|
||||
detected: response.detected,
|
||||
linked: response.linked,
|
||||
needsReview: response.needsReview,
|
||||
readOnly: true,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) {
|
||||
const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id);
|
||||
const activeWorkloadIds = new Set(workloads.map((item) => item.workloadId));
|
||||
const linkedRepositories = new Set(workloads.filter((item) => item.link?.repositoryFullName).map((item) => String(item.link.repositoryFullName).toLowerCase()));
|
||||
const additions = [];
|
||||
const updates = [];
|
||||
const conflicts = [];
|
||||
for (const workload of workloads) {
|
||||
if (workload.link?.profileId && workload.link?.repositoryFullName) {
|
||||
updates.push({
|
||||
workloadId: workload.workloadId,
|
||||
profileId: workload.link.profileId,
|
||||
repositoryFullName: workload.link.repositoryFullName,
|
||||
impact: "Refresh detected Compose identity and observed deployment state",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const candidate = workload.candidates?.[0];
|
||||
const unique = workload.candidates?.length === 1;
|
||||
const exact = unique && (candidate?.exact === true || (candidate?.identityExact === true && candidate.score >= 70));
|
||||
if (autoLink && exact && workload.runtime?.running && !linkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) {
|
||||
additions.push({
|
||||
workloadId: workload.workloadId,
|
||||
repositoryFullName: candidate.repositoryFullName,
|
||||
evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity",
|
||||
impact: "Create a server-pull deployment profile; no container changes",
|
||||
});
|
||||
} else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) {
|
||||
conflicts.push({
|
||||
workloadId: workload.workloadId,
|
||||
displayName: workload.displayName,
|
||||
status: workload.status,
|
||||
candidates: (workload.candidates || []).slice(0, 5).map((item) => ({ repositoryFullName: item.repositoryFullName, score: item.score, exact: item.exact === true })),
|
||||
});
|
||||
}
|
||||
}
|
||||
const stale = profiles.filter((profile) =>
|
||||
String(profile.workloadIdentity?.linkSource || "").startsWith("automatic")
|
||||
&& profile.workloadIdentity?.workloadId
|
||||
&& !activeWorkloadIds.has(profile.workloadIdentity.workloadId),
|
||||
).map((profile) => ({
|
||||
profileId: profile.id,
|
||||
repositoryFullName: profile._repositoryFullName,
|
||||
reason: "workload-missing",
|
||||
impact: "Review only; ForgeFlow will not remove this profile automatically",
|
||||
}));
|
||||
const payload = { serverId: server.id, additions, updates, stale, conflicts };
|
||||
return {
|
||||
id: crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"),
|
||||
createdAt: new Date().toISOString(),
|
||||
...payload,
|
||||
summary: { additions: additions.length, updates: updates.length, stale: stale.length, conflicts: conflicts.length },
|
||||
};
|
||||
}
|
||||
|
||||
async planServerInventoryReconciliation(serverId, repositories, options = {}) {
|
||||
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
||||
const plan = this.reconciliationPlan(server, workloads, repositories, options);
|
||||
return { inventory: this.inventoryResponse(server, inventory, workloads), plan };
|
||||
}
|
||||
|
||||
async reconcileServerInventory(serverId, repositories, { autoLink = true, expectedPlanId = "" } = {}) {
|
||||
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
|
||||
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink });
|
||||
if (!expectedPlanId || expectedPlanId !== plan.id) {
|
||||
const error = new Error(expectedPlanId ? "The server inventory changed after the reconciliation preview. Review a fresh plan before applying it." : "Apply reconciliation only with an explicitly reviewed plan ID.");
|
||||
error.code = expectedPlanId ? "RECONCILIATION_PLAN_STALE" : "RECONCILIATION_PLAN_REQUIRED";
|
||||
error.plan = plan;
|
||||
throw error;
|
||||
}
|
||||
const recoverySnapshot = await this.store.createRecoverySnapshot?.(`server-reconciliation-${serverId}`) || null;
|
||||
let adopted = 0;
|
||||
let refreshed = 0;
|
||||
let retired = 0;
|
||||
let staleProfiles = [];
|
||||
const inventoryStable = (inventory.warnings || []).every((warning) => /stale container reference\(s\) disappeared during inventory/i.test(warning));
|
||||
if (inventoryStable && workloads.length) {
|
||||
const activeWorkloadIds = new Set(workloads.map((item) => item.workloadId));
|
||||
@@ -1174,10 +1288,6 @@ $extra"
|
||||
&& profile.workloadIdentity?.workloadId
|
||||
&& !activeWorkloadIds.has(profile.workloadIdentity.workloadId),
|
||||
);
|
||||
for (const profile of staleAutomaticProfiles) {
|
||||
await this.store.deleteDeploymentProfile(profile._repositoryFullName, profile.id);
|
||||
retired += 1;
|
||||
}
|
||||
const runningRepositoryLinks = new Set(workloads
|
||||
.filter((workload) => workload.runtime?.running && workload.link?.repositoryFullName)
|
||||
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
|
||||
@@ -1188,10 +1298,11 @@ $extra"
|
||||
.filter((workload) => !workload.runtime?.running && workload.link?.profileId && !runningProfileIds.has(workload.link.profileId) && runningRepositoryLinks.has(String(workload.link.repositoryFullName).toLowerCase()))
|
||||
.map((workload) => this.allSshProfiles().find((profile) => profile.id === workload.link.profileId && String(profile._repositoryFullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase()))
|
||||
.filter((profile) => profile && String(profile.workloadIdentity?.linkSource || "").startsWith("automatic"));
|
||||
for (const profile of shadowedAutomaticProfiles) {
|
||||
await this.store.deleteDeploymentProfile(profile._repositoryFullName, profile.id);
|
||||
retired += 1;
|
||||
}
|
||||
staleProfiles = [...new Map([...staleAutomaticProfiles, ...shadowedAutomaticProfiles].map((profile) => [profile.id, {
|
||||
profileId: profile.id,
|
||||
repositoryFullName: profile._repositoryFullName,
|
||||
reason: staleAutomaticProfiles.includes(profile) ? "workload-missing" : "shadowed-by-running-workload",
|
||||
}])).values()];
|
||||
}
|
||||
for (const workload of workloads) {
|
||||
if (workload.status !== "linked" || !workload.link?.profileId || !workload.link?.repositoryFullName) continue;
|
||||
@@ -1214,21 +1325,16 @@ $extra"
|
||||
const uniqueCandidate = workload.candidates.length === 1;
|
||||
if (candidate && alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase())) continue;
|
||||
const exactMatch = uniqueCandidate && candidate?.exact === true;
|
||||
const strongComposeMatch = uniqueCandidate
|
||||
&& candidate?.confidence === "strong"
|
||||
&& candidate.score >= 90
|
||||
&& Boolean(workload.compose?.workingDir)
|
||||
&& Boolean(workload.compose?.configFiles?.length);
|
||||
const exactRuntimeIdentity = uniqueCandidate
|
||||
&& candidate?.identityExact === true
|
||||
&& candidate.score >= 70
|
||||
&& workload.runtime?.running === true
|
||||
&& Boolean(workload.remoteFolderCandidate)
|
||||
&& !alreadyLinkedRepositories.has(String(candidate.repositoryFullName).toLowerCase());
|
||||
if (!exactMatch && !strongComposeMatch && !exactRuntimeIdentity) continue;
|
||||
if (!exactMatch && !exactRuntimeIdentity) continue;
|
||||
const repository = (repositories || []).find((item) => item.fullName === candidate.repositoryFullName);
|
||||
if (!repository) continue;
|
||||
const linkSource = exactMatch ? "automatic" : exactRuntimeIdentity ? "automatic-runtime-identity" : "automatic-compose";
|
||||
const linkSource = exactMatch ? "automatic" : "automatic-runtime-identity";
|
||||
const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" });
|
||||
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
|
||||
await this.saveWorkloadState(saved, workload, server);
|
||||
@@ -1238,34 +1344,20 @@ $extra"
|
||||
adopted += 1;
|
||||
}
|
||||
}
|
||||
const summary = {
|
||||
const response = this.inventoryResponse(server, inventory, workloads, { adopted, refreshed, retired, staleProfiles });
|
||||
response.recoverySnapshot = recoverySnapshot;
|
||||
await this.diagnostics?.info("unraid.workloads.reconciled", {
|
||||
serverId,
|
||||
serverName: server.name,
|
||||
detected: workloads.length,
|
||||
detected: response.detected,
|
||||
adopted,
|
||||
refreshed,
|
||||
retired,
|
||||
verified: workloads.filter((item) => item.runtime.health === "healthy" && item.link).length,
|
||||
linked: workloads.filter((item) => item.status === "linked").length,
|
||||
unmatched: workloads.filter((item) => !item.link).length,
|
||||
needsReview: workloads.filter((item) => ["suggested", "ambiguous", "unmatched"].includes(item.status)).length,
|
||||
running: workloads.filter((item) => item.runtime.running).length,
|
||||
stopped: workloads.filter((item) => !item.runtime.running).length,
|
||||
};
|
||||
const response = {
|
||||
...summary,
|
||||
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
|
||||
capabilities: inventory.capabilities,
|
||||
warnings: inventory.warnings,
|
||||
workloads,
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.diagnostics?.info("unraid.workloads.discovered", summary);
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async discoverServerWorkloads(serverId, repositories) {
|
||||
return this.scanServerInventory(serverId, repositories, { autoLink: true });
|
||||
return this.scanServerInventory(serverId, repositories);
|
||||
}
|
||||
|
||||
async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
|
||||
@@ -1274,7 +1366,7 @@ $extra"
|
||||
: "server-git";
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error("The deployment server no longer exists.");
|
||||
const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false });
|
||||
const inventory = await this.scanServerInventory(serverId, [repository]);
|
||||
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
|
||||
if (!workload) throw new Error("The selected server workload no longer exists. Scan the server again.");
|
||||
const existing = this.allSshProfiles().find((profile) => profile.workloadIdentity?.workloadId === workloadId && profile.serverId === serverId);
|
||||
|
||||
+35
-1
@@ -1215,7 +1215,7 @@ function renderServerInventory() {
|
||||
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</span>` : ""}</div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(linked ? "Linked" : workload.status || "Review")}</span>${linked ? `<button class="button ghost" data-action="edit-deployment-profile" data-profile-id="${attr(workload.link?.profileId || "")}">Open link</button>` : linkButton}</div></div>`;
|
||||
}).join("")
|
||||
: `<div class="empty-state compact"><p>${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}</p></div>`;
|
||||
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
|
||||
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
|
||||
}).join("");
|
||||
const empty = configuredServers.length
|
||||
? `<div class="empty-state panel"><h3>Server inventory has not completed</h3><p>ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.</p><button class="button primary" data-action="scan-server-inventory">Scan servers now</button></div>`
|
||||
@@ -1367,6 +1367,17 @@ function renderModal() {
|
||||
const retryText = ui.modal.retry?.type === "deploy" ? "Save password & redeploy" : "Save password & rescan";
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Use password for ${escapeHtml(server.name)}</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}ForgeFlow stores the server password with Windows protected storage and uses it only for the desktop → Unraid connection.</div><div class="field" style="margin-top:14px"><label>SSH password for ${escapeHtml(server.username)}@${escapeHtml(server.host)}</label><input id="quick-server-password" class="input" type="password" autocomplete="current-password" autofocus placeholder="Server password"/></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-server-password" data-server-id="${attr(server.id)}">${escapeHtml(retryText)}</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "server-reconciliation-plan") {
|
||||
const plan = ui.modal.result?.plan || {};
|
||||
const summary = plan.summary || {};
|
||||
const rows = [
|
||||
...(plan.additions || []).map((item) => ({ tone: "success", title: `Link ${item.repositoryFullName}`, detail: `${item.evidence} · ${item.impact}` })),
|
||||
...(plan.updates || []).map((item) => ({ tone: "", title: `Refresh ${item.repositoryFullName}`, detail: item.impact })),
|
||||
...(plan.stale || []).map((item) => ({ tone: "warning", title: `Review stale link ${item.repositoryFullName}`, detail: `${item.reason} · no automatic removal` })),
|
||||
...(plan.conflicts || []).map((item) => ({ tone: "danger", title: `Manual review: ${item.displayName}`, detail: `${item.status} · ${(item.candidates || []).map((candidate) => candidate.repositoryFullName).join(", ") || "no unique repository"}` })),
|
||||
];
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="reconciliation-title"><header class="modal-header"><h2 id="reconciliation-title">Review server reconciliation</h2><button class="icon-button" data-action="close-modal" aria-label="Close reconciliation preview">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}This reviewed plan may update ForgeFlow configuration only. It never starts, stops or recreates containers, and stale profiles are never removed automatically.</div><div class="summary-grid" style="margin-top:12px"><div class="summary-card"><span>New links</span><strong>${Number(summary.additions || 0)}</strong></div><div class="summary-card"><span>Refreshes</span><strong>${Number(summary.updates || 0)}</strong></div><div class="summary-card"><span>Stale reviews</span><strong>${Number(summary.stale || 0)}</strong></div><div class="summary-card"><span>Conflicts</span><strong>${Number(summary.conflicts || 0)}</strong></div></div><div class="tool-list" style="margin-top:14px">${rows.length ? rows.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.detail)}</span></div>${item.tone ? `<span class="status-pill ${item.tone}">${escapeHtml(item.tone === "success" ? "Planned" : item.tone === "warning" ? "Review" : "Blocked")}</span>` : ""}</div>`).join("") : '<div class="empty-state compact"><p>No configuration changes are proposed.</p></div>'}</div><div class="notice" style="margin-top:12px">${icon("archive")}A private recovery snapshot is written before the plan is applied. Plan ID: <span class="mono">${escapeHtml(String(plan.id || "").slice(0, 12))}</span></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="apply-server-reconciliation" data-server-id="${attr(plan.serverId || "")}" data-plan-id="${attr(plan.id || "")}" ${summary.conflicts ? "disabled title=\"Resolve ambiguous workloads manually before applying reconciliation\"" : ""}>Apply reviewed plan</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "workload-link") {
|
||||
const serverResult = (ui.serverDiscovery || []).find(
|
||||
(item) => item.serverId === ui.modal.serverId,
|
||||
@@ -2361,6 +2372,29 @@ app.addEventListener("click", async (event) => {
|
||||
showToast("Server scan failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "plan-server-reconciliation") {
|
||||
setLoading(true, "Building a read-only reconciliation preview…");
|
||||
try {
|
||||
const result = await window.forgeflow.planServerReconciliation(target.dataset.serverId);
|
||||
ui.modal = { type: "server-reconciliation-plan", result };
|
||||
render();
|
||||
} catch (error) {
|
||||
showToast("Could not build reconciliation plan", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "apply-server-reconciliation") {
|
||||
setLoading(true, "Applying the reviewed configuration plan…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyServerReconciliation(target.dataset.serverId, target.dataset.planId);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null;
|
||||
await refreshRepositories(false, true);
|
||||
await refreshDeploymentTruth(false);
|
||||
showToast("Reconciliation applied", `${result.adopted || 0} link(s) added and ${result.refreshed || 0} profile(s) refreshed. No containers were changed.`, "success");
|
||||
} catch (error) {
|
||||
showToast("Reconciliation was not applied", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "quick-link-server-workload") {
|
||||
const serverResult = (ui.serverDiscovery || []).find(
|
||||
(item) => item.serverId === target.dataset.serverId,
|
||||
|
||||
@@ -1511,6 +1511,27 @@
|
||||
},
|
||||
];
|
||||
},
|
||||
async planServerReconciliation(serverId) {
|
||||
await wait(90);
|
||||
const id = "a".repeat(64);
|
||||
return {
|
||||
inventory: (await this.discoverServerDeployments()).find((item) => item.serverId === serverId),
|
||||
plan: {
|
||||
id,
|
||||
serverId,
|
||||
summary: { additions: 0, updates: 1, stale: 0, conflicts: 1 },
|
||||
additions: [],
|
||||
updates: [{ workloadId: "workload-demo-linked", profileId: "profile-portfolio", repositoryFullName: "jens/portfolio", impact: "Refresh detected Compose identity and observed deployment state" }],
|
||||
stale: [],
|
||||
conflicts: [{ workloadId: "workload-demo-review", displayName: "OmniRoute", status: "suggested", candidates: [{ repositoryFullName: repositories[0].fullName, score: 55, exact: false }] }],
|
||||
},
|
||||
};
|
||||
},
|
||||
async applyServerReconciliation(serverId, planId) {
|
||||
await wait(120);
|
||||
if (serverId !== "server-unraid" || planId !== "a".repeat(64)) throw new Error("The reconciliation plan is stale.");
|
||||
return { adopted: 0, refreshed: 1, retired: 0, state: clone(state) };
|
||||
},
|
||||
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") {
|
||||
await wait(120);
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import backupModule from '../src/main/configuration-backup.cjs';
|
||||
import configModule from '../src/main/config-store.cjs';
|
||||
|
||||
const { sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup } = backupModule;
|
||||
const { ConfigStore } = configModule;
|
||||
|
||||
test('configuration backups exclude credentials and operation history', () => {
|
||||
const clean = sanitizeConfiguration({
|
||||
@@ -25,3 +30,16 @@ test('configuration backups round-trip with authenticated encryption', () => {
|
||||
assert.equal(restored.configuration.gitea.encryptedToken, null);
|
||||
assert.throws(() => readEncryptedBackup(serialized, 'incorrect passphrase'), /could not be decrypted/i);
|
||||
});
|
||||
|
||||
test('recovery snapshots preserve the exact in-memory configuration before a mutation', async (context) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'forgeflow-config-snapshot-'));
|
||||
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const store = new ConfigStore(directory);
|
||||
store.data.workspaceRoots = ['C:/Projects'];
|
||||
store.data.deploymentProfiles = { 'jens/example': [{ id: 'production', provider: 'gitea-actions' }] };
|
||||
await store.save();
|
||||
const before = `${JSON.stringify(store.data, null, 2)}\n`;
|
||||
const snapshot = await store.createRecoverySnapshot('server reconciliation / production');
|
||||
assert.equal(await readFile(snapshot.filePath, 'utf8'), before);
|
||||
assert.equal(snapshot.reason, 'server-reconciliation-production');
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ test("server workload inventory links running containers to exact Gitea checkout
|
||||
);
|
||||
});
|
||||
|
||||
test("automatic server discovery adopts and verifies a running Gitea deployment", async () => {
|
||||
test("server discovery is read-only and explicit reconciliation adopts a verified deployment", async () => {
|
||||
const b64 = (value) => Buffer.from(value).toString("base64");
|
||||
const sha = "b".repeat(40);
|
||||
const container = {
|
||||
@@ -155,7 +155,7 @@ test("automatic server discovery adopts and verifies a running Gitea deployment"
|
||||
},
|
||||
gitea: { getBranch: async () => ({ commit: { id: sha } }) },
|
||||
});
|
||||
const result = await service.discoverServerWorkloads("unraid", [
|
||||
const repositories = [
|
||||
{
|
||||
fullName: "Jens/Portfolio",
|
||||
name: "Portfolio",
|
||||
@@ -163,7 +163,16 @@ test("automatic server discovery adopts and verifies a running Gitea deployment"
|
||||
cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git",
|
||||
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
|
||||
},
|
||||
]);
|
||||
];
|
||||
const discovery = await service.discoverServerWorkloads("unraid", repositories);
|
||||
assert.equal(discovery.adopted, 0);
|
||||
assert.equal(discovery.verified, 0);
|
||||
assert.equal(profiles.length, 0);
|
||||
assert.equal(states.size, 0);
|
||||
|
||||
const preview = await service.planServerInventoryReconciliation("unraid", repositories, { autoLink: true });
|
||||
assert.equal(preview.plan.summary.additions, 1);
|
||||
const result = await service.reconcileServerInventory("unraid", repositories, { autoLink: true, expectedPlanId: preview.plan.id });
|
||||
assert.equal(result.adopted, 1);
|
||||
assert.equal(result.verified, 1);
|
||||
assert.equal(profiles[0].containerName, "Portfolio");
|
||||
|
||||
Reference in New Issue
Block a user