feat: add transactional deploy key lifecycle
This commit is contained in:
+65
-1
@@ -157,6 +157,7 @@ const ui = {
|
||||
systemPreflight: null,
|
||||
deploymentPreflight: null,
|
||||
serverGitVerifications: {},
|
||||
deployKeyLifecycle: null,
|
||||
diagnosticsStatus: null,
|
||||
troubleshooter: null,
|
||||
deploymentDiscovery: null,
|
||||
@@ -1019,7 +1020,7 @@ function renderProfileCard(repository, profile, compact = false) {
|
||||
? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD"
|
||||
: state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare";
|
||||
const serverAccessAction = isSsh && mode === "server-git"
|
||||
? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
|
||||
? `<button class="button" data-action="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</button><button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
|
||||
: "";
|
||||
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Deploy source</span><strong>${escapeHtml(sourceLabel)}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(shortSha(targetSha))}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
|
||||
}
|
||||
@@ -1466,6 +1467,13 @@ function renderModal() {
|
||||
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
|
||||
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "Server pull fetches the exact selected Gitea commit with a repository-scoped read-only key, validates Compose and services, then promotes atomically with rollback protection." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deploy-key-lifecycle") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const inventory = lifecycle?.inventory;
|
||||
const rotation = lifecycle?.rotation;
|
||||
const revocation = lifecycle?.revocation;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="deploy-key-title"><header class="modal-header"><h2 id="deploy-key-title">Deploy key lifecycle</h2><button class="icon-button" data-action="close-modal" aria-label="Close deploy key lifecycle">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${inventory?.ready ? "" : "danger"}">${icon(inventory?.ready ? "shield" : "warning")}<div><strong>${inventory?.ready ? "Repository access is verified" : "Deploy key requires review"}</strong><span>${escapeHtml(inventory?.repository || "")} · ${escapeHtml(inventory?.server?.name || "server")}</span></div></div><div class="confirm-grid"><span>Key ID</span><strong>${escapeHtml(inventory?.configuredKey?.id || "Missing")}</strong><span>Fingerprint</span><strong class="mono">${escapeHtml(inventory?.serverKey?.fingerprint || "Unavailable")}</strong><span>Rights</span><strong>${inventory?.configuredKey?.readOnly ? "Repository-scoped · read-only" : "Unverified or writable"}</strong><span>Stale</span><strong>${inventory?.stale ? "Yes · blocked" : "No"}</strong><span>Shared references</span><strong>${inventory?.shared?.length || 0}</strong><span>Orphaned Gitea keys</span><strong>${inventory?.orphaned?.length || 0}</strong></div><section class="settings-group" style="margin-top:16px"><h3>Rotation impact</h3><ul>${(rotation?.impact || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p>${escapeHtml(rotation?.recovery || "")}</p><small class="mono">Plan ${escapeHtml(rotation?.id || "unavailable")}</small></section><section class="settings-group"><h3>Revocation impact</h3><ul>${(revocation?.impact || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p>Containers remain untouched. Server pull changes to monitoring-only until restored.</p><small class="mono">Plan ${escapeHtml(revocation?.id || "unavailable")}</small></section></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button" data-action="restore-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}">Restore</button><button class="button danger" data-action="confirm-revoke-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}" ${revocation?.id ? "" : "disabled"}>Revoke key</button><button class="button primary" data-action="confirm-rotate-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}" ${rotation?.id ? "" : "disabled"}>Rotate safely</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deployment-preflight") {
|
||||
const profile =
|
||||
repository?.deploymentProfiles?.find(
|
||||
@@ -2785,6 +2793,62 @@ app.addEventListener("click", async (event) => {
|
||||
} else if (action === "run-deployment-preflight") {
|
||||
if (!repository) repository = profileRepository(target.dataset.profileId);
|
||||
await runDeploymentPreflight(repository, target.dataset.profileId);
|
||||
} else if (action === "manage-deploy-key") {
|
||||
const profileId = target.dataset.profileId || ui.selectedProfileId;
|
||||
if (!repository) repository = profileRepository(profileId);
|
||||
if (!repository || !profileId) return;
|
||||
setLoading(true, "Inspecting deploy-key lifecycle without changing access…");
|
||||
try {
|
||||
const [inventory, rotation, revocation] = await Promise.all([
|
||||
window.forgeflow.deployKeyInventory(repository, profileId),
|
||||
window.forgeflow.planDeployKeyRotation(repository, profileId),
|
||||
window.forgeflow.planDeployKeyRevocation(repository, profileId),
|
||||
]);
|
||||
ui.deployKeyLifecycle = { repositoryId: repository.id, profileId, inventory, rotation, revocation };
|
||||
ui.modal = { type: "deploy-key-lifecycle" };
|
||||
render();
|
||||
} catch (error) {
|
||||
showToast("Could not inspect deploy key", error.message, "error");
|
||||
} finally { setLoading(false); }
|
||||
} else if (action === "confirm-rotate-deploy-key") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
|
||||
if (!targetRepository || !lifecycle?.rotation?.id) return;
|
||||
setLoading(true, "Rotating and verifying the repository deploy key…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyDeployKeyRotation(targetRepository, lifecycle.profileId, lifecycle.rotation.id);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null; ui.deployKeyLifecycle = null;
|
||||
await refreshRepositories(false, true);
|
||||
showToast("Deploy key rotated", `New fingerprint ${result.profile?.serverGitAccess?.keyFingerprint || "verified"}.`, "success");
|
||||
} catch (error) { showToast("Deploy-key rotation failed safely", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "confirm-revoke-deploy-key") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
|
||||
if (!targetRepository || !lifecycle?.revocation?.id) return;
|
||||
setLoading(true, "Revoking repository access while preserving recovery…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyDeployKeyRevocation(targetRepository, lifecycle.profileId, lifecycle.revocation.id);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null; ui.deployKeyLifecycle = null;
|
||||
await refreshRepositories(false, true);
|
||||
showToast("Deploy key revoked", "Server pull is disabled; containers were not changed and recovery is available.", "success");
|
||||
} catch (error) { showToast("Deploy-key revocation failed", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "restore-deploy-key") {
|
||||
const lifecycle = ui.deployKeyLifecycle;
|
||||
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
|
||||
if (!targetRepository || !lifecycle?.profileId) return;
|
||||
setLoading(true, "Restoring and verifying repository access…");
|
||||
try {
|
||||
const result = await window.forgeflow.restoreDeployKey(targetRepository, lifecycle.profileId);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null; ui.deployKeyLifecycle = null;
|
||||
await refreshRepositories(false, true);
|
||||
showToast("Deploy key restored", "Read-only server pull access is verified again.", "success");
|
||||
} catch (error) { showToast("Deploy-key recovery failed", error.message, "error"); }
|
||||
finally { setLoading(false); }
|
||||
} else if (action === "verify-server-git-access") {
|
||||
const profileId = target.dataset.profileId || ui.selectedProfileId;
|
||||
if (!repository) repository = profileRepository(profileId);
|
||||
|
||||
@@ -1606,6 +1606,31 @@
|
||||
],
|
||||
};
|
||||
},
|
||||
async deployKeyInventory(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
||||
const profile = repo?.deploymentProfiles.find((item) => item.id === profileId);
|
||||
return { repository: repo.fullName, profileId, server: { id: profile.serverId, name: "Unraid" }, configuredKey: { id: profile.serverGitAccess?.deployKeyId || 17, readOnly: true }, serverKey: { privateKeyPresent: true, fingerprint: profile.serverGitAccess?.keyFingerprint || "SHA256:demo" }, stale: false, orphaned: [], shared: [], conflicts: [], ready: true, checkedAt: iso() };
|
||||
},
|
||||
async planDeployKeyRotation(repository, profileId) {
|
||||
const evidence = await this.deployKeyInventory(repository, profileId);
|
||||
return { id: `rotation-${profileId}`, operation: "rotate-deploy-key", impact: ["Generate a new server-side key", "Verify read-only access", "Switch atomically", "Revoke the previous key"], recovery: "Previous access remains recoverable until verification succeeds.", evidence };
|
||||
},
|
||||
async applyDeployKeyRotation(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
|
||||
profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 18, keyFingerprint: "SHA256:rotated", rotatedAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state) };
|
||||
},
|
||||
async planDeployKeyRevocation(repository, profileId) {
|
||||
const evidence = await this.deployKeyInventory(repository, profileId);
|
||||
return { id: `revocation-${profileId}`, operation: "revoke-deploy-key", impact: ["Remove the repository key", "Disable server pull", "Preserve recovery material"], containersUnaffected: true, evidence };
|
||||
},
|
||||
async applyDeployKeyRevocation(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
|
||||
profile.deploymentMode = "monitor-only"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: false, revokedAt: iso(), recoveryAvailable: true }; syncState(); return { profile: clone(profile), state: clone(state) };
|
||||
},
|
||||
async restoreDeployKey(repository, profileId) {
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
|
||||
profile.deploymentMode = "server-git"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 19, keyFingerprint: "SHA256:restored", restoredAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state), proof: { ready: true } };
|
||||
},
|
||||
async refreshOperations(operationId = null) {
|
||||
await wait(300);
|
||||
if (operationId) {
|
||||
|
||||
Reference in New Issue
Block a user