diff --git a/DESIGN.md b/DESIGN.md index fa10c50db..9a656f28f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,7 +12,7 @@ This file collects architecture and design notes for studio that aren't otherwis - [`src/features/instance/databases/DESIGN.md`](src/features/instance/databases/DESIGN.md) — when the browse grid may show its spinner, and why a table without a primary key can't be listed. -- [`src/features/instance/applications/DESIGN.md`](src/features/instance/applications/DESIGN.md) — when a rolling deploy is done: following its restart job, and why a job Studio can't follow is never a success. +- [`src/features/instance/applications/DESIGN.md`](src/features/instance/applications/DESIGN.md) — when a rolling deploy is done: following its restart job, and why a job Studio can't follow is never a success; and when a live deploy may be sent again. - [`src/features/instance/config/roles/DESIGN.md`](src/features/instance/config/roles/DESIGN.md) — why the role editor's JSON text is the only source of truth, what forces the JSON view, and what Save changes on purpose. - [`src/router/DESIGN.md`](src/router/DESIGN.md) — why a failed route load is reported and shown only by its route, and why a preload still toasts. diff --git a/src/features/instance/applications/DESIGN.md b/src/features/instance/applications/DESIGN.md index 95b77130f..e58317fc0 100644 --- a/src/features/instance/applications/DESIGN.md +++ b/src/features/instance/applications/DESIGN.md @@ -31,3 +31,27 @@ node the job could not reach is listed with its error and the job still complete Canary certification is shown only when the response carries it, which only 5.4+ does. There is no version check on the deploy path: `registration_info` is denied to a role with an operations allowlist, which is exactly the kind of role that may be allowed to deploy, so a version gate there would hide the result from it. + +## Staging and activation are gated the other way + +Stage only (`activate: false`) and Activate (`deployment_id`) are offered only when `registration_info` reads 5.3.0 or +later (`useStagedDeploymentsAvailable`), and an unreadable version offers neither. The difference from certification is +what happens when the guess is wrong: a node older than 5.3.0 accepts `activate: false` as an unknown field and makes +the release live, which is the opposite of what the person asked for. Certification only adds a field to a response. + +All three kinds of request are built in one place, [`deployRequestBody`](../../../integrations/api/instance/applications/deployComponent.ts), +because Harper's validator refuses the wrong mix outright: a stage with `restart`, or an activation carrying `package`, +install options or `activate`. A stage succeeds only once every other node confirmed staging it, so a stage restarts +nothing and is reported without a health check. Rollback to a kept release (#1232) is the same activation call. + +## A live deploy is sent once + +A live (SSE) deploy that gets no event stream goes out again as the buffered POST only when its status proves Harper +never started it: a 4xx. Harper refuses a request before the operation starts (authentication, permissions, +validation) with a 4xx, and answers one it started with a 200 event stream at once. Everything else can follow a deploy +that is already running: a gateway's 5xx after forwarding, a 2xx that is not a stream, a dropped connection, the idle +watchdog firing before headers. Harper's own request-queue 503 never started anything either, but status alone cannot +tell it from a gateway's. So [`onDeployComponentSubmit`](../../../integrations/api/instance/applications/deployComponent.ts) +turns those into `SSEInconclusiveError`, which every deploy caller already answers by checking rather than resending. +`streamOperation` stays neutral: it puts the `status` on `SSEUnsupportedError` and leaves the decision to the caller, +because a read (`get_deployment`) may fall back on anything. diff --git a/src/features/instance/applications/components/ContentActions.tsx b/src/features/instance/applications/components/ContentActions.tsx index 6638d13dd..39fd53629 100644 --- a/src/features/instance/applications/components/ContentActions.tsx +++ b/src/features/instance/applications/components/ContentActions.tsx @@ -16,10 +16,16 @@ import { useEditorFileContent } from '@/features/instance/applications/context/e import { isDirectory } from '@/features/instance/applications/context/isDirectory'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; import { useEntryActions } from '@/features/instance/applications/hooks/useEntryActions'; -import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions'; +import { + useDeploymentsAvailable, + useStagedDeploymentsAvailable, +} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; +import { useInstanceBrowseManagePermission, useInstanceManagePermission } from '@/hooks/usePermissions'; import { useRestartInstanceClick } from '@/hooks/useRestartInstanceClick'; import { emitToListeners, useEmitToListeners } from '@/lib/events/listener'; import { useSetWatchedValue, useWatchedValue } from '@/lib/events/watcher'; +import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage'; +import { useNavigate, useParams } from '@tanstack/react-router'; import { ArrowLeftIcon, ArrowRightIcon, @@ -28,12 +34,14 @@ import { DownloadIcon, FilePlusIcon, FolderPlusIcon, + HistoryIcon, LockIcon, PackageIcon, PanelRightCloseIcon, PanelRightOpenIcon, PencilIcon, PlusIcon, + RocketIcon, RotateCcwIcon, SaveIcon, TrashIcon, @@ -73,6 +81,14 @@ export function ContentActions({ const { openedEntryContents, openedEntry, isSavingFile, restrictPackageModification } = useEditorView(); const { content: updatedFileContent } = useEditorFileContent(openedEntry?.path); const canManageBrowseInstance = useInstanceBrowseManagePermission(); + const canManageInstance = useInstanceManagePermission(); + const deploymentsAvailable = useDeploymentsAvailable(); + const stagingAvailable = useStagedDeploymentsAvailable(); + const canViewDeployments = canManageInstance && deploymentsAvailable; + const navigate = useNavigate(); + const routeParams: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({ + strict: false, + }); const { onRestartClick, isRestartPending } = useRestartInstanceClick({ operation: 'restart_service', instanceClient: instanceParams.instanceClient, @@ -133,7 +149,8 @@ export function ContentActions({ useEntryActions(openedEntry); const showFileMenu = canEditFile || canRename || canAddEntries || canAddTable || canDeleteEntry; - const showApplicationMenu = canDownload || canManageBrowseInstance || canRedeploy; + const deploymentsProject = canViewDeployments && openedEntry?.project ? openedEntry.project : undefined; + const showApplicationMenu = canDownload || canManageBrowseInstance || canRedeploy || !!deploymentsProject; // The "File" menu acts on whatever is selected, so name it for that thing. const fileMenuLabel = openedEntry && isDirectory(openedEntry) ? 'Directory' : 'File'; @@ -389,6 +406,30 @@ export function ContentActions({ Redeploy Package )} + {deploymentsProject && ( + + void navigate({ + to: buildAbsoluteLinkToPage(routeParams, 'config/deployments'), + search: { project: deploymentsProject }, + })} + > + + View Deployments + + )} + {deploymentsProject && stagingAvailable && ( + + void navigate({ + to: buildAbsoluteLinkToPage(routeParams, 'config/deployments'), + search: { project: deploymentsProject, status: 'staged' }, + })} + > + + Staged Releases + + )} )} diff --git a/src/features/instance/applications/components/DeployProgress/DeployProgress.test.tsx b/src/features/instance/applications/components/DeployProgress/DeployProgress.test.tsx index 70f582348..fb867318e 100644 --- a/src/features/instance/applications/components/DeployProgress/DeployProgress.test.tsx +++ b/src/features/instance/applications/components/DeployProgress/DeployProgress.test.tsx @@ -119,6 +119,20 @@ describe('DeployProgress canary certification (Harper 5.4+)', () => { }); }); +describe('DeployProgress staging (Harper 5.3+)', () => { + it('ends a stage at Staged, with no restart', () => { + render( + , + ); + expect(screen.getByText('Staged')).toBeTruthy(); + expect(screen.queryByText('Restart')).toBeNull(); + expect(screen.queryByText('Finish')).toBeNull(); + }); +}); + describe('DeployProgress warnings and rollout', () => { it('keeps the warnings the deploy streamed', () => { render(); diff --git a/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx b/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx index 40c517ccc..bec4f3674 100644 --- a/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx +++ b/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx @@ -2,7 +2,7 @@ import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scrollArea'; import { Separator } from '@/components/ui/separator'; import { deploymentRecordPath } from '@/features/instance/applications/hooks/useDeploymentRecordAction'; -import { DEPLOY_PHASE_ORDER } from '@/integrations/api/instance/applications/deployComponentStream'; +import { DEPLOY_PHASE_ORDER, STAGE_PHASE_ORDER } from '@/integrations/api/instance/applications/deployComponentStream'; import { CertificationDecision } from '@/integrations/api/instance/applications/deployOutcome'; import { RolloutNode, RolloutSnapshot } from '@/integrations/api/instance/applications/restartJob'; import { cn } from '@/lib/cn'; @@ -33,6 +33,7 @@ const PHASE_LABELS: Record = { replicate: 'Replicate', restart: 'Restart', success: 'Finish', + staged: 'Staged', }; /** @@ -62,10 +63,12 @@ function PhaseIcon({ status, isCurrent }: { status?: PhaseStatus; isCurrent: boo export function DeployProgress({ state, onNavigateAway, + staging = false, }: { state: DeploymentStreamState; /** Called when a guidance link navigates away — lets a hosting modal close itself first. */ onNavigateAway?: () => void; + staging?: boolean; }) { const logEndRef = useRef(null); const params: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({ strict: false }); @@ -79,7 +82,7 @@ export function DeployProgress({ return (
    - {DEPLOY_PHASE_ORDER.map((phase) => { + {(staging ? STAGE_PHASE_ORDER : DEPLOY_PHASE_ORDER).map((phase) => { const status = state.phases[phase]; const isCurrent = state.currentPhase === phase && status !== 'done'; return ( diff --git a/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx b/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx index 4399f8c89..a63381c2d 100644 --- a/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx +++ b/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx @@ -1,13 +1,16 @@ import { Button } from '@/components/ui/button'; import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { FormControl } from '@/components/ui/form/FormControl'; +import { FormDescription } from '@/components/ui/form/FormDescription'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useStagedDeploymentsAvailable } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; import { GitMergeIcon, LinkIcon, PackageIcon, RocketIcon } from 'lucide-react'; import { useCallback } from 'react'; import { Control, FormState, UseFormSetValue, UseFormWatch } from 'react-hook-form'; @@ -29,6 +32,7 @@ export function ImportInstructions({ watch: UseFormWatch>; }) { const importSource = watch('contents.source'); + const canStage = useStagedDeploymentsAvailable(); const setImportSource = useCallback((source: string) => { setValue('contents.source', source as 'git' | 'npm' | 'tarball'); }, [setValue]); @@ -154,6 +158,26 @@ export function ImportInstructions({ {importSource === 'git' && } + {canStage && ( + ( + + + + +
    + Stage only + + Install it on every node without making it live. Activate it later from Deployments. + +
    +
    + )} + /> + )} + diff --git a/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx b/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx index b2d2f39bf..fb44778fe 100644 --- a/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx +++ b/src/features/instance/applications/components/NewApplication/NewApplication.test.tsx @@ -21,6 +21,9 @@ vi.mock('./useCheckCLI', () => ({ useCheckCLI: () => ({ checkCLI: vi.fn() }) })) // These read the instance's SSH keys and auth, which aren't what these tests exercise. vi.mock('./ImportAuthorization', () => ({ ImportAuthorization: () => null })); vi.mock('./useCLISteps', () => ({ useCLISteps: () => [] })); +vi.mock('@/features/instance/applications/hooks/useSupportsDeploymentSSE', () => ({ + useStagedDeploymentsAvailable: () => false, +})); import { NewApplication } from './index'; diff --git a/src/features/instance/applications/components/NewApplication/schema.ts b/src/features/instance/applications/components/NewApplication/schema.ts index 70c1d1d9f..d55d7da44 100644 --- a/src/features/instance/applications/components/NewApplication/schema.ts +++ b/src/features/instance/applications/components/NewApplication/schema.ts @@ -20,6 +20,7 @@ export const ImportSchema = z.object({ // The SSH key the import URL is expected to use. Form-only state used to verify the URL's host // — it is not sent to deploy_component (the backend resolves the key from the URL host alias). sshKeyName: z.string(), + stageOnly: z.boolean(), }); export const defaultImportOptions: z.infer = { @@ -29,6 +30,7 @@ export const defaultImportOptions: z.infer = { requiresAuth: false, installCommand: '', sshKeyName: '', + stageOnly: false, }; export const CLISchema = z.object({ diff --git a/src/features/instance/applications/components/NewApplication/useImportApplication.ts b/src/features/instance/applications/components/NewApplication/useImportApplication.ts index 30f6cccb7..156156d32 100644 --- a/src/features/instance/applications/components/NewApplication/useImportApplication.ts +++ b/src/features/instance/applications/components/NewApplication/useImportApplication.ts @@ -2,8 +2,11 @@ import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { useComponentHealthCheck } from '@/features/instance/applications/hooks/useComponentHealthCheck'; import { useDeploymentRecordAction } from '@/features/instance/applications/hooks/useDeploymentRecordAction'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; -import { useSupportsDeploymentSSE } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; -import { reportDeployOutcome } from '@/features/instance/applications/modals/reportDeployHealth'; +import { + useStagedDeploymentsAvailable, + useSupportsDeploymentSSE, +} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; +import { reportDeployOutcome, reportStaged } from '@/features/instance/applications/modals/reportDeployHealth'; import { useDeployComponentMutation } from '@/integrations/api/instance/applications/deployComponent'; import { deployFailureDetails } from '@/integrations/api/instance/applications/deployOutcome'; import { DeployRolloutError } from '@/integrations/api/instance/applications/restartJob'; @@ -31,6 +34,7 @@ export function useImportApplication( const queryClient = useQueryClient(); const instanceParams = useInstanceClientIdParams(); const sseDeploy = useSupportsDeploymentSSE(); + const canStage = useStagedDeploymentsAvailable(); const runHealthCheck = useComponentHealthCheck(instanceParams); const recordAction = useDeploymentRecordAction(); const { reloadRootEntries, setFocusedItem, setExpandedItems, setSelectedItems } = useEditorView(); @@ -59,32 +63,36 @@ export function useImportApplication( contents: z.infer; project: string; }) => { - const toastId = toast.loading(`Importing application...`, { + const stage = canStage && contents.stageOnly; + const toastId = toast.loading(stage ? 'Staging application...' : 'Importing application...', { description: 'This may take a bit.', duration: 300_000, }); mutate({ - applicationName: project, - applicationUrl: contents.ref, - installCommand: contents.installCommand, + request: { + kind: stage ? 'stage' : 'deploy', + project, + package: contents.ref, + installCommand: contents.installCommand, + }, useSSE: sseDeploy, onEvent: (event) => { // Stream live progress into the loading toast: phase transitions, and the latest // install output line during the install/load phase (so npm output shows through). if (event.type === 'phase' && event.data.status === 'start') { - toast.loading(`Importing ${project}…`, { + toast.loading(`${stage ? 'Staging' : 'Importing'} ${project}…`, { id: toastId, description: PHASE_LABELS[event.data.phase] ?? event.data.phase, duration: 300_000, }); } else if (event.type === 'install' && event.data.line?.trim()) { - toast.loading(`Importing ${project}…`, { + toast.loading(`${stage ? 'Staging' : 'Importing'} ${project}…`, { id: toastId, description: event.data.line.trim().slice(0, 140), duration: 300_000, }); } else if (event.type === 'rollout' && event.data.state === 'following') { - toast.loading(`Importing ${project}…`, { + toast.loading(`${stage ? 'Staging' : 'Importing'} ${project}…`, { id: toastId, description: 'Rolling out to the other nodes…', duration: Infinity, @@ -95,6 +103,11 @@ export function useImportApplication( }, { onSuccess: async (result) => { toast.dismiss(toastId); + if (stage) { + // Nothing went live, so there is no new application in the tree to open. + reportStaged(project, result, { action: recordAction(result?.deployment_id) }); + return; + } onImported(project); // A followed rollout restarted the other nodes, so there is a health to check however the deploy was sent. if (sseDeploy || result?.rollout) { @@ -111,6 +124,12 @@ export function useImportApplication( onError: async (error) => { toast.dismiss(toastId); // Live stream dropped after the deploy started — verify rather than assume failure. + if (error instanceof SSEInconclusiveError && stage) { + toast.warning(`${project} may not be staged`, { + description: 'Live updates ended before the stage finished. Check Deployments for its record.', + }); + return; + } if (error instanceof SSEInconclusiveError) { onImported(project); await reportDeployOutcome(project, undefined, runHealthCheck); @@ -122,13 +141,13 @@ export function useImportApplication( toast.error(`${project} did not reach every node`, { description: error.message, action }); return; } - toast.error(`Failed to import ${project}`, { + toast.error(`Failed to ${stage ? 'stage' : 'import'} ${project}`, { description: error instanceof Error ? error.message : undefined, action, }); }, }); - }, [mutate, sseDeploy, instanceParams, runHealthCheck, onImported, recordAction]); + }, [mutate, sseDeploy, canStage, instanceParams, runHealthCheck, onImported, recordAction]); return useMemo(() => ({ isImportingApplication, diff --git a/src/features/instance/applications/hooks/useSupportsDeploymentSSE.test.ts b/src/features/instance/applications/hooks/useSupportsDeploymentSSE.test.ts new file mode 100644 index 000000000..5fb026b9d --- /dev/null +++ b/src/features/instance/applications/hooks/useSupportsDeploymentSSE.test.ts @@ -0,0 +1,20 @@ +/** @vitest-environment jsdom */ +import { describe, expect, it } from 'vitest'; +import { supportsStagedDeployments } from './useSupportsDeploymentSSE'; + +describe('supportsStagedDeployments', () => { + it.each([ + ['5.3.0', true], + ['5.3.2', true], + ['5.4.0', true], + ['6.0.0', true], + ['5.2.15', false], + ['4.7.9', false], + // An older node reads `activate: false` as an ordinary deploy, so anything short of a release is refused. + ['5.3.0-beta.4', false], + [undefined, false], + ['not a version', false], + ])('%s -> %s', (version, expected) => { + expect(supportsStagedDeployments(version)).toBe(expected); + }); +}); diff --git a/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts b/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts index deedf2c6b..4bdbf78a6 100644 --- a/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts +++ b/src/features/instance/applications/hooks/useSupportsDeploymentSSE.ts @@ -15,6 +15,26 @@ export function supportsDeployments(harperVersion: string | undefined): boolean return !!harperVersion && wasAReleasedBeforeB(MIN_DEPLOYMENT_SSE_VERSION, harperVersion); } +/** + * Staging (`activate: false`), activation by `deployment_id`, and the `staged` status ship in 5.3.0 (harper + * `v5.3.0:components/operations.js:638`). A 5.3.0 prerelease is refused, as `supportsPutOperation` refuses one. + */ +export const MIN_STAGED_DEPLOYMENT_VERSION = '5.3.0'; + +export function supportsStagedDeployments(harperVersion: string | undefined): boolean { + return !!harperVersion && wasAReleasedBeforeB(MIN_STAGED_DEPLOYMENT_VERSION, harperVersion); +} + +/** + * Whether to offer staging and activation. Unlike a put, an unreadable version must not offer them: a node older + * than 5.3.0 accepts `activate: false` as an unknown field and deploys the release live. + */ +export function useStagedDeploymentsAvailable(): boolean { + const params = useInstanceClientIdParams(); + const { data } = useQuery(getRegistrationInfoQueryOptions(params)); + return supportsStagedDeployments(data?.version); +} + /** * True when the instance/cluster is Harper >= 5.1.0, so the deployment history/detail * feature (`list_deployments` / `get_deployment`) is available. This gates feature diff --git a/src/features/instance/applications/modals/RedeployApplicationModal.tsx b/src/features/instance/applications/modals/RedeployApplicationModal.tsx index 6882bbed7..2047b14a1 100644 --- a/src/features/instance/applications/modals/RedeployApplicationModal.tsx +++ b/src/features/instance/applications/modals/RedeployApplicationModal.tsx @@ -2,18 +2,23 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Form } from '@/components/ui/form/Form'; import { FormControl } from '@/components/ui/form/FormControl'; +import { FormDescription } from '@/components/ui/form/FormDescription'; import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { DeployProgress } from '@/features/instance/applications/components/DeployProgress/DeployProgress'; import { useDeploymentStream } from '@/features/instance/applications/components/DeployProgress/useDeploymentStream'; import { useComponentHealthCheck } from '@/features/instance/applications/hooks/useComponentHealthCheck'; import { useDeploymentRecordAction } from '@/features/instance/applications/hooks/useDeploymentRecordAction'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; -import { useSupportsDeploymentSSE } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; +import { + useStagedDeploymentsAvailable, + useSupportsDeploymentSSE, +} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; import { useDeployComponentMutation } from '@/integrations/api/instance/applications/deployComponent'; import { deployFailureDetails } from '@/integrations/api/instance/applications/deployOutcome'; import { DeployRolloutError } from '@/integrations/api/instance/applications/restartJob'; @@ -26,7 +31,7 @@ import { RefreshCwIcon } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; -import { deployNotes, reportDeployOutcome, reportDeployResult } from './reportDeployHealth'; +import { deployNotes, reportDeployOutcome, reportDeployResult, reportStaged } from './reportDeployHealth'; export function RedeployApplicationModal() { const { value: isModalOpen, trigger } = useWatchedValue('ShowRedeployApplicationModal', false); @@ -34,6 +39,7 @@ export function RedeployApplicationModal() { const queryClient = useQueryClient(); const instanceParams = useInstanceClientIdParams(); const sseDeploy = useSupportsDeploymentSSE(); + const canStage = useStagedDeploymentsAvailable(); const stream = useDeploymentStream(); const runHealthCheck = useComponentHealthCheck(instanceParams); const recordAction = useDeploymentRecordAction(); @@ -43,25 +49,29 @@ export function RedeployApplicationModal() { const { mutate: reDeployApplication, isPending } = useDeployComponentMutation(); const [isStreaming, setIsStreaming] = useState(false); + const [isStaging, setIsStaging] = useState(false); const methods = useForm({ defaultValues: { applicationUrl: packageUrl, installCommand: '', + stageOnly: false, }, }); - const { control, handleSubmit, reset } = methods; + const { control, handleSubmit, reset, watch } = methods; + const stageOnlyChecked = canStage && watch('stageOnly'); useEffect(() => { - reset({ applicationUrl: packageUrl }); + reset({ applicationUrl: packageUrl, stageOnly: false }); }, [reset, packageUrl]); const closeModal = useCallback(() => { setWatchedValue('ShowRedeployApplicationModal', false); attemptToRestoreFocus(trigger); - reset({ applicationUrl: packageUrl }); + reset({ applicationUrl: packageUrl, stageOnly: false }); setIsStreaming(false); + setIsStaging(false); stream.reset(); }, [reset, packageUrl, trigger, stream]); @@ -73,7 +83,7 @@ export function RedeployApplicationModal() { }, [queryClient, instanceParams.entityId]); const redeployWithProgress = useCallback( - (applicationUrl: string, installCommand: string | undefined) => { + (applicationUrl: string, installCommand: string | undefined, stage: boolean) => { if (!openedEntry) { return; } @@ -81,18 +91,25 @@ export function RedeployApplicationModal() { stream.reset(); stream.markStarted(); setIsStreaming(true); + setIsStaging(stage); reDeployApplication( { - applicationName: project, - applicationUrl, - installCommand, + request: { kind: stage ? 'stage' : 'deploy', project, package: applicationUrl, installCommand }, useSSE: true, onEvent: stream.onEvent, ...instanceParams, }, { onSuccess: async (result) => { + if (stage) { + stream.markSettled('success', undefined, { result }); + reportStaged(project, result, { action: recordAction(result?.deployment_id) }); + if (!deployNotes(result).warn) { + closeModal(); + } + return; + } const known = result?.rollout?.state !== 'unfollowable'; stream.markSettled(known ? 'success' : 'inconclusive', undefined, { result }); invalidateComponents(); @@ -109,6 +126,14 @@ export function RedeployApplicationModal() { // instead of reporting a failure we don't actually know happened. if (error instanceof SSEInconclusiveError) { stream.markSettled('inconclusive'); + if (stage) { + // A stage changes nothing that is running, so the component's health says nothing about it. + toast.warning(`${project} may not be staged`, { + description: 'Live updates ended before the stage finished. Check Deployments for its record.', + }); + closeModal(); + return; + } invalidateComponents(); await reportDeployOutcome(project, undefined, runHealthCheck); closeModal(); @@ -125,7 +150,7 @@ export function RedeployApplicationModal() { stream.markSettled('error', error instanceof Error ? error.message : String(error), { failure: error, }); - toast.error(`Failed to redeploy ${project}`, { + toast.error(`Failed to ${stage ? 'stage' : 'redeploy'} ${project}`, { description: error instanceof Error ? error.message : undefined, action, }); @@ -146,16 +171,28 @@ export function RedeployApplicationModal() { ); const redeployBuffered = useCallback( - (applicationUrl: string, installCommand: string | undefined) => { + (applicationUrl: string, installCommand: string | undefined, stage: boolean) => { if (!openedEntry) { return; } closeModal(); - const toastId = toast.loading('Redeploying...'); + const toastId = toast.loading(stage ? 'Staging...' : 'Redeploying...'); reDeployApplication( - { applicationName: openedEntry.project, applicationUrl, installCommand, ...instanceParams }, + { + request: { + kind: stage ? 'stage' : 'deploy', + project: openedEntry.project, + package: applicationUrl, + installCommand, + }, + ...instanceParams, + }, { onSuccess: (result) => { + if (stage) { + reportStaged(openedEntry.project, result, { id: toastId, action: recordAction(result?.deployment_id) }); + return; + } reportDeployResult( openedEntry.project, result, @@ -177,7 +214,7 @@ export function RedeployApplicationModal() { return; } if (action) { - toast.error(`Failed to redeploy ${openedEntry.project}`, { + toast.error(`Failed to ${stage ? 'stage' : 'redeploy'} ${openedEntry.project}`, { id: toastId, description: describeError(error).message, action, @@ -192,17 +229,19 @@ export function RedeployApplicationModal() { [reDeployApplication, openedEntry, instanceParams, invalidateComponents, closeModal, recordAction], ); - const submitForm = ({ applicationUrl, installCommand }: { + const submitForm = ({ applicationUrl, installCommand, stageOnly }: { applicationUrl: string | undefined; installCommand: string | undefined; + stageOnly?: boolean; }) => { if (!applicationUrl) { return; } + const stage = canStage && !!stageOnly; if (sseDeploy) { - redeployWithProgress(applicationUrl, installCommand); + redeployWithProgress(applicationUrl, installCommand, stage); } else { - redeployBuffered(applicationUrl, installCommand); + redeployBuffered(applicationUrl, installCommand, stage); } }; @@ -215,14 +254,16 @@ export function RedeployApplicationModal() { Redeploy Package - {isStreaming ? `Redeploying ${openedEntry?.project ?? 'package'}…` : 'Redeploy this package?'} + {isStreaming + ? `${isStaging ? 'Staging' : 'Redeploying'} ${openedEntry?.project ?? 'package'}…` + : 'Redeploy this package?'} {isStreaming ? (
    - + + {started && ( + <> + + + + )} + + + + Activate this release of {project}? + + Deployment {deploymentId}{' '} + goes live on every node, which restart one at a time. Nothing is installed: each node swaps in the release + it staged. + + + + Cancel + void activate()}>Activate + + + + + ); +} diff --git a/src/features/instance/config/deployments/components/DeploymentDetail.tsx b/src/features/instance/config/deployments/components/DeploymentDetail.tsx index 2f68691b5..01c45ec6e 100644 --- a/src/features/instance/config/deployments/components/DeploymentDetail.tsx +++ b/src/features/instance/config/deployments/components/DeploymentDetail.tsx @@ -3,14 +3,19 @@ import { Separator } from '@/components/ui/separator'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { DeployProgress } from '@/features/instance/applications/components/DeployProgress/DeployProgress'; import { useDeploymentStream } from '@/features/instance/applications/components/DeployProgress/useDeploymentStream'; +import { deploymentRecordPath } from '@/features/instance/applications/hooks/useDeploymentRecordAction'; import { useSupportsDeploymentSSE } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; import { getDeploymentQueryOptions, getDeploymentStream } from '@/integrations/api/instance/deployments/getDeployment'; import { Deployment, isTerminalDeploymentStatus } from '@/integrations/api/instance/deployments/types'; +import { cn } from '@/lib/cn'; import { errorText } from '@/lib/errorText'; import { humanFileSize } from '@/lib/humanFileSize'; import { translateSecondsToAgo } from '@/lib/translateSecondsToAgo'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, useParams } from '@tanstack/react-router'; import { useEffect } from 'react'; +import { describePeerInstall } from '../functions/describePeerInstall'; +import { ActivateStagedRelease } from './ActivateStagedRelease'; import { DeploymentStatusBadge } from './deploymentStatusBadge'; function Field({ label, value }: { label: string; value: React.ReactNode }) { @@ -31,6 +36,7 @@ function timestamp(ms?: number) { export function DeploymentDetail({ deploymentId }: { deploymentId: string }) { const instanceParams = useInstanceClientIdParams(); + const params: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({ strict: false }); const sseSupported = useSupportsDeploymentSSE(); const queryClient = useQueryClient(); const stream = useDeploymentStream(); @@ -113,17 +119,35 @@ export function DeploymentDetail({ deploymentId }: { deploymentId: string }) { : undefined} /> {deployment.install_fingerprint.source} + : undefined} /> {deployment.rollback_of} + label="Activation of" + value={deployment.activated_from + ? ( + + {deployment.activated_from} + + ) : undefined} />
    + {deployment.status === 'staged' && ( + <> +
    + Staged: built and installed, but not live. Activating it makes it live with nothing to install. +
    + + + )} + {showLiveProgress && ( <> @@ -152,17 +176,30 @@ export function DeploymentDetail({ deploymentId }: { deploymentId: string }) { <>
    -
    Replication
    -
      +
      Other nodes
      +
        {deployment.peer_results.map((peer, i) => { const peerError = errorText(peer.error); + const install = describePeerInstall(peer); return ( -
      • - {peer.node} - - {peer.status ?? 'ok'} - {peerError ? `: ${peerError}` : ''} +
      • + + {peer.node} + + {peer.status ?? 'ok'} + {peerError ? `: ${peerError}` : ''} + + {install && ( + + {install.label} + + )}
      • ); })} diff --git a/src/features/instance/config/deployments/components/deploymentStatusBadge.tsx b/src/features/instance/config/deployments/components/deploymentStatusBadge.tsx index eb4f0ff4b..7033913c3 100644 --- a/src/features/instance/config/deployments/components/deploymentStatusBadge.tsx +++ b/src/features/instance/config/deployments/components/deploymentStatusBadge.tsx @@ -13,9 +13,10 @@ const STATUS_VARIANT: Record = { success: 'success', failed: 'destructive', rolled_back: 'destructive', + staged: 'secondary', }; -const STATUS_LABEL: Record = { +export const DEPLOYMENT_STATUS_LABEL: Record = { pending: 'Pending', extracting: 'Extracting', installing: 'Installing', @@ -25,8 +26,9 @@ const STATUS_LABEL: Record = { success: 'Success', failed: 'Failed', rolled_back: 'Rolled back', + staged: 'Staged', }; export function DeploymentStatusBadge({ status }: { status: DeploymentStatus }) { - return {STATUS_LABEL[status] ?? status}; + return {DEPLOYMENT_STATUS_LABEL[status] ?? status}; } diff --git a/src/features/instance/config/deployments/constants/tableDefinition.tsx b/src/features/instance/config/deployments/constants/tableDefinition.tsx index 81a03dfb7..83cfc3446 100644 --- a/src/features/instance/config/deployments/constants/tableDefinition.tsx +++ b/src/features/instance/config/deployments/constants/tableDefinition.tsx @@ -2,6 +2,7 @@ import { Deployment } from '@/integrations/api/instance/deployments/types'; import { ColumnDef, createColumnHelper } from '@/lib/table'; import { translateSecondsToAgo } from '@/lib/translateSecondsToAgo'; import { DeploymentStatusBadge } from '../components/deploymentStatusBadge'; +import { deploymentSource, shortDeploymentId } from '../functions/deploymentSource'; const columnHelper = createColumnHelper(); @@ -10,8 +11,18 @@ function ago(ms?: number) { } export const deploymentColumns: Array> = [ + columnHelper.display({ + header: 'Deployment', + id: 'deployment_id', + enableSorting: false, + cell: (props) => ( + + {shortDeploymentId(props.row.original.deployment_id)} + + ), + }), { - header: 'Project', + header: 'Component', accessorKey: 'project', enableSorting: false, }, @@ -19,28 +30,35 @@ export const deploymentColumns: Array> = [ header: 'Status', id: 'status', enableSorting: false, - cell: (props) => , - }), - columnHelper.display({ - header: 'Started', - id: 'started_at', - enableSorting: false, - cell: (props) => ago(props.row.original.started_at), + cell: (props) => ( + + + {props.row.original.status === 'staged' && ( + waiting to be activated + )} + + ), }), columnHelper.display({ - header: 'Completed', - id: 'completed_at', + header: 'Source', + id: 'source', enableSorting: false, - cell: (props) => ago(props.row.original.completed_at), + cell: (props) => { + const source = deploymentSource(props.row.original); + return source + ? {source.label} + : '—'; + }, }), { header: 'User', accessorKey: 'user', enableSorting: false, }, - { - header: 'Origin', - accessorKey: 'origin_node', + columnHelper.display({ + header: 'Started', + id: 'started_at', enableSorting: false, - }, + cell: (props) => ago(props.row.original.started_at), + }), ]; diff --git a/src/features/instance/config/deployments/deploymentsSearch.test.ts b/src/features/instance/config/deployments/deploymentsSearch.test.ts new file mode 100644 index 000000000..347e1e639 --- /dev/null +++ b/src/features/instance/config/deployments/deploymentsSearch.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { filterableStatuses, validateDeploymentsSearch } from './deploymentsSearch'; + +describe('validateDeploymentsSearch', () => { + it('keeps a component, a known status and a later page', () => { + expect(validateDeploymentsSearch({ project: ' web ', status: 'staged', page: '3' })) + .toEqual({ project: 'web', status: 'staged', page: 3 }); + }); + + it.each([ + ['an empty component', { project: ' ' }], + ['a status Harper does not filter on', { status: 'bogus' }], + ['the first page, which is the default', { page: 1 }], + ['a page that is not a whole number', { page: '2.5' }], + ['a negative page', { page: -2 }], + ['a component that is not text', { project: ['web'] }], + ])('drops %s', (_label, search) => { + expect(validateDeploymentsSearch(search)).toEqual({ project: undefined, status: undefined, page: undefined }); + }); +}); + +describe('filterableStatuses', () => { + it('offers staged only where staging exists (Harper 5.3.0+)', () => { + expect(filterableStatuses(true)).toContain('staged'); + expect(filterableStatuses(false)).not.toContain('staged'); + expect(filterableStatuses(false)).toContain('success'); + }); +}); diff --git a/src/features/instance/config/deployments/deploymentsSearch.ts b/src/features/instance/config/deployments/deploymentsSearch.ts new file mode 100644 index 000000000..084369a24 --- /dev/null +++ b/src/features/instance/config/deployments/deploymentsSearch.ts @@ -0,0 +1,38 @@ +import { DeploymentStatus } from '@/integrations/api/instance/deployments/types'; + +export interface DeploymentsSearch { + project?: string; + status?: DeploymentStatus; + /** 1-based. */ + page?: number; +} + +// `rolled_back` is left out: no Harper version writes it, so the filter could only ever come back empty. +export const FILTERABLE_STATUSES: readonly DeploymentStatus[] = [ + 'pending', + 'extracting', + 'installing', + 'loading', + 'replicating', + 'restarting', + 'success', + 'failed', + 'staged', +]; + +export function filterableStatuses(stagingSupported: boolean): readonly DeploymentStatus[] { + return stagingSupported ? FILTERABLE_STATUSES : FILTERABLE_STATUSES.filter((status) => status !== 'staged'); +} + +export const DEPLOYMENTS_PAGE_SIZE = 25; + +export function validateDeploymentsSearch(search: Record): DeploymentsSearch { + const project = typeof search.project === 'string' ? search.project.trim() : ''; + const status = FILTERABLE_STATUSES.find((candidate) => candidate === search.status); + const page = Number(search.page); + return { + project: project || undefined, + status, + page: Number.isInteger(page) && page > 1 ? page : undefined, + }; +} diff --git a/src/features/instance/config/deployments/functions/deploymentDescriptions.test.ts b/src/features/instance/config/deployments/functions/deploymentDescriptions.test.ts new file mode 100644 index 000000000..f702becd9 --- /dev/null +++ b/src/features/instance/config/deployments/functions/deploymentDescriptions.test.ts @@ -0,0 +1,52 @@ +import { Deployment } from '@/integrations/api/instance/deployments/types'; +import { describe, expect, it } from 'vitest'; +import { deploymentSource } from './deploymentSource'; +import { describePeerInstall } from './describePeerInstall'; + +const base = { deployment_id: 'd', project: 'web', status: 'success' } as Deployment; + +describe('deploymentSource', () => { + it('names the package a deploy came from', () => { + expect(deploymentSource({ ...base, package_identifier: 'github:acme/web#v2' })) + .toEqual({ label: 'github:acme/web#v2', title: 'github:acme/web#v2' }); + }); + + it("names a payload deploy by its tarball's hash", () => { + expect(deploymentSource({ ...base, package_identifier: null, payload_hash: 'abcdef0123456789abcdef' })) + .toEqual({ label: 'Payload abcdef012345', title: 'abcdef0123456789abcdef' }); + }); + + it('names the release an activation made live, which built nothing', () => { + expect(deploymentSource({ ...base, activated_from: 'a1b2c3d4-0000-4000-8000-000000000001' })) + .toEqual({ label: 'Activation of a1b2c3d4', title: 'a1b2c3d4-0000-4000-8000-000000000001' }); + }); + + it('names nothing for a record that says nothing', () => { + expect(deploymentSource(base)).toBeUndefined(); + }); +}); + +// The comparison fields Harper records per peer: harper `components/deploymentRecorder.ts` installComparisonFields. +describe('describePeerInstall', () => { + it('says nothing for a record from before installs were compared (pre-5.3.1)', () => { + expect(describePeerInstall({ node: 'node-b', status: 'success' })).toBeUndefined(); + }); + + it('names what a peer installed differently', () => { + expect(describePeerInstall({ + node: 'node-b', + install: { source: 'npm:web@1.5.0', lockfiles: {} }, + install_matches: false, + install_differs: ['source', 'package-lock.json'], + })).toEqual({ label: 'Installed differently: source npm:web@1.5.0, package-lock.json.', matches: false }); + }); + + it('says a matching install matched', () => { + expect(describePeerInstall({ node: 'node-b', install_matches: true, install_differs: [] })?.matches).toBe(true); + }); + + it('does not read missing evidence as a match', () => { + expect(describePeerInstall({ node: 'node-b', install: null, install_matches: null, install_differs: [] })) + .toEqual({ label: "Couldn't compare its install: a fingerprint is missing or unidentified.", matches: null }); + }); +}); diff --git a/src/features/instance/config/deployments/functions/deploymentSource.ts b/src/features/instance/config/deployments/functions/deploymentSource.ts new file mode 100644 index 000000000..b7641e005 --- /dev/null +++ b/src/features/instance/config/deployments/functions/deploymentSource.ts @@ -0,0 +1,25 @@ +import { Deployment } from '@/integrations/api/instance/deployments/types'; + +export function shortDeploymentId(id: string): string { + return id.length > 8 ? id.slice(0, 8) : id; +} + +/** + * Where a release came from. A package deploy records its reference and a payload deploy its tarball's hash; an + * activation (5.3.0+) builds nothing, so it names the deployment whose release it made live. + */ +export function deploymentSource(deployment: Deployment): { label: string; title: string } | undefined { + if (deployment.activated_from) { + return { + label: `Activation of ${shortDeploymentId(deployment.activated_from)}`, + title: deployment.activated_from, + }; + } + if (deployment.package_identifier) { + return { label: deployment.package_identifier, title: deployment.package_identifier }; + } + if (deployment.payload_hash) { + return { label: `Payload ${deployment.payload_hash.slice(0, 12)}`, title: deployment.payload_hash }; + } + return undefined; +} diff --git a/src/features/instance/config/deployments/functions/describePeerInstall.ts b/src/features/instance/config/deployments/functions/describePeerInstall.ts new file mode 100644 index 000000000..c66ef2378 --- /dev/null +++ b/src/features/instance/config/deployments/functions/describePeerInstall.ts @@ -0,0 +1,26 @@ +import { DeploymentPeerResult } from '@/integrations/api/instance/deployments/types'; + +/** + * Whether a node installed what the node that received the deploy did (5.3.1+). Harper adds the comparison only + * on a deploy or a stage that compared fingerprints, so a record without it says nothing either way. + */ +export function describePeerInstall( + peer: DeploymentPeerResult, +): { label: string; matches: boolean | null } | undefined { + if (!('install_matches' in peer)) { + return undefined; + } + if (peer.install_matches === true) { + return { label: 'Installed the same as the node that received the deploy.', matches: true }; + } + if (peer.install_matches === false) { + const differs = (peer.install_differs ?? []).map((field) => + field === 'source' && peer.install?.source ? `source ${peer.install.source}` : field + ); + return { + label: `Installed differently: ${differs.length ? differs.join(', ') : 'see the deploy log'}.`, + matches: false, + }; + } + return { label: "Couldn't compare its install: a fingerprint is missing or unidentified.", matches: null }; +} diff --git a/src/features/instance/config/deployments/index.test.tsx b/src/features/instance/config/deployments/index.test.tsx index 4a621a354..c210bc520 100644 --- a/src/features/instance/config/deployments/index.test.tsx +++ b/src/features/instance/config/deployments/index.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConfigDeploymentsIndex } from './index'; @@ -8,8 +8,16 @@ const state = vi.hoisted(() => ({ version: undefined as string | undefined, registrationFails: false, listEnabled: [] as (boolean | undefined)[], + listParams: [] as Record[], + listData: undefined as { deployments: object[]; total: number } | undefined, + search: {} as Record, + navigate: vi.fn(), +})); +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => state.navigate, + useParams: () => ({}), + useSearch: () => state.search, })); -vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), useParams: () => ({}) })); vi.mock('@/hooks/usePermissions', () => ({ useInstanceManagePermission: () => true })); vi.mock('@/config/useInstanceClient', () => ({ useInstanceClientIdParams: () => ({ entityId: 'ins-a' }) })); vi.mock('@/integrations/api/instance/status/getRegistrationInfo', () => ({ @@ -21,9 +29,15 @@ vi.mock('@/integrations/api/instance/status/getRegistrationInfo', () => ({ }), })); vi.mock('@/integrations/api/instance/deployments/listDeployments', () => ({ - getDeploymentsQueryOptions: ({ enabled }: { enabled?: boolean }) => { - state.listEnabled.push(enabled); - return { queryKey: ['list_deployments'], queryFn: () => ({ deployments: [], total: 0 }), enabled: false }; + getDeploymentsQueryOptions: (params: { enabled?: boolean }) => { + state.listEnabled.push(params.enabled); + state.listParams.push(params); + return { + queryKey: ['list_deployments'], + queryFn: () => ({ deployments: [], total: 0 }), + enabled: false, + ...(state.listData ? { initialData: state.listData } : {}), + }; }, })); vi.mock('./modals/DeploymentDetailModal', () => ({ DeploymentDetailModal: () => null })); @@ -33,6 +47,10 @@ afterEach(() => { state.version = undefined; state.registrationFails = false; state.listEnabled = []; + state.listParams = []; + state.listData = undefined; + state.search = {}; + state.navigate.mockClear(); }); function renderPage(version: string | undefined) { @@ -71,3 +89,89 @@ describe('ConfigDeploymentsIndex version gate', () => { expect(screen.queryByText('No deployments yet.')).toBeNull(); }); }); + +const row = (id: string, extra: object = {}) => ({ + deployment_id: id, + project: 'web', + status: 'success', + started_at: 1, + ...extra, +}); + +describe('ConfigDeploymentsIndex filters and paging', () => { + it('asks list_deployments for the filtered page the URL names', () => { + state.search = { project: 'web', status: 'staged', page: 3 }; + renderPage('5.3.0'); + expect(state.listParams.at(-1)).toMatchObject({ project: 'web', status: 'staged', limit: 25, offset: 50 }); + }); + + it('does not ask a pre-5.3 instance for staged records a saved link names', () => { + state.search = { status: 'staged' }; + renderPage('5.2.15'); + expect(state.listParams.at(-1)).toMatchObject({ status: undefined }); + // …and drops it from the URL, which would otherwise claim a filter the list is not applying. + const update = state.navigate.mock.calls.at(-1)?.[0] as { search: (prev: object) => object; replace: boolean }; + expect(update.replace).toBe(true); + expect(update.search({ status: 'staged', project: 'web' })).toEqual({ status: undefined, project: 'web' }); + }); + + it('keeps a staged filter where staging exists', () => { + state.search = { status: 'staged' }; + renderPage('5.3.0'); + expect(state.listParams.at(-1)).toMatchObject({ status: 'staged' }); + expect(state.navigate).not.toHaveBeenCalled(); + }); + + it('applies the component filter on Enter only, so leaving the field does not move the page', () => { + state.search = { page: 2 }; + renderPage('5.3.0'); + const input = screen.getByRole('textbox', { name: 'Component' }); + fireEvent.change(input, { target: { value: 'web' } }); + fireEvent.blur(input); + expect(state.navigate).not.toHaveBeenCalled(); + }); + + it('pages by the total the server reports, not by the rows it sent', () => { + state.search = { page: 2 }; + state.listData = { deployments: Array.from({ length: 25 }, (_, i) => row(`d-${i}`)), total: 60 }; + renderPage('5.3.0'); + expect(screen.getByText('26–50 of 60')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + const update = state.navigate.mock.calls.at(-1)?.[0] as { search: (prev: object) => object }; + expect(update.search({ page: 2, project: 'web' })).toEqual({ page: 3, project: 'web' }); + fireEvent.click(screen.getByRole('button', { name: 'Previous page' })); + const back = state.navigate.mock.calls.at(-1)?.[0] as { search: (prev: object) => object }; + expect(back.search({ page: 2 })).toEqual({ page: undefined }); + }); + + it('filters by component on Enter, and starts again from the first page', () => { + state.search = { page: 4 }; + renderPage('5.3.0'); + const input = screen.getByRole('textbox', { name: 'Component' }); + fireEvent.change(input, { target: { value: ' web ' } }); + fireEvent.submit(input.closest('form')!); + const update = state.navigate.mock.calls.at(-1)?.[0] as { search: (prev: object) => object }; + expect(update.search({ page: 4 })).toEqual({ page: undefined, project: 'web' }); + }); + + it('says the filters matched nothing rather than that there are no deployments', () => { + state.search = { project: 'nope' }; + state.listData = { deployments: [], total: 0 }; + renderPage('5.3.0'); + expect(screen.getByText('No deployments match these filters.')).toBeTruthy(); + }); + + it('shows a staged release as waiting, and an activation by the release it made live', () => { + state.listData = { + deployments: [ + row('a1b2c3d4-0000-4000-8000-000000000001', { status: 'staged', package_identifier: 'npm:web@2.0.0' }), + row('e5f6a7b8-0000-4000-8000-000000000002', { activated_from: 'a1b2c3d4-0000-4000-8000-000000000001' }), + ], + total: 2, + }; + renderPage('5.3.0'); + expect(screen.getByText('waiting to be activated')).toBeTruthy(); + expect(screen.getByText('npm:web@2.0.0')).toBeTruthy(); + expect(screen.getByText('Activation of a1b2c3d4')).toBeTruthy(); + }); +}); diff --git a/src/features/instance/config/deployments/index.tsx b/src/features/instance/config/deployments/index.tsx index ec984b3ab..8ba5e698b 100644 --- a/src/features/instance/config/deployments/index.tsx +++ b/src/features/instance/config/deployments/index.tsx @@ -1,24 +1,34 @@ import { SimpleBrowseDataTable } from '@/components/SimpleBrowseDataTable'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; -import { supportsDeployments } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; +import { + supportsDeployments, + supportsStagedDeployments, +} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE'; import { useInstanceManagePermission } from '@/hooks/usePermissions'; import { useRefreshClick } from '@/hooks/useRefreshClick'; import { getDeploymentsQueryOptions } from '@/integrations/api/instance/deployments/listDeployments'; -import { Deployment } from '@/integrations/api/instance/deployments/types'; +import { Deployment, DeploymentStatus } from '@/integrations/api/instance/deployments/types'; import { getRegistrationInfoQueryOptions } from '@/integrations/api/instance/status/getRegistrationInfo'; import { Row } from '@/lib/table'; import { useQuery } from '@tanstack/react-query'; -import { useNavigate, useParams } from '@tanstack/react-router'; -import { RefreshCwIcon } from 'lucide-react'; -import { useCallback, useMemo } from 'react'; +import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; +import { ChevronLeftIcon, ChevronRightIcon, RefreshCwIcon } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { DEPLOYMENT_STATUS_LABEL } from './components/deploymentStatusBadge'; import { deploymentColumns } from './constants/tableDefinition'; +import { DEPLOYMENTS_PAGE_SIZE, DeploymentsSearch, filterableStatuses } from './deploymentsSearch'; import { DeploymentDetailModal } from './modals/DeploymentDetailModal'; +const ALL_STATUSES = 'all'; + export function ConfigDeploymentsIndex() { const navigate = useNavigate(); const canManage = useInstanceManagePermission(); const { deploymentId }: { deploymentId?: string } = useParams({ strict: false }); + const { project, status: requestedStatus, page = 1 }: DeploymentsSearch = useSearch({ strict: false }); const instanceParams = useInstanceClientIdParams(); // The nav hides this page below Harper 5.1, but a bookmark or back-navigation still lands here. const { data: registrationInfo, isError: registrationFailed } = useQuery( @@ -27,27 +37,70 @@ export function ConfigDeploymentsIndex() { const versionKnown = registrationInfo !== undefined; const versionUnavailable = !versionKnown && registrationFailed; const deploymentsAvailable = supportsDeployments(registrationInfo?.version); + const statuses = filterableStatuses(supportsStagedDeployments(registrationInfo?.version)); + const status = requestedStatus && statuses.includes(requestedStatus) ? requestedStatus : undefined; + const unsupportedStatus = versionKnown && !!requestedStatus && !status; + useEffect(() => { + // Otherwise the URL would keep claiming a filter the list is not applying. + if (unsupportedStatus) { + void navigate({ + to: '.', + search: (previous: DeploymentsSearch) => ({ ...previous, status: undefined }), + replace: true, + }); + } + }, [unsupportedStatus, navigate]); const { data, isFetching, isRefetching, refetch, error } = useQuery( getDeploymentsQueryOptions({ ...instanceParams, - limit: 100, + project, + status, + limit: DEPLOYMENTS_PAGE_SIZE, + offset: (page - 1) * DEPLOYMENTS_PAGE_SIZE, enabled: canManage && deploymentsAvailable, pollWhileOpen: true, }), ); const deployments = useMemo(() => data?.deployments ?? [], [data]); + const total = data?.total ?? 0; + const firstShown = total === 0 ? 0 : (page - 1) * DEPLOYMENTS_PAGE_SIZE + 1; + const lastShown = Math.min(total, (page - 1) * DEPLOYMENTS_PAGE_SIZE + deployments.length); + const filtered = !!project || !!status; + + const setSearch = useCallback( + (patch: DeploymentsSearch) => { + void navigate({ to: '.', search: (previous: DeploymentsSearch) => ({ ...previous, ...patch }) }); + }, + [navigate], + ); + const applyProject = useCallback( + (value: string) => { + const next = value.trim() || undefined; + if (next !== project) { + setSearch({ project: next, page: undefined }); + } + }, + [project, setSearch], + ); const onSelect = useCallback( (next: string | undefined) => { const parts = [deploymentId ? '..' : '', next].filter(Boolean); - void navigate({ to: parts.join('/') || '.' }); + void navigate({ to: parts.join('/') || '.', search: true }); }, [deploymentId, navigate], ); const onRowClick = useCallback((row: Row) => onSelect(row.original.deployment_id), [onSelect]); const closeModal = useCallback(() => onSelect(undefined), [onSelect]); const onRefreshClick = useRefreshClick(refetch); + const projectInput = useRef(null); + useEffect(() => { + // Follows a filter set from outside the field, such as the editor's link, without remounting it. + if (projectInput.current && projectInput.current.value.trim() !== (project ?? '')) { + projectInput.current.value = project ?? ''; + } + }, [project]); if (!canManage) { return ( @@ -73,6 +126,37 @@ export function ConfigDeploymentsIndex() { columns={deploymentColumns} onRowClick={onRowClick} > +
        { + event.preventDefault(); + applyProject(String(new FormData(event.currentTarget).get('project') ?? '')); + }} + > + + +
        + + ) + : filtered + ? 'No deployments match these filters.' + : 'No deployments yet.'} +
    + )} + {deployments.length > 0 && ( +
    + + {firstShown}–{lastShown} of {total} + + + +
    )} {!!deploymentId && ( diff --git a/src/features/instance/config/routes.ts b/src/features/instance/config/routes.ts index ee2b40bef..55713d9a2 100644 --- a/src/features/instance/config/routes.ts +++ b/src/features/instance/config/routes.ts @@ -1,5 +1,6 @@ import { ConfigCertificatesIndex } from '@/features/instance/config/certificates'; import { ConfigDeploymentsIndex } from '@/features/instance/config/deployments'; +import { validateDeploymentsSearch } from '@/features/instance/config/deployments/deploymentsSearch'; import { ConfigDomainsIndex } from '@/features/instance/config/domains'; import { ConfigIndex } from '@/features/instance/config/index'; import { ConfigOverviewIndex } from '@/features/instance/config/overview'; @@ -76,12 +77,14 @@ export function createConfigRouteTree(instanceLayoutRoute: ReturnType instanceConfigRoute, path: 'deployments', + validateSearch: validateDeploymentsSearch, head: () => ({ meta: [{ title: 'Deployments — Harper Fabric' }] }), component: ConfigDeploymentsIndex, }); const instanceConfigDeploymentRoute = createRoute({ getParentRoute: () => instanceConfigRoute, path: 'deployments/$deploymentId', + validateSearch: validateDeploymentsSearch, head: () => ({ meta: [{ title: 'Deployments — Harper Fabric' }] }), component: ConfigDeploymentsIndex, }); diff --git a/src/integrations/api/instance/applications/deployComponent.fallback.test.ts b/src/integrations/api/instance/applications/deployComponent.fallback.test.ts new file mode 100644 index 000000000..7eaed9539 --- /dev/null +++ b/src/integrations/api/instance/applications/deployComponent.fallback.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/integrations/api/sse/resolveInstanceConnection', () => ({ + resolveInstanceConnection: () => ({ + url: 'https://ins-1.example.com:9925/', + headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, + credentials: 'same-origin' as RequestCredentials, + mode: 'direct', + }), +})); + +import { SSEInconclusiveError } from '@/integrations/api/sse/errors'; +import { onDeployComponentSubmit } from './deployComponent'; + +const live = { + request: { kind: 'deploy' as const, project: 'my-app', package: 'npm:my-app' }, + entityId: 'ins-1' as const, + entityType: 'instance' as const, + useSSE: true, +}; + +function bufferedClient() { + return { post: vi.fn().mockResolvedValue({ data: { message: 'Successfully deployed: my-app' } }) }; +} + +function deploy(client: ReturnType) { + return onDeployComponentSubmit({ ...live, instanceClient: client as never }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('the live deploy, through the real stream', () => { + it.each([502, 503, 504])( + 'is not sent again after a %i from a gateway, which may have forwarded it', + async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('Bad Gateway', { + status, + headers: { 'content-type': 'text/html' }, + }), + ), + ); + const client = bufferedClient(); + + await expect(deploy(client)).rejects.toBeInstanceOf(SSEInconclusiveError); + expect(client.post).not.toHaveBeenCalled(); + }, + ); + + it('is not sent again after a 2xx that is not a stream, which means it ran', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ message: 'Successfully deployed: my-app' })), + ); + const client = bufferedClient(); + + await expect(deploy(client)).rejects.toBeInstanceOf(SSEInconclusiveError); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('is not sent again when the connection drops after the request went out', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); + const client = bufferedClient(); + + await expect(deploy(client)).rejects.toBeInstanceOf(SSEInconclusiveError); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('is not sent again when no answer arrives within the idle window', async () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }) + ), + ); + const client = bufferedClient(); + + const outcome = deploy(client).catch((e: unknown) => e); + await vi.advanceTimersByTimeAsync(120_000); + + expect(await outcome).toBeInstanceOf(SSEInconclusiveError); + expect(client.post).not.toHaveBeenCalled(); + }); + + it.each([401, 403])('goes out on the buffered path when Harper refused the stream with a %i', async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ error: 'Login failed' }, { status })), + ); + const client = bufferedClient(); + + await expect(deploy(client)).resolves.toEqual({ message: 'Successfully deployed: my-app' }); + expect(client.post).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/integrations/api/instance/applications/deployComponent.test.ts b/src/integrations/api/instance/applications/deployComponent.test.ts index 45ca0050d..199ca4f79 100644 --- a/src/integrations/api/instance/applications/deployComponent.test.ts +++ b/src/integrations/api/instance/applications/deployComponent.test.ts @@ -4,8 +4,8 @@ vi.mock('./deployComponentStream', () => ({ deployComponentStream: vi.fn(), })); -import { SSEOperationError, SSEUnsupportedError } from '@/integrations/api/sse/errors'; -import { deployAndFollowComponent, onDeployComponentSubmit } from './deployComponent'; +import { SSEInconclusiveError, SSEOperationError, SSEUnsupportedError } from '@/integrations/api/sse/errors'; +import { deployAndFollowComponent, deployRequestBody, onDeployComponentSubmit } from './deployComponent'; import { deployComponentStream } from './deployComponentStream'; import { DeployRolloutError } from './restartJob'; @@ -16,8 +16,7 @@ function fakeClient(data: unknown) { } const baseArgs = { - applicationName: 'my-app', - applicationUrl: 'npm:my-app', + request: { kind: 'deploy' as const, project: 'my-app', package: 'npm:my-app' }, entityId: 'ins-1' as const, entityType: 'instance' as const, }; @@ -26,7 +25,78 @@ afterEach(() => { vi.clearAllMocks(); }); +// Harper's validator decides these shapes: a stage refuses `restart` (harper `v5.3.0:components/operationsValidation.js:517-524`), +// and an activation refuses `package`, install options and `activate` alongside `deployment_id` (`:613-616`). +describe('deployRequestBody', () => { + it('deploys with a rolling restart', () => { + expect(deployRequestBody({ kind: 'deploy', project: 'web', package: 'npm:web', installCommand: 'npm ci' }, true)) + .toEqual({ + operation: 'deploy_component', + package: 'npm:web', + project: 'web', + replicated: true, + install_command: 'npm ci', + restart: 'rolling', + }); + }); + + it('stages with activate: false and no restart', () => { + const body = deployRequestBody({ kind: 'stage', project: 'web', package: 'npm:web' }, true); + expect(body).toEqual({ + operation: 'deploy_component', + package: 'npm:web', + project: 'web', + replicated: true, + install_command: undefined, + activate: false, + }); + expect(body).not.toHaveProperty('restart'); + }); + + it('activates by id alone, with a rolling restart and nothing to install', () => { + const body = deployRequestBody({ kind: 'activate', project: 'web', deploymentId: 'a3f8c2d1' }, false); + expect(body).toEqual({ + operation: 'deploy_component', + project: 'web', + deployment_id: 'a3f8c2d1', + replicated: false, + restart: 'rolling', + }); + expect(JSON.parse(JSON.stringify(body))).not.toHaveProperty('package'); + }); +}); + describe('onDeployComponentSubmit', () => { + it('stages through the buffered path exactly as built', async () => { + const client = fakeClient({ message: 'Staged: my-app', staged: true, deployment_id: 'd1' }); + await onDeployComponentSubmit({ + ...baseArgs, + request: { kind: 'stage', project: 'my-app', package: 'npm:my-app' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instanceClient: client as any, + }); + expect(client.post).toHaveBeenCalledWith( + '/', + expect.objectContaining({ activate: false, project: 'my-app', replicated: false }), + { timeout: 300_000 }, + ); + expect(client.post.mock.calls[0][1]).not.toHaveProperty('restart'); + }); + + it('streams the same body it would post', async () => { + mockStream.mockResolvedValue({ message: 'ok' }); + await onDeployComponentSubmit({ + ...baseArgs, + request: { kind: 'activate', project: 'my-app', deploymentId: 'd1' }, + useSSE: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instanceClient: fakeClient({}) as any, + }); + expect(mockStream).toHaveBeenCalledWith(expect.objectContaining({ + body: deployRequestBody({ kind: 'activate', project: 'my-app', deploymentId: 'd1' }, false), + })); + }); + it('uses the buffered axios path when SSE is not requested', async () => { const client = fakeClient({ message: 'buffered' }); const result = await onDeployComponentSubmit({ @@ -53,17 +123,44 @@ describe('onDeployComponentSubmit', () => { expect(client.post).not.toHaveBeenCalled(); }); - it('falls back to the buffered path when the stream is unsupported', async () => { - mockStream.mockRejectedValue(new SSEUnsupportedError('no stream')); + it.each([400, 401, 403, 404, 406, 413, 429])( + 'falls back to the buffered path when the stream was refused with a %i', + async (status) => { + mockStream.mockRejectedValue(new SSEUnsupportedError('no stream', { status })); + const client = fakeClient({ message: 'buffered' }); + const result = await onDeployComponentSubmit({ + ...baseArgs, + useSSE: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instanceClient: client as any, + }); + expect(result).toEqual({ message: 'buffered' }); + expect(client.post).toHaveBeenCalledOnce(); + }, + ); + + it.each([ + ['a gateway 502', 502], + ['a gateway 503', 503], + ['a gateway 504', 504], + ['a Harper 500', 500], + ['a 2xx that is not a stream', 200], + ['no response at all', undefined], + ])('never sends the deploy again after %s, which can follow a deploy already running', async (_, status) => { + const unsupported = new SSEUnsupportedError('no stream', { status }); + mockStream.mockRejectedValue(unsupported); const client = fakeClient({ message: 'buffered' }); - const result = await onDeployComponentSubmit({ + + const error = await onDeployComponentSubmit({ ...baseArgs, useSSE: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any instanceClient: client as any, - }); - expect(result).toEqual({ message: 'buffered' }); - expect(client.post).toHaveBeenCalledOnce(); + }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SSEInconclusiveError); + expect((error as Error).cause).toBe(unsupported); + expect(client.post).not.toHaveBeenCalled(); }); it('rethrows a deploy failure without re-POSTing (no double deploy)', async () => { diff --git a/src/integrations/api/instance/applications/deployComponent.ts b/src/integrations/api/instance/applications/deployComponent.ts index a9fab9b35..d5c3e0ec9 100644 --- a/src/integrations/api/instance/applications/deployComponent.ts +++ b/src/integrations/api/instance/applications/deployComponent.ts @@ -1,18 +1,44 @@ import { InstanceClientConfig, InstanceTypeConfig } from '@/config/instanceClientConfig'; import { EntityIds } from '@/features/auth/store/authStore'; import { ReplicatedResponse } from '@/integrations/api/replication'; -import { SSEUnsupportedError } from '@/integrations/api/sse/errors'; +import { SSEInconclusiveError, SSEUnsupportedError } from '@/integrations/api/sse/errors'; import { useMutation } from '@tanstack/react-query'; import { DeployComponentResult, deployComponentStream, DeployStreamEvent } from './deployComponentStream'; import { DeployRolloutError, followRestartJob } from './restartJob'; -export interface DeployComponentFormData { - applicationName: string; - applicationUrl: string; - installCommand?: string; +/** + * What a `deploy_component` call does. `stage` builds and installs the release on every node without making it + * live, and takes no `restart` (Harper refuses one); `activate` makes a staged or kept release live by its id and + * installs nothing. Both need Harper 5.3.0 on every node — an older node reads a stage as an ordinary deploy and + * serves it — so a caller offers them only on a version it could read. + */ +export type DeployRequest = + | { kind: 'deploy'; project: string; package: string; installCommand?: string } + | { kind: 'stage'; project: string; package: string; installCommand?: string } + | { kind: 'activate'; project: string; deploymentId: string }; + +export function deployRequestBody(request: DeployRequest, replicated: boolean): Record { + if (request.kind === 'activate') { + return { + operation: 'deploy_component', + project: request.project, + deployment_id: request.deploymentId, + replicated, + restart: 'rolling', + }; + } + return { + operation: 'deploy_component', + package: request.package, + project: request.project, + replicated, + install_command: request.installCommand || undefined, + ...(request.kind === 'stage' ? { activate: false } : { restart: 'rolling' }), + }; } -export interface DeployComponentArgs extends DeployComponentFormData, InstanceClientConfig, InstanceTypeConfig { +export interface DeployComponentArgs extends InstanceClientConfig, InstanceTypeConfig { + request: DeployRequest; /** Entity id, used to resolve the SSE connection. Present on `useInstanceClientIdParams`. */ entityId?: EntityIds; /** Opt into the live SSE deploy (only when the instance is Harper >= 5.1.0). */ @@ -23,10 +49,13 @@ export interface DeployComponentArgs extends DeployComponentFormData, InstanceCl signal?: AbortSignal; } +/** Only a 4xx proves Harper never started the deploy: applications/DESIGN.md, "A live deploy is sent once". */ +function refusedBeforeRunning({ status }: SSEUnsupportedError): boolean { + return status !== undefined && status >= 400 && status < 500; +} + export async function onDeployComponentSubmit({ - applicationName, - applicationUrl, - installCommand, + request, entityType, entityId, instanceClient, @@ -34,44 +63,28 @@ export async function onDeployComponentSubmit({ onEvent, signal, }: DeployComponentArgs): Promise { - const replicated = entityType === 'cluster'; + const body = deployRequestBody(request, entityType === 'cluster'); if (useSSE && entityId) { try { - return await deployComponentStream({ - connection: { id: entityId }, - package: applicationUrl, - project: applicationName, - replicated, - install_command: installCommand, - restart: 'rolling', - signal, - onEvent, - }); + return await deployComponentStream({ connection: { id: entityId }, body, signal, onEvent }); } catch (error) { - // Couldn't establish a stream (bad response/content-type/connection) and no deploy - // has started yet — safe to fall back to the proven buffered path below. if (!(error instanceof SSEUnsupportedError)) { // A terminal error, an inconclusive stream, or a deliberate abort. The deploy has // already started server-side, so we must NOT re-POST it (that would deploy // twice). Let the caller handle it (show the error, or poll on inconclusive). throw error; } + if (!refusedBeforeRunning(error)) { + throw new SSEInconclusiveError( + 'The live deploy did not stream and may already have started, so it was not sent again.', + { cause: error }, + ); + } } } - const { data } = await instanceClient.post( - '/', - { - operation: 'deploy_component', - package: applicationUrl, - project: applicationName, - replicated, - install_command: installCommand || undefined, - restart: 'rolling', - }, - { timeout: 300_000 }, - ); + const { data } = await instanceClient.post('/', body, { timeout: 300_000 }); return data as ReplicatedResponse; } @@ -100,8 +113,10 @@ export async function deployAndFollowComponent(args: DeployComponentArgs): Promi return { ...result, rollout }; } -export function useDeployComponentMutation() { +export function useDeployComponentMutation(mutationKey?: readonly unknown[], meta?: Record) { return useMutation({ + mutationKey, + meta, mutationFn: deployAndFollowComponent, }); } diff --git a/src/integrations/api/instance/applications/deployComponentStream.ts b/src/integrations/api/instance/applications/deployComponentStream.ts index c502a7778..2d06e1406 100644 --- a/src/integrations/api/instance/applications/deployComponentStream.ts +++ b/src/integrations/api/instance/applications/deployComponentStream.ts @@ -7,7 +7,7 @@ import { RolloutSnapshot } from './restartJob'; /** Lifecycle phases Harper's `deploy_component` emits, in order. */ export const DEPLOY_PHASE_ORDER = ['prepare', 'load', 'replicate', 'restart', 'success'] as const; -export type DeployPhase = (typeof DEPLOY_PHASE_ORDER)[number]; +export const STAGE_PHASE_ORDER = ['prepare', 'load', 'replicate', 'staged'] as const; export interface DeployPhaseEvent { phase: string; @@ -51,6 +51,7 @@ export interface DeployComponentResult extends ReplicatedResponse { deployment_id?: string; phase?: string; certification?: DeployCertification; + staged?: boolean; /** The `restart_service` job a rolling restart hands the other nodes to. */ restartJobId?: string; /** The `warning` events the deploy streamed. */ @@ -80,11 +81,7 @@ export function toDeployStreamEvent(message: SSEMessage): DeployStreamEvent | un export interface DeployComponentStreamParams { connection: ResolveInstanceConnectionParams; - package: string; - project: string; - replicated: boolean; - install_command?: string; - restart?: string; + body: Record; signal?: AbortSignal; /** Called for each live deploy event so the UI can render progress. */ onEvent?: (event: DeployStreamEvent) => void; @@ -95,31 +92,21 @@ export interface DeployComponentStreamParams { * and resolving with the terminal `done` result. * * Throws the transport errors from `@/integrations/api/sse/errors`: - * - `SSEUnsupportedError` — the server/proxy didn't stream; caller should retry buffered. + * - `SSEUnsupportedError` — the server/proxy didn't stream. Resend buffered only when its `status` is a 4xx + * refusal (`onDeployComponentSubmit` decides); anything else may follow a deploy already running. * - `SSEOperationError` — the deploy failed (terminal `error` event). * - `SSEInconclusiveError` — the stream ended without a verdict; caller must poll. */ export async function deployComponentStream({ connection, - package: packageRef, - project, - replicated, - install_command, - restart, + body, signal, onEvent, }: DeployComponentStreamParams): Promise { const warnings: string[] = []; const result = await streamOperation({ connection, - body: { - operation: 'deploy_component', - package: packageRef, - project, - replicated, - install_command: install_command || undefined, - restart, - }, + body, signal, onMessage: (message) => { const event = toDeployStreamEvent(message); diff --git a/src/integrations/api/instance/applications/deployOutcome.test.ts b/src/integrations/api/instance/applications/deployOutcome.test.ts index 5ccef5569..73deb7064 100644 --- a/src/integrations/api/instance/applications/deployOutcome.test.ts +++ b/src/integrations/api/instance/applications/deployOutcome.test.ts @@ -9,6 +9,7 @@ vi.mock('@/integrations/api/sse/resolveInstanceConnection', () => ({ }), })); +import { deployRequestBody } from './deployComponent'; import { deployComponentStream } from './deployComponentStream'; import { deployFailureDetails, readCertificationDecision } from './deployOutcome'; import { DeployRolloutError } from './restartJob'; @@ -39,10 +40,7 @@ function sseResponse(body: string): Response { function streamDeploy() { return deployComponentStream({ connection: { id: 'ins-1' }, - package: 'npm:web', - project: 'web', - replicated: true, - restart: 'rolling', + body: deployRequestBody({ kind: 'deploy', project: 'web', package: 'npm:web' }, true), }); } @@ -130,9 +128,7 @@ describe('deployComponentStream', () => { const events: unknown[] = []; const result = await deployComponentStream({ connection: { id: 'ins-1' }, - package: 'npm:web', - project: 'web', - replicated: true, + body: deployRequestBody({ kind: 'deploy', project: 'web', package: 'npm:web' }, true), onEvent: (event) => events.push(event), }); expect(events).toEqual([{ type: 'warning', data: { message: drift } }]); diff --git a/src/integrations/api/instance/deployments/deploymentPolling.test.ts b/src/integrations/api/instance/deployments/deploymentPolling.test.ts index 3ba1e9f83..3ceab67e9 100644 --- a/src/integrations/api/instance/deployments/deploymentPolling.test.ts +++ b/src/integrations/api/instance/deployments/deploymentPolling.test.ts @@ -36,6 +36,7 @@ describe('get_deployment polling', () => { const poll = detailInterval(); expect(poll({ state: { error: null, data: deployment('installing') } })).toBe(2_000); expect(poll({ state: { error: null, data: deployment('success') } })).toBe(false); + expect(poll({ state: { error: null, data: deployment('staged') } })).toBe(false); }); it('does not poll a detail view that never asked to', () => { @@ -62,6 +63,7 @@ describe('list_deployments polling', () => { const poll = listInterval() as IntervalFn; expect(poll({ state: { error: null, data: list('success', 'installing') } })).toBe(2_000); expect(poll({ state: { error: null, data: list('success', 'failed') } })).toBe(10_000); + expect(poll({ state: { error: null, data: list('success', 'staged') } })).toBe(10_000); expect(poll({ state: { error: null } })).toBe(10_000); }); diff --git a/src/integrations/api/instance/deployments/types.test.ts b/src/integrations/api/instance/deployments/types.test.ts index 3c2dd27ef..3c21e25ca 100644 --- a/src/integrations/api/instance/deployments/types.test.ts +++ b/src/integrations/api/instance/deployments/types.test.ts @@ -12,6 +12,7 @@ describe('isTerminalDeploymentStatus', () => { ['success', true], ['failed', true], ['rolled_back', true], + ['staged', true], ])('%s -> terminal=%s', (status, expected) => { expect(isTerminalDeploymentStatus(status)).toBe(expected); }); diff --git a/src/integrations/api/instance/deployments/types.ts b/src/integrations/api/instance/deployments/types.ts index f86693480..9c9254c8b 100644 --- a/src/integrations/api/instance/deployments/types.ts +++ b/src/integrations/api/instance/deployments/types.ts @@ -8,12 +8,16 @@ export type DeploymentStatus = | 'restarting' | 'success' | 'failed' - | 'rolled_back'; + | 'rolled_back' + /** Built and installed on every node, not live (5.3.0+). Activating it writes a record of its own. */ + | 'staged'; +// Harper's own set (harper `components/deploymentOperations.ts:19`): a staged record never changes again. export const TERMINAL_DEPLOYMENT_STATUSES: ReadonlySet = new Set([ 'success', 'failed', 'rolled_back', + 'staged', ]); export function isTerminalDeploymentStatus(status: DeploymentStatus | undefined): boolean { @@ -27,6 +31,15 @@ export interface DeploymentEventLogEntry { data: unknown; } +/** + * What an install came from (5.3.1+): `npm:@`, `git:`, `integrity:` or `unidentified`, + * absent for a payload; and the sha256 of each root lockfile, or why it could not be read. + */ +export interface InstallFingerprint { + source?: string; + lockfiles?: Record; +} + export interface DeploymentPeerResult { node: string; status?: string; @@ -34,6 +47,12 @@ export interface DeploymentPeerResult { error?: string | DeploymentPeerError | null; started_at?: number; completed_at?: number; + /** Present once the origin compared this peer's install with its own (5.3.1+). */ + install?: InstallFingerprint | null; + /** `false` when anything differs, `null` when the evidence is missing or unidentified, `true` when it all matches. */ + install_matches?: boolean | null; + /** `source` and/or the lockfile names that differ. */ + install_differs?: string[]; } export interface DeploymentPeerError { @@ -68,8 +87,10 @@ export interface Deployment { started_at?: number; completed_at?: number; user?: string; - restorable?: boolean; - rollback_of?: string | null; + /** On an activation (5.3.0+), the deployment whose staged or kept release it made live. */ + activated_from?: string | null; + /** The origin's own install, on a deploy or a stage (5.3.1+); an activation installs nothing. */ + install_fingerprint?: InstallFingerprint | null; error?: DeploymentError | null; } diff --git a/src/integrations/api/sse/errors.ts b/src/integrations/api/sse/errors.ts index fa00d8d9d..66911be20 100644 --- a/src/integrations/api/sse/errors.ts +++ b/src/integrations/api/sse/errors.ts @@ -8,12 +8,16 @@ * The server did not (or could not) speak SSE for this request: the response was not OK, * arrived without a `text/event-stream` content-type (e.g. an older Harper that ignores * the `Accept` header, or a proxy that rewrote it), or the connection failed before any - * bytes streamed. The caller should fall back to the classic buffered operation. + * bytes streamed. A read can fall back to the classic buffered operation; a write only when + * `status` shows the server refused it (see `onDeployComponentSubmit`). */ export class SSEUnsupportedError extends Error { - constructor(message: string, options?: { cause?: unknown }) { + readonly status?: number; + + constructor(message: string, options?: { cause?: unknown; status?: number }) { super(message, options); this.name = 'SSEUnsupportedError'; + this.status = options?.status; } } diff --git a/src/integrations/api/sse/streamOperation.test.ts b/src/integrations/api/sse/streamOperation.test.ts index 734733250..2c667f123 100644 --- a/src/integrations/api/sse/streamOperation.test.ts +++ b/src/integrations/api/sse/streamOperation.test.ts @@ -109,23 +109,39 @@ describe('streamOperation', () => { ).rejects.toMatchObject({ name: 'SSEOperationError', message: 'The operation failed.' }); }); - it('falls back (SSEUnsupportedError) when the response is not an event stream', async () => { + it('falls back (SSEUnsupportedError) when the response is not an event stream, with its status', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(sseResponse('{"message":"ok"}', { contentType: 'application/json' })), ); - await expect( - streamOperation({ connection: { id: 'ins-1' }, body: { operation: 'deploy_component' } }), - ).rejects.toBeInstanceOf(SSEUnsupportedError); + const error = await streamOperation({ connection: { id: 'ins-1' }, body: { operation: 'deploy_component' } }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SSEUnsupportedError); + expect((error as SSEUnsupportedError).status).toBe(200); }); - it('falls back (SSEUnsupportedError) when fetch rejects before streaming', async () => { + it('reports the status of a response that refused the stream', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(sseResponse('{"error":"Gateway Timeout"}', { status: 504, contentType: 'text/html' })), + ); + + const error = await streamOperation({ connection: { id: 'ins-1' }, body: { operation: 'deploy_component' } }) + .catch((e: unknown) => e); + + expect((error as SSEUnsupportedError).status).toBe(504); + }); + + it('falls back (SSEUnsupportedError) when fetch rejects before streaming, with no status', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down'))); - await expect( - streamOperation({ connection: { id: 'ins-1' }, body: { operation: 'deploy_component' } }), - ).rejects.toBeInstanceOf(SSEUnsupportedError); + const error = await streamOperation({ connection: { id: 'ins-1' }, body: { operation: 'deploy_component' } }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(SSEUnsupportedError); + expect((error as SSEUnsupportedError).status).toBeUndefined(); }); it('throws SSEInconclusiveError when the stream ends without a terminal event', async () => { diff --git a/src/integrations/api/sse/streamOperation.ts b/src/integrations/api/sse/streamOperation.ts index 841e474f7..206c1051d 100644 --- a/src/integrations/api/sse/streamOperation.ts +++ b/src/integrations/api/sse/streamOperation.ts @@ -25,8 +25,8 @@ export interface StreamOperationOptions { * * Failure modes are explicit (see `./errors`): * - {@link SSEUnsupportedError} — couldn't establish a stream (bad response, wrong - * content-type, or connection error before any bytes). Caller should fall back to the - * buffered axios path. + * content-type, or connection error before any bytes). A read can fall back to the buffered + * axios path; the request may still have reached the server, so a write must check `status`. * - {@link SSEOperationError} — a terminal `error` event arrived. * - {@link SSEInconclusiveError} — the stream ended (or idled out) without a terminal * event. The outcome is unknown; caller must poll to determine it, never assume success. @@ -70,8 +70,8 @@ export async function streamOperation({ if (signal?.aborted) { throw error; } - // Anything else (network error, DNS, idle before headers) means we never got a - // stream going; let the caller fall back to the buffered request. + // Anything else (network error, DNS, idle before headers) means we never got a stream + // going, though the request may have been sent: there is no `status` to say otherwise. throw new SSEUnsupportedError('Failed to open the SSE stream.', { cause: error }); } @@ -81,6 +81,7 @@ export async function streamOperation({ await response.body?.cancel().catch(() => {}); throw new SSEUnsupportedError( `Server did not return an event stream (status ${response.status}, content-type "${contentType}").`, + { status: response.status }, ); }