Files
ForgeFlow/src/renderer/actions/deployment-profile.js
T

402 lines
17 KiB
JavaScript

async function handleDeploymentProfileActions(event, target, action, repository) {
if (action === "configure-deployment") {
ui.deploymentDiscovery = null;
ui.modal = {
type: "deployment-config",
profileId: null,
provider: (ui.boot.state.servers || []).length
? "ssh-unraid"
: "gitea-actions",
};
render();
} else if (action === "edit-deployment-profile") {
ui.deploymentDiscovery = null;
repository = profileRepository(target.dataset.profileId) || repository;
if (repository && String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
ui.modal = {
type: "deployment-config",
profileId: target.dataset.profileId || null,
provider: repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
)?.provider,
};
render();
} else if (action === "close-modal") {
ui.modal = null;
render();
} else if (action === "select-profile-icon") {
const iconPath = await window.forgeflow.selectImageFile({
title: "Select DockerMan PNG icon",
defaultPath:
document.querySelector("#profile-icon-file")?.value || undefined,
});
if (iconPath) {
document.querySelector("#profile-icon-file").value = iconPath;
const mode = document.querySelector("#profile-icon-mode");
if (mode) mode.value = "upload";
}
} else if (action === "clear-profile-icon") {
const input = document.querySelector("#profile-icon-file");
if (input) input.value = "";
const mode = document.querySelector("#profile-icon-mode");
if (mode) mode.value = "builtin";
} else if (action === "discover-existing-deployment") {
const serverId = document.querySelector("#profile-server")?.value;
const remoteFolder =
document.querySelector("#profile-remote-folder")?.value.trim() ||
safeCloneFolderName(repository);
if (!serverId) {
showToast(
"Select an Unraid server",
"Configure and select the server before importing an existing deployment.",
"error",
);
return;
}
setLoading(
true,
"Reading Git, Compose, Docker and DockerMan from the server…",
);
try {
const result = await window.forgeflow.discoverExistingDeployment(
repository,
serverId,
remoteFolder,
);
ui.deploymentDiscovery = { ...result, repository: repository.fullName };
showToast(
"Existing deployment imported",
`${result.runtime.containers} container(s), ${result.runtime.services} service(s) and ${result.runtime.ports.length} port mapping(s) detected.`,
"success",
);
render();
} catch (error) {
showToast("Could not import deployment", error.message, "error");
}
setLoading(false);
} else if (action === "save-deployment-profile") {
const previousProfile =
repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
) || {};
const provider = document.querySelector("#profile-provider").value;
let maintenanceWindows = [];
try {
maintenanceWindows = (
document.querySelector("#profile-policy-windows")?.value || ""
)
.split("|")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const match = item.match(
/^([0-6](?:,[0-6])*)\s*:\s*((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d)$/,
);
if (!match) throw new Error(`Invalid maintenance window: ${item}`);
return {
days: match[1].split(",").map(Number),
start: match[2],
end: match[3],
};
});
} catch (error) {
showToast("Could not save profile", error.message, "error");
return;
}
const composeFiles =
provider === "ssh-unraid"
? (document.querySelector("#profile-compose-files")?.value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const composeServices =
provider === "ssh-unraid"
? (document.querySelector("#profile-compose-services")?.value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const profile = {
id: target.dataset.profileId || undefined,
provider,
name: document.querySelector("#profile-name").value.trim(),
environment: document.querySelector("#profile-environment").value.trim(),
branch: document.querySelector("#profile-branch").value.trim(),
healthcheckUrl:
document.querySelector("#profile-healthcheck")?.value.trim() || "",
confirmationRequired: document.querySelector("#profile-confirmation")
.checked,
deploymentPolicy: {
frozen:
document.querySelector("#profile-policy-frozen")?.checked === true,
freezeReason:
document
.querySelector("#profile-policy-freeze-reason")
?.value.trim() || "",
requireNote:
document.querySelector("#profile-policy-note")?.checked === true,
maintenanceWindows,
},
...(provider === "ssh-unraid"
? {
serverId: document.querySelector("#profile-server").value,
remoteFolder: document
.querySelector("#profile-remote-folder")
.value.trim(),
deploymentMode: ["server-git", "push-bundle", "monitor-only"].includes(
document.querySelector("#profile-deployment-mode")?.value,
) ? document.querySelector("#profile-deployment-mode").value : "server-git",
cloneUrl: previousProfile.cloneUrl || "",
alignRemote: false,
generatedCompose:
document.querySelector("#profile-generated-compose").value ===
"true",
composeProject:
document.querySelector("#profile-compose-project")?.value.trim() ||
previousProfile.composeProject ||
"",
composeWorkingDir: previousProfile.composeWorkingDir || "",
composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"],
composeFile: composeFiles[0] || "docker-compose.yml",
composeServices: composeServices.length
? composeServices
: [safeCloneFolderName(repository).toLowerCase()],
composeService:
composeServices[0] || safeCloneFolderName(repository).toLowerCase(),
containerName: document
.querySelector("#profile-container-name")
.value.trim(),
hostPort:
Number(document.querySelector("#profile-host-port").value) ||
null,
containerPort:
Number(document.querySelector("#profile-container-port").value) ||
null,
webUiUrl: document.querySelector("#profile-web-ui").value.trim(),
iconMode: document.querySelector("#profile-icon-mode").value,
iconUrl: document.querySelector("#profile-icon-url").value.trim(),
iconFilePath: document
.querySelector("#profile-icon-file")
.value.trim(),
dockerShell: document.querySelector("#profile-docker-shell").value,
preservePaths: document
.querySelector("#profile-preserve-paths")
.value.split(",")
.map((item) => item.trim())
.filter(Boolean),
manageDockerMan:
document.querySelector("#profile-manage-dockerman")?.checked ===
true,
forceRecreate: false,
removeOrphans: false,
adoptedFromServer: Boolean(
ui.deploymentDiscovery || previousProfile.adoptedFromServer,
),
serverSourceOfTruth: Boolean(
ui.deploymentDiscovery || previousProfile.serverSourceOfTruth,
),
workloadIdentity:
ui.deploymentDiscovery?.profile?.workloadIdentity ||
previousProfile.workloadIdentity ||
null,
detectedAt:
ui.deploymentDiscovery?.profile?.detectedAt ||
previousProfile.detectedAt ||
null,
provenance:
ui.deploymentDiscovery?.provenance ||
previousProfile.provenance ||
{},
detectedMetadata:
ui.deploymentDiscovery?.profile?.detectedMetadata ||
previousProfile.detectedMetadata ||
{},
}
: {
workflowFile: document
.querySelector("#profile-workflow")
.value.trim(),
rollbackWorkflowFile: document
.querySelector("#profile-rollback-workflow")
.value.trim(),
statusUrl: document
.querySelector("#profile-status-url")
.value.trim(),
}),
};
setLoading(true, "Saving deployment environment…");
try {
const result = await window.forgeflow.saveDeploymentProfile(
repository.fullName,
profile,
);
ui.boot.state = result.state;
ui.modal = null;
ui.deploymentDiscovery = null;
await refreshRepositories(false);
ui.selectedProfileId = result.profile.id;
showToast(
"Deployment configured",
`${profile.name} targets ${profile.environment}.`,
"success",
);
} catch (error) {
showToast("Could not save profile", error.message, "error");
}
setLoading(false);
} else if (action === "delete-deployment-profile") {
if (
!confirm("Delete this deployment profile? Operation history is retained.")
)
return;
setLoading(true, "Deleting deployment profile…");
try {
const result = await window.forgeflow.deleteDeploymentProfile(
repository.fullName,
target.dataset.profileId,
);
ui.boot.state = result.state;
ui.modal = null;
await refreshRepositories(false);
showToast(
"Profile deleted",
"Deployment environment removed.",
"success",
);
} catch (error) {
showToast("Could not delete profile", error.message, "error");
}
setLoading(false);
} 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);
if (!repository || !profileId) return;
setLoading(true, "Verifying Gitea, deploy key, server commit and runtime…");
try {
const result = await window.forgeflow.verifyServerGitProfile(repository, profileId);
ui.serverGitVerifications[profileId] = result;
render();
const failures = result.checks.filter((check) => check.status === "fail");
showToast(result.readiness, failures[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`, result.ready ? "success" : "warning");
} catch (error) {
showToast("Server-pull verification failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "configure-server-git-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
const approved = confirm(
`Configure read-only Gitea access for ${repository.fullName}?\n\nForgeFlow creates a dedicated SSH deploy key on the selected server, adds only its public key to this Gitea repository and pins the observed Gitea SSH host key. The private key never leaves the server.`,
);
if (!approved) return;
setLoading(true, "Configuring repository-scoped Gitea access…");
try {
const result = await window.forgeflow.configureServerGitAccess(repository, profileId);
if (result.state) ui.boot.state = result.state;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Server pull ready",
`Read-only Gitea access verified at ${shortSha(result.remoteSha)}.`,
"success",
);
await runDeploymentPreflight(repository, profileId, { showModal: true });
} catch (error) {
showToast("Could not configure Gitea access", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "repair-deployment-write-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
const profile = repository.deploymentProfiles?.find((item) => item.id === profileId);
const approved = confirm(
`Repair write access for ${repository.fullName} on ${profile?.name || profile?.environment || "the linked Unraid deployment"}?\n\nForgeFlow will only adjust the linked project source tree and its .forgeflow state folders. Preserved runtime paths such as appdata, data, config and logs are excluded. No container will be stopped, removed or recreated.`,
);
if (!approved) return;
setLoading(true, "Repairing scoped Unraid write access…");
try {
const result = await window.forgeflow.repairDeploymentWriteAccess(
repository,
profileId,
);
showToast(
"Write access normalized",
"Project source and ForgeFlow upload folders now use safe shared write permissions. Preserved runtime data was not changed.",
"success",
);
await runDeploymentPreflight(repository, profileId, { showModal: true });
} catch (error) {
showToast("Write-access repair failed", error.message, "error");
} finally {
setLoading(false);
}
}
else return false;
return true;
}