mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
feat: pre-deploy scan visibility and pinned scanner version (#1378)
* feat: pre-deploy scan visibility and pinned scanner version Pin managed Trivy installs and add an opt-in pre-deploy scan advisory so a manual deploy can surface each image's latest scan before it runs. - Managed Trivy now installs a pinned, known-good version by default for reproducible installs. Auto-update still tracks the latest release, and an explicit update always pulls the latest. - Add an opt-in pre-deploy scan advisory: when enabled, deploying a stack from the editor first shows each image's latest cached scan severity for review. It is visibility only and never blocks; deploy enforcement is unchanged. - Backend: pre_deploy_scan_advisory setting, PUT /security/pre-deploy-scan-advisory, a cache-only GET /security/stacks/:name/pre-deploy-summary, and a node-scoped getLatestVulnScanByDigestForNode lookup. - Frontend: advisory toggle on the Security page scanner setup, and a PreDeployScanDialog wired into the editor deploy flow that fails open when the summary is unavailable. - Docs: scanner configuration, version pinning, and the advisory. * fix: harden pre-deploy advisory guard, toggle visibility, and installer busy state Addresses review findings on the pre-deploy advisory. - Block a second editor deploy during the async advisory window with a synchronous pending ref, cleared on cancel and in the deploy's finally, so a double-click can no longer start two deploys. - Keep the pre-deploy advisory toggle visible to admins whenever the setting is on, so it can still be turned off after the scanner becomes unavailable. - Resolve the managed Trivy version inside the install lock so the busy state and serialization cover the latest-version fetch and the managed-install check.
This commit is contained in:
@@ -11,6 +11,7 @@ import type { RunWithLogParams } from '@/context/DeployFeedbackContext';
|
||||
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
|
||||
import type { NotificationItem } from '../../dashboard/types';
|
||||
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
|
||||
import type { PreDeployScanImage } from '@/types/security';
|
||||
|
||||
interface RunResult {
|
||||
ok: boolean;
|
||||
@@ -118,6 +119,38 @@ interface UseStackActionsOptions {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const PRE_DEPLOY_SUMMARY_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Fetch the pre-deploy scan advisory for a manual deploy. Returns the image
|
||||
* list when the advisory is enabled and the backend answers in time, or null to
|
||||
* mean "no advisory, deploy normally" for every other case (setting off,
|
||||
* timeout, an older node without the route, or any error). Failing open is
|
||||
* deliberate: the advisory is visibility, it must never block a deploy. Bound to
|
||||
* the captured node so it targets the same node the deploy will hit.
|
||||
*/
|
||||
export async function fetchPreDeployAdvisory(
|
||||
stackName: string,
|
||||
opNodeId: number | null,
|
||||
): Promise<PreDeployScanImage[] | null> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), PRE_DEPLOY_SUMMARY_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/security/stacks/${encodeURIComponent(stackName)}/pre-deploy-summary`,
|
||||
{ nodeId: opNodeId, signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data: unknown = await res.json();
|
||||
if (!isRecord(data) || data.enabled !== true || !Array.isArray(data.images)) return null;
|
||||
return data.images as PreDeployScanImage[];
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const parseStackOpInProgress = (rawBody: string): StackOpInProgressInfo | null => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(rawBody);
|
||||
@@ -202,6 +235,9 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
const pendingStackLoadRef = useRef<string | null>(null);
|
||||
const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null);
|
||||
const checkUpdatesIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// True from a deploy click through the async pre-deploy advisory phase until
|
||||
// the deploy starts or is cancelled, so a double-click cannot start two deploys.
|
||||
const deployPendingRef = useRef(false);
|
||||
// Aborts the most recent loadFile sequence (compose GET, envs GET, env content
|
||||
// GET, containers GET, backup GET). A node switch, an unmount, or a second
|
||||
// loadFile call before the first finishes all cancel the in-flight fetches so
|
||||
@@ -723,22 +759,63 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
const deployStack = async (e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
if (!stackListState.selectedFile || stackListState.isStackBusy(stackListState.selectedFile))
|
||||
// deployPendingRef blocks a second deploy from the moment of the click
|
||||
// through the async advisory phase, before setStackAction marks the stack
|
||||
// busy. Without it, a double-click during the advisory fetch window could
|
||||
// launch two deploys. Cleared on cancel and in the deploy's finally.
|
||||
if (
|
||||
!stackListState.selectedFile ||
|
||||
stackListState.isStackBusy(stackListState.selectedFile) ||
|
||||
deployPendingRef.current
|
||||
)
|
||||
return;
|
||||
const stackFile = stackListState.selectedFile;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
stackListState.setStackAction(stackFile, 'deploy');
|
||||
// Snapshot the node once so the request stays bound to it even if the active
|
||||
// node changes while the operation is in flight.
|
||||
// Snapshot the node once so the advisory fetch and the deploy stay bound to
|
||||
// it even if the active node changes while the advisory dialog is open.
|
||||
const opNodeId = activeNode?.id ?? null;
|
||||
try {
|
||||
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) =>
|
||||
runDeploy(stackName, stackFile, false, started, ds, opNodeId),
|
||||
);
|
||||
} finally {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
stackListState.refreshStacks(true);
|
||||
deployPendingRef.current = true;
|
||||
|
||||
// The actual deploy, pulled out so the optional pre-deploy advisory can gate
|
||||
// it without duplicating the action lifecycle. The stack action is set here
|
||||
// (not before the advisory) so cancelling the advisory leaves no stuck state.
|
||||
const runDeployFlow = async () => {
|
||||
stackListState.setStackAction(stackFile, 'deploy');
|
||||
try {
|
||||
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) =>
|
||||
runDeploy(stackName, stackFile, false, started, ds, opNodeId),
|
||||
);
|
||||
} finally {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
stackListState.refreshStacks(true);
|
||||
deployPendingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Advisory runs before the deploy log opens (fails open: a null result means
|
||||
// setting off / timeout / older node / error, so the deploy proceeds).
|
||||
const advisoryImages = await fetchPreDeployAdvisory(stackName, opNodeId);
|
||||
if (advisoryImages && advisoryImages.length > 0) {
|
||||
let settled = false;
|
||||
overlayState.setPreDeployAdvisory({
|
||||
stackName,
|
||||
images: advisoryImages,
|
||||
proceed: () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
overlayState.setPreDeployAdvisory(null);
|
||||
void runDeployFlow();
|
||||
},
|
||||
cancel: () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
overlayState.setPreDeployAdvisory(null);
|
||||
deployPendingRef.current = false;
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runDeployFlow();
|
||||
};
|
||||
|
||||
const handleSaveAndDeploy = async (e: React.MouseEvent) => {
|
||||
|
||||
Reference in New Issue
Block a user