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
45 changes: 28 additions & 17 deletions apps/agent/internal/communication/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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"`
Expand Down Expand Up @@ -382,14 +388,16 @@ func (c *Client) serve(ctx context.Context, conn *websocket.Conn) error {
logger.Warn("host metrics not sent", "error", err.Error())
}
}


}
}

func (c *Client) handleServerMessage(ctx context.Context, conn *websocket.Conn, env Envelope) error {
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)
Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -650,6 +659,8 @@ func actionFromType(msgType string) string {
return "stop"
case TypeContainerRestart:
return "restart"
case TypeContainerRemove:
return "remove"
default:
return "unknown"
}
Expand Down
65 changes: 65 additions & 0 deletions apps/agent/internal/communication/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions apps/agent/internal/docker/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
17 changes: 12 additions & 5 deletions apps/server/src/agents/agents.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
CONTAINER_RESTART,
CONTAINER_RESULT,
CONTAINER_START,
CONTAINER_REMOVE,
CONTAINER_STOP,
LOGS_CHUNK,
LOGS_SUBSCRIBE,
Expand Down Expand Up @@ -75,6 +76,7 @@ const COMMAND_TYPE: Record<ContainerAction, string> = {
start: CONTAINER_START,
stop: CONTAINER_STOP,
restart: CONTAINER_RESTART,
remove: CONTAINER_REMOVE,
};

@WebSocketGateway({ path: '/agents' })
Expand Down Expand Up @@ -173,6 +175,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect {
hostId: string,
action: ContainerAction,
containerId: string,
force = false,
timeoutMs = 45_000,
): Promise<ContainerResultPayload> {
this.inventory.rememberHost(hostId, agentUuid);
Expand Down Expand Up @@ -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}` : ''}`,
);
});
}
Expand Down
34 changes: 31 additions & 3 deletions apps/server/src/containers/containers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 {
Expand Down Expand Up @@ -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)',
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/containers/containers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class ContainersService {
hostId: string,
containerId: string,
action: ContainerAction,
force = false,
): Promise<ContainerResultPayload> {
if (!hostId?.trim()) {
throw new Error('hostId is required');
Expand All @@ -37,6 +38,7 @@ export class ContainersService {
agent.id,
action,
containerId,
force,
);
}

Expand Down
6 changes: 1 addition & 5 deletions apps/server/src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
Controller,
Get,
ServiceUnavailableException,
} from '@nestjs/common';
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
import {
ApiOkResponse,
ApiOperation,
Expand Down
4 changes: 1 addition & 3 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('CORS_ORIGINS');
if (configured != null && configured.trim() !== '') {
return configured
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -19,7 +20,9 @@ type AppProvidersProps = {
export function AppProviders({ children }: AppProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
<ToastProvider>
<ConfirmProvider>{children}</ConfirmProvider>
</ToastProvider>
</QueryClientProvider>
)
}
Loading
Loading