feat: normalize deployment inventory evidence
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { classifyInventory } = require("../src/main/inventory-classifier.cjs");
|
||||
const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("../src/main/deployment-identity.cjs");
|
||||
const { InventoryReviewService } = require("../src/main/inventory-review-service.cjs");
|
||||
|
||||
function workload(id, options = {}) {
|
||||
return {
|
||||
workloadId: id, serverId: options.serverId || "unraid", displayName: options.name || id,
|
||||
status: options.status || (options.link ? "linked" : "suggested"), link: options.link || null,
|
||||
compose: { project: options.project || id, workingDir: options.root || `/mnt/user/appdata/${id}`, configFiles: options.files || [`/mnt/user/appdata/${id}/compose.yml`], services: ["app"] },
|
||||
containers: options.noContainers ? [] : [{ id: `container-${id}`, name: id, running: options.running !== false }],
|
||||
runtime: { running: options.running !== false, health: options.health || "healthy" },
|
||||
metadata: { sourceRepository: options.remote ?? "git@gitea.test:Jens/Portfolio.git", liveRevision: options.sha || "a".repeat(40), branch: options.branch || "main" },
|
||||
candidates: options.candidates || [{ repositoryFullName: "Jens/Portfolio", score: 100, exact: true }],
|
||||
remoteFolderCandidate: id,
|
||||
};
|
||||
}
|
||||
|
||||
test("deployment identity is canonical across SSH and HTTPS remotes", () => {
|
||||
const ssh = deploymentIdentity({ workload: workload("one") });
|
||||
const https = deploymentIdentity({ workload: workload("two", { remote: "https://gitea.test/Jens/Portfolio.git" }) });
|
||||
assert.equal(ssh.repository, https.repository);
|
||||
assert.equal(deploymentAuthorityKey(ssh), deploymentAuthorityKey({ ...https, serverId: ssh.serverId, environment: ssh.environment }));
|
||||
});
|
||||
|
||||
test("running duplicate is authoritative and historical folder is never linked", () => {
|
||||
const active = workload("portfolio-current", { link: { profileId: "profile", repositoryFullName: "Jens/Portfolio" } });
|
||||
const historical = workload("portfolio-old", { running: false, root: "/mnt/user/appdata/portfolio/releases/old", link: { profileId: "profile-old", repositoryFullName: "Jens/Portfolio" } });
|
||||
const result = classifyInventory([historical, active], [{ id: "profile", environment: "production" }, { id: "profile-old", environment: "production" }]);
|
||||
assert.equal(result.find((item) => item.workloadId === "portfolio-current").authoritative, true);
|
||||
assert.equal(result.find((item) => item.workloadId === "portfolio-old").classification.type, "duplicate");
|
||||
assert.equal(result.find((item) => item.workloadId === "portfolio-old").link, null);
|
||||
});
|
||||
|
||||
for (const [label, item, expected] of [
|
||||
["backup Compose folder", workload("backup", { root: "/mnt/user/appdata/portfolio-backup", running: false }), "backup"],
|
||||
["release directory", workload("release", { root: "/mnt/user/appdata/portfolio/releases/a1", running: false }), "release-folder"],
|
||||
["staging workload", workload("stage", { root: "/mnt/user/appdata/portfolio-staging" }), "staging"],
|
||||
["stopped legitimate app", workload("stopped", { running: false }), "stopped-application"],
|
||||
["Compose without container", workload("historical", { noContainers: true, running: false }), "historical-compose"],
|
||||
["container without repository", workload("orphan", { remote: "", candidates: [], status: "unmatched" }), "orphan-container"],
|
||||
["system container", workload("infra", { name: "watchtower", remote: "", candidates: [] }), "system-container"],
|
||||
["ambiguous exact matches", workload("ambiguous", { status: "ambiguous", candidates: [{ repositoryFullName: "Jens/A", score: 100, exact: true }, { repositoryFullName: "Jens/B", score: 100, exact: true }] }), "ambiguous"],
|
||||
["profile whose server workload disappeared", { ...workload("stale", { running: false, noContainers: true, link: { profileId: "profile-stale", repositoryFullName: "Jens/Portfolio" } }), metadata: { sourceRepository: "git@gitea.test:Jens/Portfolio.git", branch: "main", staleLink: true } }, "stale-link"],
|
||||
]) test(`inventory classifies ${label}`, () => {
|
||||
assert.equal(classifyInventory([item])[0].classification.type, expected);
|
||||
});
|
||||
|
||||
test("multi-instance environments remain separate authority groups", () => {
|
||||
const a = workload("instance-a", { link: { profileId: "a", repositoryFullName: "Jens/Portfolio" } });
|
||||
const b = workload("instance-b", { link: { profileId: "b", repositoryFullName: "Jens/Portfolio" } });
|
||||
const result = classifyInventory([a, b], [{ id: "a", environment: "production" }, { id: "b", environment: "staging" }]);
|
||||
assert.equal(result.filter((item) => item.classification.type === "duplicate").length, 0);
|
||||
});
|
||||
|
||||
test("stored review decision becomes stale when remote evidence changes", () => {
|
||||
const original = classifyInventory([workload("review")])[0];
|
||||
const decision = { workloadId: original.workloadId, evidenceHash: original.evidenceHash, action: "ignore", reason: "Known external workload" };
|
||||
const unchanged = classifyInventory([workload("review")], [], [decision])[0];
|
||||
const changed = classifyInventory([workload("review", { remote: "git@gitea.test:Jens/Renamed.git" })], [], [decision])[0];
|
||||
assert.equal(unchanged.reviewDecision.action, "ignore");
|
||||
assert.equal(changed.reviewDecision, null);
|
||||
assert.equal(changed.reviewDecisionStale, true);
|
||||
});
|
||||
|
||||
test("review decisions drive classification and explicit authority", () => {
|
||||
const primary = classifyInventory([workload("primary")])[0];
|
||||
const secondary = classifyInventory([workload("secondary")])[0];
|
||||
const decisions = [
|
||||
{ workloadId: primary.workloadId, evidenceHash: primary.evidenceHash, action: "mark-historical", reason: "Retained rollback definition" },
|
||||
{ workloadId: secondary.workloadId, evidenceHash: secondary.evidenceHash, action: "select-authoritative", reason: "Verified production instance" },
|
||||
];
|
||||
const result = classifyInventory([workload("primary"), workload("secondary")], [], decisions);
|
||||
assert.equal(result.find((item) => item.workloadId === "primary").classification.type, "duplicate");
|
||||
assert.equal(result.find((item) => item.workloadId === "secondary").authoritative, true);
|
||||
});
|
||||
|
||||
test("ignore and monitor-only decisions stay evidence-bound", () => {
|
||||
const ignored = classifyInventory([workload("ignored")])[0];
|
||||
const monitoredSource = workload("monitored", { remote: "git@gitea.test:Jens/Monitored.git", candidates: [{ repositoryFullName: "Jens/Monitored", score: 100, exact: true }] });
|
||||
const monitored = classifyInventory([monitoredSource])[0];
|
||||
const result = classifyInventory([workload("ignored"), monitoredSource], [], [
|
||||
{ workloadId: ignored.workloadId, evidenceHash: ignored.evidenceHash, action: "ignore", reason: "Managed by another platform" },
|
||||
{ workloadId: monitored.workloadId, evidenceHash: monitored.evidenceHash, action: "monitor-only", reason: "Visibility without deployment ownership" },
|
||||
]);
|
||||
assert.equal(result.find((item) => item.workloadId === "ignored").classification.type, "manually-excluded");
|
||||
assert.equal(result.find((item) => item.workloadId === "monitored").classification.type, "monitor-only");
|
||||
});
|
||||
|
||||
test("review service requires reason, exact plan and recovery snapshot", async () => {
|
||||
const decisions = [];
|
||||
const store = { getInventoryReviewDecisions: () => decisions, createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }), saveInventoryReviewDecision: async (_server, decision) => { decisions.push(decision); return decision; } };
|
||||
const service = new InventoryReviewService({ store });
|
||||
const item = classifyInventory([workload("review")])[0];
|
||||
assert.throws(() => service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "no" }), (error) => error.code === "INVENTORY_REVIEW_REASON_REQUIRED");
|
||||
const plan = service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "Managed outside ForgeFlow" });
|
||||
await assert.rejects(service.apply({ plan }), (error) => error.code === "INVENTORY_REVIEW_PLAN_REQUIRED");
|
||||
const result = await service.apply({ plan, expectedPlanId: plan.id });
|
||||
assert.equal(result.snapshot.filePath, "snapshot.json");
|
||||
assert.equal(decisions[0].evidenceHash, item.evidenceHash);
|
||||
});
|
||||
|
||||
test("large inventory classification is deterministic and bounded", () => {
|
||||
const input = Array.from({ length: 1200 }, (_, index) => workload(`app-${index}`, { remote: `git@gitea.test:Jens/App-${index}.git`, candidates: [{ repositoryFullName: `Jens/App-${index}`, score: 100, exact: true }] }));
|
||||
const started = Date.now();
|
||||
const result = classifyInventory(input);
|
||||
assert.equal(result.length, 1200);
|
||||
assert.ok(Date.now() - started < 2000);
|
||||
assert.equal(new Set(result.map((item) => item.evidenceHash)).size, 1200);
|
||||
});
|
||||
|
||||
test("evidence hash changes for runtime, Compose and candidate changes", () => {
|
||||
const identity = deploymentIdentity({ workload: workload("hash") });
|
||||
const one = deploymentEvidenceHash(identity, { running: true, files: ["compose.yml"] });
|
||||
const two = deploymentEvidenceHash(identity, { running: false, files: ["compose.yml"] });
|
||||
assert.notEqual(one, two);
|
||||
});
|
||||
@@ -1064,6 +1064,22 @@ test("linked Compose deployments retain the existing project, files and service
|
||||
assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]);
|
||||
});
|
||||
|
||||
test("inventory scan uses only configured roots and reports partial find failures", () => {
|
||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
|
||||
const script = service.inventoryScript({
|
||||
basePath: "/mnt/user/appdata",
|
||||
scanRoots: ["/mnt/user/appdata", "/mnt/cache/custom apps"],
|
||||
scanExcludes: ["archive-*", "scratch"],
|
||||
});
|
||||
assert.match(script, /add_scan_root '\/mnt\/user\/appdata'/);
|
||||
assert.match(script, /add_scan_root '\/mnt\/cache\/custom apps'/);
|
||||
assert.match(script, /-name 'archive-\*'/);
|
||||
assert.match(script, /-name 'scratch'/);
|
||||
assert.match(script, /Inventory scan partially failed/);
|
||||
assert.match(script, /2>"\$scan_error" \|\| true/);
|
||||
assert.doesNotMatch(script, /add_scan_root \/mnt\/cache\/appdata/);
|
||||
});
|
||||
|
||||
test("push bundle activation validates Compose and services before promoting current SHA", () => {
|
||||
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
|
||||
const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" };
|
||||
|
||||
Reference in New Issue
Block a user