Skip to content
Merged
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,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,
Expand All @@ -28,12 +34,14 @@ import {
DownloadIcon,
FilePlusIcon,
FolderPlusIcon,
HistoryIcon,
LockIcon,
PackageIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
PencilIcon,
PlusIcon,
RocketIcon,
RotateCcwIcon,
SaveIcon,
TrashIcon,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -389,6 +406,30 @@ export function ContentActions({
Redeploy Package
</DropdownMenuItem>
)}
{deploymentsProject && (
<DropdownMenuItem
onSelect={() =>
void navigate({
to: buildAbsoluteLinkToPage(routeParams, 'config/deployments'),
search: { project: deploymentsProject },
})}
>
<HistoryIcon />
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
Loading
Loading