Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 24 additions & 0 deletions src/features/instance/applications/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ 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 { useDeploymentsAvailable } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE';
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';
Expand All @@ -38,6 +41,7 @@ import {
PanelRightOpenIcon,
PencilIcon,
PlusIcon,
RocketIcon,
RotateCcwIcon,
SaveIcon,
TrashIcon,
Expand Down Expand Up @@ -79,6 +83,7 @@ export function ContentActions({
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({
Expand Down Expand Up @@ -413,6 +418,18 @@ export function ContentActions({
View Deployments
</DropdownMenuItem>
)}
{deploymentsProject && stagingAvailable && (
<DropdownMenuItem
onSelect={() =>
void navigate({
to: buildAbsoluteLinkToPage(routeParams, 'config/deployments'),
search: { project: deploymentsProject, status: 'staged' },
})}
>
<RocketIcon />
Staged Releases
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DeployProgress
staging
state={deployedState({ phases: { prepare: 'done', replicate: 'done', staged: 'done' } })}
/>,
);
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(<DeployProgress state={deployedState({ warnings: ['Install fingerprints differ on node-b'] })} />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -33,6 +33,7 @@ const PHASE_LABELS: Record<string, string> = {
replicate: 'Replicate',
restart: 'Restart',
success: 'Finish',
staged: 'Staged',
};

/**
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const params: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({ strict: false });
Expand All @@ -79,7 +82,7 @@ export function DeployProgress({
return (
<div className="flex flex-col gap-4">
<ol className="flex flex-col gap-2">
{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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -29,6 +32,7 @@ export function ImportInstructions({
watch: UseFormWatch<z.infer<typeof NewApplicationSchema>>;
}) {
const importSource = watch('contents.source');
const canStage = useStagedDeploymentsAvailable();
const setImportSource = useCallback((source: string) => {
setValue('contents.source', source as 'git' | 'npm' | 'tarball');
}, [setValue]);
Expand Down Expand Up @@ -154,14 +158,34 @@ export function ImportInstructions({

{importSource === 'git' && <ImportAuthorization control={control} setValue={setValue} watch={watch} />}

{canStage && (
<FormField
control={control}
name="contents.stageOnly"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3">
<FormControl>
<Switch checked={!!field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="flex flex-col gap-1">
<FormLabel>Stage only</FormLabel>
<FormDescription>
Install it on every node without making it live. Activate it later from Deployments.
</FormDescription>
</div>
</FormItem>
)}
/>
)}

<Separator className="bg-border" />

<Button
className="w-full"
disabled={!formState.isValid || isImportingApplication}
>
<RocketIcon className="w-4 h-4 mr-2" />
Import Application
{canStage && watch('contents.stageOnly') ? 'Stage Application' : 'Import Application'}
</Button>
</CardContent>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ImportSchema> = {
Expand All @@ -29,6 +30,7 @@ export const defaultImportOptions: z.infer<typeof ImportSchema> = {
requiresAuth: false,
installCommand: '',
sshKeyName: '',
stageOnly: false,
};

export const CLISchema = z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -59,32 +63,36 @@ export function useImportApplication(
contents: z.infer<typeof ImportSchema>;
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,
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ export function supportsStagedDeployments(harperVersion: string | undefined): bo
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
Expand Down
Loading
Loading