diff --git a/apps/agent/internal/communication/client.go b/apps/agent/internal/communication/client.go index 0c7e8aa..4a82008 100644 --- a/apps/agent/internal/communication/client.go +++ b/apps/agent/internal/communication/client.go @@ -27,6 +27,7 @@ const ( TypeContainerInspected = "container.inspected" TypeContainerStart = "container.start" TypeContainerStop = "container.stop" + TypeContainerRemove = "container.remove" TypeContainerRestart = "container.restart" TypeContainerResult = "container.result" TypeLogsSubscribe = "logs.subscribe" @@ -73,13 +74,13 @@ type HostMetricsPayload struct { // ContainerSummary matches protocol container discovery fields. type ContainerSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Image string `json:"image"` - Status string `json:"status"` - State string `json:"state"` - Ports []container.Port `json:"ports"` - Created int64 `json:"created"` + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + Status string `json:"status"` + State string `json:"state"` + Ports []container.Port `json:"ports"` + Created int64 `json:"created"` } // ContainerListedPayload is sent on container.listed. @@ -94,6 +95,11 @@ type ContainerInspectedPayload struct { Error *string `json:"error"` } +type ContainerRemovePayload struct { + ContainerCommandPayload + Force bool `json:"force"` +} + // ContainerCommandPayload is shared by start/stop/restart. type ContainerCommandPayload struct { RequestID string `json:"requestId"` @@ -382,6 +388,8 @@ func (c *Client) serve(ctx context.Context, conn *websocket.Conn) error { logger.Warn("host metrics not sent", "error", err.Error()) } } + + } } @@ -389,7 +397,7 @@ func (c *Client) handleServerMessage(ctx context.Context, conn *websocket.Conn, switch env.Type { case TypeContainerList: return c.handleContainerList(ctx, conn) - case TypeContainerStart, TypeContainerStop, TypeContainerRestart: + case TypeContainerStart, TypeContainerStop, TypeContainerRestart, TypeContainerRemove: return c.handleContainerCommand(ctx, conn, env) case TypeLogsSubscribe: return c.handleLogsSubscribe(env) @@ -469,14 +477,13 @@ func (c *Client) handleContainerList(ctx context.Context, conn *websocket.Conn) summaries := make([]ContainerSummary, 0, len(items)) for _, item := range items { summaries = append(summaries, ContainerSummary{ - ID: item.ID, - Name: item.Name, - Image: item.Image, - Status: item.Status, - State: item.State, - Ports: item.Ports, - Created : item.Created, - + ID: item.ID, + Name: item.Name, + Image: item.Image, + Status: item.Status, + State: item.State, + Ports: item.Ports, + Created: item.Created, }) } @@ -485,7 +492,7 @@ func (c *Client) handleContainerList(ctx context.Context, conn *websocket.Conn) } func (c *Client) handleContainerCommand(ctx context.Context, conn *websocket.Conn, env Envelope) error { - var payload ContainerCommandPayload + var payload ContainerRemovePayload if err := json.Unmarshal(env.Payload, &payload); err != nil { return fmt.Errorf("parse container command: %w", err) } @@ -526,6 +533,8 @@ func (c *Client) handleContainerCommand(ctx context.Context, conn *websocket.Con err = c.docker.StopContainer(ctx, payload.ContainerID) case TypeContainerRestart: err = c.docker.RestartContainer(ctx, payload.ContainerID) + case TypeContainerRemove: + err = c.docker.RemoveContainer(ctx, payload.ContainerID, payload.Force) default: err = fmt.Errorf("unsupported action %s", env.Type) } @@ -650,6 +659,8 @@ func actionFromType(msgType string) string { return "stop" case TypeContainerRestart: return "restart" + case TypeContainerRemove: + return "remove" default: return "unknown" } diff --git a/apps/agent/internal/communication/conformance_test.go b/apps/agent/internal/communication/conformance_test.go index 940e7bd..2bcfe9a 100644 --- a/apps/agent/internal/communication/conformance_test.go +++ b/apps/agent/internal/communication/conformance_test.go @@ -101,6 +101,71 @@ func TestHostMetricsFieldsArePopulated(t *testing.T) { } } +// TestContainerRemoveMatchesProtocolFixture guards the server -> agent direction +// of the same hand-mirrored contract. It matters more here than for metrics: a +// mistyped `force` tag would decode to the zero value with no error anywhere, so +// an operator's explicit force-remove would silently become an ordinary remove +// that Docker then refuses. +func TestContainerRemoveMatchesProtocolFixture(t *testing.T) { + fixtures := []struct { + name string + wantForce bool + }{ + {"container.remove.json", false}, + {"container.remove.force.json", true}, + } + + for _, fixture := range fixtures { + t.Run(fixture.name, func(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(fixturesDir, fixture.name)) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + var envelope struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("decode envelope: %v", err) + } + + if envelope.Type != TypeContainerRemove { + t.Errorf("envelope type = %q, want %q", envelope.Type, TypeContainerRemove) + } + + var payload ContainerRemovePayload + if err := json.Unmarshal(envelope.Payload, &payload); err != nil { + t.Fatalf("decode payload into ContainerRemovePayload: %v", err) + } + + // The round trip catches a renamed key; these catch a key that decodes + // under the right name but into the wrong field. + if payload.RequestID == "" || payload.ContainerID == "" { + t.Errorf("requestId/containerId did not decode: %+v", payload) + } + if payload.Force != fixture.wantForce { + t.Errorf("force = %v, want %v", payload.Force, fixture.wantForce) + } + + roundTripped, err := json.Marshal(payload) + if err != nil { + t.Fatalf("re-encode payload: %v", err) + } + + want := normalize(t, envelope.Payload) + got := normalize(t, roundTripped) + if !reflect.DeepEqual(want, got) { + t.Errorf( + "payload does not round-trip through the Go structs.\n fixture: %s\n go: %s\n"+ + "The Go structs in client.go have drifted from packages/protocol.", + mustJSON(t, want), mustJSON(t, got), + ) + } + }) + } +} + // normalize decodes JSON into generic maps so comparison ignores key order and // integer/float formatting differences. func normalize(t *testing.T, data []byte) map[string]any { diff --git a/apps/agent/internal/docker/service.go b/apps/agent/internal/docker/service.go index 0688e42..64f101a 100644 --- a/apps/agent/internal/docker/service.go +++ b/apps/agent/internal/docker/service.go @@ -267,3 +267,15 @@ func (s *Service) InspectContainer(ctx context.Context, containerID string) (*Co } return mapContainerInspect(containerJSON), nil } + +// RemoveContainer removes a container. Without force, Docker refuses to remove +// one that is running. +func (s *Service) RemoveContainer(ctx context.Context, containerID string, force bool) error { + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + if err := s.client.sdk.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force}); err != nil { + return fmt.Errorf("docker remove %s: %w", shortID(containerID), err) + } + return nil +} diff --git a/apps/server/src/agents/agents.gateway.ts b/apps/server/src/agents/agents.gateway.ts index 09bdc66..f8c9d60 100644 --- a/apps/server/src/agents/agents.gateway.ts +++ b/apps/server/src/agents/agents.gateway.ts @@ -19,6 +19,7 @@ import { CONTAINER_RESTART, CONTAINER_RESULT, CONTAINER_START, + CONTAINER_REMOVE, CONTAINER_STOP, LOGS_CHUNK, LOGS_SUBSCRIBE, @@ -75,6 +76,7 @@ const COMMAND_TYPE: Record = { start: CONTAINER_START, stop: CONTAINER_STOP, restart: CONTAINER_RESTART, + remove: CONTAINER_REMOVE, }; @WebSocketGateway({ path: '/agents' }) @@ -173,6 +175,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { hostId: string, action: ContainerAction, containerId: string, + force = false, timeoutMs = 45_000, ): Promise { this.inventory.rememberHost(hostId, agentUuid); @@ -202,14 +205,18 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { this.send( client, - createEnvelope(type, { - requestId, - containerId, - }), + createEnvelope( + type, + // Only container.remove carries `force`; the other three lifecycle + // commands share ContainerCommandPayload, which has no such field. + action === 'remove' + ? { requestId, containerId, force } + : { requestId, containerId }, + ), ); this.logger.log( - `Sent ${type} requestId=${requestId} containerId=${containerId.slice(0, 12)} uuid=${agentUuid}`, + `Sent ${type} requestId=${requestId} containerId=${containerId.slice(0, 12)} uuid=${agentUuid}${action === 'remove' ? ` force=${force}` : ''}`, ); }); } diff --git a/apps/server/src/containers/containers.controller.ts b/apps/server/src/containers/containers.controller.ts index 6d0f746..8c38b1c 100644 --- a/apps/server/src/containers/containers.controller.ts +++ b/apps/server/src/containers/containers.controller.ts @@ -23,9 +23,9 @@ import { ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { Observable, finalize } from 'rxjs'; -import type { LogsChunkPayload } from '@docksight/protocol'; +import type { ContainerAction, LogsChunkPayload } from '@docksight/protocol'; import { Roles } from '../auth/roles.decorator'; import { ContainersService } from './containers.service'; @@ -36,6 +36,17 @@ class ContainerActionBodyDto { hostId!: string; } +class ContainerRemoveBodyDto extends ContainerActionBodyDto { + @ApiProperty({ + required: false, + default: false, + description: 'Remove a running container by killing it first', + }) + @IsOptional() + @IsBoolean() + force?: boolean; +} + @ApiTags('containers') @Controller('containers') export class ContainersController { @@ -112,6 +123,21 @@ export class ContainersController { return this.runAction(containerId, body.hostId, 'restart'); } + @Post(':id/remove') + @Roles('ADMIN') + @ApiBearerAuth() + @ApiOperation({ summary: 'Remove a container on a host' }) + @ApiBody({ type: ContainerRemoveBodyDto }) + @ApiOkResponse({ description: 'Lifecycle command result' }) + @ApiUnauthorizedResponse({ description: 'Missing or invalid token' }) + @ApiForbiddenResponse({ description: 'Requires the ADMIN role' }) + remove( + @Param('id') containerId: string, + @Body() body: ContainerRemoveBodyDto, + ) { + return this.runAction(containerId, body.hostId, 'remove', body.force); + } + @Sse(':id/logs') @ApiOperation({ summary: 'Stream container logs (SSE)', @@ -194,13 +220,15 @@ export class ContainersController { private async runAction( containerId: string, hostId: string, - action: 'start' | 'stop' | 'restart', + action: ContainerAction, + force = false, ) { try { return await this.containersService.runLifecycleAction( hostId, containerId, action, + force, ); } catch (error) { this.mapAndThrow(error, 'Container action failed'); diff --git a/apps/server/src/containers/containers.service.ts b/apps/server/src/containers/containers.service.ts index 9594d05..50ce890 100644 --- a/apps/server/src/containers/containers.service.ts +++ b/apps/server/src/containers/containers.service.ts @@ -19,6 +19,7 @@ export class ContainersService { hostId: string, containerId: string, action: ContainerAction, + force = false, ): Promise { if (!hostId?.trim()) { throw new Error('hostId is required'); @@ -37,6 +38,7 @@ export class ContainersService { agent.id, action, containerId, + force, ); } diff --git a/apps/server/src/health/health.controller.ts b/apps/server/src/health/health.controller.ts index ec9240b..1bbade5 100644 --- a/apps/server/src/health/health.controller.ts +++ b/apps/server/src/health/health.controller.ts @@ -1,8 +1,4 @@ -import { - Controller, - Get, - ServiceUnavailableException, -} from '@nestjs/common'; +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; import { ApiOkResponse, ApiOperation, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7add400..03286eb 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -60,9 +60,7 @@ async function bootstrap() { * - production, unset → false (same-origin only; no reflected Origin) * - development, unset → Vite dev server defaults */ -function resolveCorsOrigin( - config: ConfigService, -): boolean | string[] { +function resolveCorsOrigin(config: ConfigService): boolean | string[] { const configured = config.get('CORS_ORIGINS'); if (configured != null && configured.trim() !== '') { return configured diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index 4f89680..13770b4 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { ReactNode } from 'react' +import { ConfirmProvider } from '@/components/ConfirmProvider' import { ToastProvider } from '@/components/ToastProvider' const queryClient = new QueryClient({ @@ -19,7 +20,9 @@ type AppProvidersProps = { export function AppProviders({ children }: AppProvidersProps) { return ( - {children} + + {children} + ) } diff --git a/apps/web/src/components/ConfirmProvider.tsx b/apps/web/src/components/ConfirmProvider.tsx new file mode 100644 index 0000000..748a6b7 --- /dev/null +++ b/apps/web/src/components/ConfirmProvider.tsx @@ -0,0 +1,176 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import type { ReactNode } from 'react' +import { AlertTriangle } from 'lucide-react' +import { Button } from '@/components/ui/button' + +export type ConfirmToggle = { + label: string + description?: string + /** Initial checked state. Always start unchecked for destructive toggles. */ + defaultChecked?: boolean +} + +export type ConfirmRequest = { + title: string + description: ReactNode + confirmLabel: string + cancelLabel?: string + /** Optional checkbox rendered above the buttons (e.g. "force remove"). */ + toggle?: ConfirmToggle +} + +export type ConfirmResult = { + confirmed: boolean + /** Final state of `toggle`; false when no toggle was offered. */ + toggled: boolean +} + +type ConfirmContextValue = { + confirm: (request: ConfirmRequest) => Promise +} + +const ConfirmContext = createContext(null) + +/** + * Promise-based confirmation dialog, mirroring `ToastProvider`. + * + * Living behind a promise rather than component state means a caller can write + * `if (!(await confirm(...)).confirmed) return` inline. Destructive commands can + * therefore gate themselves inside the shared command hook, instead of every + * surface that renders a delete control having to remember to ask first. + */ +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [request, setRequest] = useState(null) + const [toggled, setToggled] = useState(false) + const resolveRef = useRef<((result: ConfirmResult) => void) | null>(null) + + const settle = useCallback((result: ConfirmResult) => { + resolveRef.current?.(result) + resolveRef.current = null + setRequest(null) + }, []) + + const confirm = useCallback((next: ConfirmRequest) => { + // A second request while one is open would orphan the first promise, so + // resolve it as a cancel before taking over. + resolveRef.current?.({ confirmed: false, toggled: false }) + setToggled(next.toggle?.defaultChecked ?? false) + setRequest(next) + return new Promise((resolve) => { + resolveRef.current = resolve + }) + }, []) + + useEffect(() => { + if (!request) { + return + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + settle({ confirmed: false, toggled: false }) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [request, settle]) + + const value = useMemo(() => ({ confirm }), [confirm]) + + return ( + + {children} + {request ? ( +
settle({ confirmed: false, toggled: false })} + > +
event.stopPropagation()} + > +
+ +
+

+ {request.title} +

+
+ {request.description} +
+
+
+ + {request.toggle ? ( + + ) : null} + +
+ + +
+
+
+ ) : null} +
+ ) +} + +export function useConfirm() { + const ctx = useContext(ConfirmContext) + if (!ctx) { + throw new Error('useConfirm must be used within ConfirmProvider') + } + return ctx.confirm +} diff --git a/apps/web/src/components/ContainerTable.tsx b/apps/web/src/components/ContainerTable.tsx index e069b4a..4495d53 100644 --- a/apps/web/src/components/ContainerTable.tsx +++ b/apps/web/src/components/ContainerTable.tsx @@ -11,7 +11,7 @@ import { Square, Trash2, } from 'lucide-react' -import { StatusDot, MockBadge } from '@/components/ui/badge' +import { StatusDot } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dropdown, @@ -495,15 +495,17 @@ function RowMenuItems({ Copy image - } destructive disabled> + } + destructive + disabled={!onAction || !canManage} + onSelect={() => { + onAction?.(container, 'remove') + close() + }} + > Delete -
- -
) } diff --git a/apps/web/src/hooks/useContainerAction.ts b/apps/web/src/hooks/useContainerAction.ts index 6749b80..afd3d69 100644 --- a/apps/web/src/hooks/useContainerAction.ts +++ b/apps/web/src/hooks/useContainerAction.ts @@ -8,6 +8,8 @@ type ContainerActionVariables = { hostId: string action: ContainerAction containerName?: string + /** Only meaningful for `remove`: kill a running container first. */ + force?: boolean } export function useContainerAction() { @@ -18,8 +20,9 @@ export function useContainerAction() { containerId, hostId, action, + force = false, }: ContainerActionVariables): Promise => - runContainerAction(containerId, hostId, action), + runContainerAction(containerId, hostId, action, force), onSuccess: async (_result, variables) => { await queryClient.invalidateQueries({ queryKey: containersQueryKey(variables.hostId), diff --git a/apps/web/src/hooks/useContainerCommands.ts b/apps/web/src/hooks/useContainerCommands.ts index 2f642db..4baf9a5 100644 --- a/apps/web/src/hooks/useContainerCommands.ts +++ b/apps/web/src/hooks/useContainerCommands.ts @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useConfirm } from '@/components/ConfirmProvider' import { useToast } from '@/components/ToastProvider' import { useContainerAction } from '@/hooks/useContainerAction' import { ApiError } from '@/services/api' @@ -9,6 +10,15 @@ const PAST_TENSE: Record = { start: 'started', stop: 'stopped', restart: 'restarted', + remove: 'deleted', +} + +/** Verb used in failure toasts; `remove` reads better as "delete" to a user. */ +const VERB: Record = { + start: 'start', + stop: 'stop', + restart: 'restart', + remove: 'delete', } /** @@ -20,6 +30,7 @@ export function useContainerCommands( onSettled?: () => void, ) { const toast = useToast() + const confirm = useConfirm() const mutation = useContainerAction() const [busyKey, setBusyKey] = useState(null) @@ -29,6 +40,33 @@ export function useContainerCommands( return } + const name = container.name.replace(/^\//, '') + let force = false + + // Deletion is the one irreversible action, so it is gated here rather than + // in any single component: every surface routes through this function. + if (action === 'remove') { + const running = container.state === 'running' + const answer = await confirm({ + title: `Delete ${name}?`, + description: running + ? 'This container is running. Deleting it cannot be undone.' + : 'This cannot be undone.', + confirmLabel: 'Delete', + toggle: running + ? { + label: 'Force delete', + description: + 'Kill the container first. Without this, Docker refuses to delete a running container.', + } + : undefined, + }) + if (!answer.confirmed) { + return + } + force = answer.toggled + } + setBusyKey(`${container.id}:${action}`) try { const result = await mutation.mutateAsync({ @@ -36,30 +74,31 @@ export function useContainerCommands( hostId, action, containerName: container.name, + force, }) if (result.ok) { toast.push({ tone: 'success', title: `Container ${PAST_TENSE[action]}`, - description: `${container.name.replace(/^\//, '')} · ${result.message}`, + description: `${name} · ${result.message}`, }) onSettled?.() } else { toast.push({ tone: 'error', - title: `Could not ${action} container`, + title: `Could not ${VERB[action]} container`, description: result.error ?? result.message, }) } } catch (error) { toast.push({ tone: 'error', - title: `Could not ${action} container`, + title: `Could not ${VERB[action]} container`, description: error instanceof ApiError || error instanceof Error ? error.message - : `Failed to ${action} container`, + : `Failed to ${VERB[action]} container`, }) } finally { setBusyKey(null) diff --git a/apps/web/src/services/hosts.ts b/apps/web/src/services/hosts.ts index 6970d89..04c60bc 100644 --- a/apps/web/src/services/hosts.ts +++ b/apps/web/src/services/hosts.ts @@ -32,10 +32,13 @@ export function runContainerAction( containerId: string, hostId: string, action: ContainerAction, + force = false, ): Promise { return apiClient.post( `/containers/${encodeURIComponent(containerId)}/${action}`, - { hostId }, + // Only container.remove accepts `force`; the other routes reject unknown + // body properties, so it is omitted entirely for them. + action === 'remove' ? { hostId, force } : { hostId }, ) } diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 6a11755..c0dd97d 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -63,7 +63,7 @@ export type HostContainersResponse = { updatedAt: string | null } -export type ContainerAction = 'start' | 'stop' | 'restart' +export type ContainerAction = 'start' | 'stop' | 'restart' | 'remove' export type ContainerActionResult = { requestId: string diff --git a/packages/protocol/fixtures/container.remove.force.json b/packages/protocol/fixtures/container.remove.force.json new file mode 100644 index 0000000..cb0aa67 --- /dev/null +++ b/packages/protocol/fixtures/container.remove.force.json @@ -0,0 +1,8 @@ +{ + "type": "container.remove", + "payload": { + "requestId": "9f1c2a4e-7b3d-4c58-9e21-6a0d5f8b3c17", + "containerId": "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a", + "force": true + } +} diff --git a/packages/protocol/fixtures/container.remove.json b/packages/protocol/fixtures/container.remove.json new file mode 100644 index 0000000..bff6555 --- /dev/null +++ b/packages/protocol/fixtures/container.remove.json @@ -0,0 +1,8 @@ +{ + "type": "container.remove", + "payload": { + "requestId": "9f1c2a4e-7b3d-4c58-9e21-6a0d5f8b3c17", + "containerId": "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a", + "force": false + } +} diff --git a/packages/protocol/src/container.ts b/packages/protocol/src/container.ts index 51e6203..051cafd 100644 --- a/packages/protocol/src/container.ts +++ b/packages/protocol/src/container.ts @@ -12,6 +12,7 @@ export const CONTAINER_MESSAGE_TYPE = { CONTAINER_STOP: 'container.stop', CONTAINER_RESTART: 'container.restart', CONTAINER_RESULT: 'container.result', + CONTAINER_REMOVE: 'container.remove' } as const export type ContainerMessageType = @@ -25,6 +26,7 @@ export const CONTAINER_START = CONTAINER_MESSAGE_TYPE.CONTAINER_START export const CONTAINER_STOP = CONTAINER_MESSAGE_TYPE.CONTAINER_STOP export const CONTAINER_RESTART = CONTAINER_MESSAGE_TYPE.CONTAINER_RESTART export const CONTAINER_RESULT = CONTAINER_MESSAGE_TYPE.CONTAINER_RESULT +export const CONTAINER_REMOVE = CONTAINER_MESSAGE_TYPE.CONTAINER_REMOVE /** * Payload for `container.list` (Server -> Agent). @@ -99,7 +101,7 @@ export type ContainerInspect = { /** * Lifecycle actions that produce `container.result`. */ -export type ContainerAction = 'start' | 'stop' | 'restart' +export type ContainerAction = 'start' | 'stop' | 'restart' | "remove" /** * Shared command payload for container operations (Server -> Agent). @@ -109,6 +111,15 @@ export type ContainerCommandPayload = { containerId: string } +/** + * Payload for `container.remove` (Server -> Agent). + + */ +export type ContainerRemovePayload = ContainerCommandPayload & { + force?: boolean +} + + export type ContainerInspectPayload = ContainerCommandPayload export type ContainerInspectedPayload = { @@ -155,6 +166,12 @@ export type ContainerStartMessage = MessageEnvelope< ContainerCommandPayload > +export type ContainerRemoveMessage = MessageEnvelope< + typeof CONTAINER_REMOVE, + ContainerRemovePayload +> + + export type ContainerStopMessage = MessageEnvelope< typeof CONTAINER_STOP, ContainerCommandPayload @@ -179,3 +196,4 @@ export type ContainerMessage = | ContainerStopMessage | ContainerRestartMessage | ContainerResultMessage + | ContainerRemoveMessage diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 19355e3..6702e15 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -47,6 +47,8 @@ export type { ContainerMessage, ContainerMessageType, ContainerPort, + ContainerRemoveMessage, + ContainerRemovePayload, ContainerRestartMessage, ContainerResultMessage, ContainerResultPayload, @@ -60,6 +62,7 @@ export { CONTAINER_INSPECT, CONTAINER_INSPECTED, CONTAINER_LIST, + CONTAINER_REMOVE, CONTAINER_LISTED, CONTAINER_MESSAGE_TYPE, CONTAINER_RESTART,