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
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,13 @@ COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
# Public assets used at runtime
COPY --from=build /app/apps/web/public ./apps/web/public
# Copy prisma schema and migrations
COPY ./packages/db/prisma ./prisma
COPY ./packages/db/prisma.config.ts /app/apps/web/prisma.config.ts
COPY ./packages/db/prisma ./apps/web/prisma
COPY ./packages/db/prisma.config.ts ./apps/web/prisma.config.ts

# The app listens on PORT (defaults to 3000)
ENV PORT=3000
# Path to Prisma schema (copied from packages/db/prisma)
ENV PRISMA_SCHEMA_PATH=/app/prisma/schema.prisma
ENV PRISMA_SCHEMA_PATH=/app/apps/web/prisma/schema.prisma
EXPOSE 3000

# Switch to the app directory inside standalone output and run the server
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ export default function ServiceOverviewFormSwarm({
</FormItem>
)}
/>

<FormField
control={form.control}
name='command'
defaultValue={swarmService.command ?? ''}
render={({ field }) => (
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder='node index.js' {...field} />
Comment on lines +140 to +142
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command input field lacks helpful description or hint text to guide users. Consider adding a FormDescription component (similar to other form fields in the codebase) that explains:

  • The format expected (e.g., "The command to run in the container, e.g., 'node index.js' or 'python app.py'")
  • That it overrides the default CMD from the Docker image
  • How quotes and special characters are handled

This would improve the user experience and reduce potential configuration errors.

Copilot uses AI. Check for mistakes.
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end'>
<Button type={'submit'}>Save</Button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.11.4",
"version": "0.11.5",
"type": "module",
"private": true,
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const updateSwarmServiceOverview = protectedProcedure
data: {
image: input.image,
registryId: input.registryId,
command: input.command?.trim() || null,
},
});
} catch (e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SwarmService" ADD COLUMN "command" TEXT;
1 change: 1 addition & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ model SwarmService {
id String @id
service Service @relation(fields: [id], references: [id], onDelete: Cascade)
image String
command String?
replicas Int?
registry Registry? @relation(fields: [registryId], references: [id])
registryId String?
Expand Down
1 change: 1 addition & 0 deletions packages/schemas/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const serviceIdSchema = z.object({
export const updateSwarmServiceOverviewSchema = serviceIdSchema.extend({
image: z.string().optional(),
registryId: z.string().nullable().optional(),
command: z.string().optional(),
});

export const addNetworkToServiceSchema = serviceIdSchema.extend({
Expand Down
51 changes: 51 additions & 0 deletions packages/utils/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export function splitCommand(cmd: string): string[] {
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The splitCommand function lacks documentation. Add a JSDoc comment explaining:

  • The purpose of the function
  • How it handles quotes (single and double)
  • How it handles escape sequences
  • Example usage and expected output
  • Any limitations or edge cases (e.g., unclosed quotes behavior)

This is particularly important since this function implements shell-like parsing logic that may not be immediately obvious to other developers.

Copilot uses AI. Check for mistakes.
const result: string[] = [];
let current = '';
let quote: "'" | '"' | null = null;
let escaped = false;

for (let i = 0; i < cmd.length; i++) {
const c = cmd[i];

if (escaped) {
current += c;
escaped = false;
continue;
}

if (c === '\\') {
escaped = true;
continue;
}

if (quote) {
if (c === quote) {
quote = null;
} else {
current += c;
}
continue;
}

if (c === "'" || c === '"') {
quote = c;
continue;
}

if (c === ' ') {
if (current.length > 0) {
result.push(current);
current = '';
}
continue;
}

current += c;
}

if (current.length > 0) {
result.push(current);
}

return result;
}
Comment on lines +1 to +51
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The splitCommand function lacks test coverage. This is a critical utility function that parses shell commands with complex logic for handling quotes, escapes, and spaces. Edge cases that should be tested include:

  • Commands with single and double quotes
  • Escaped characters within and outside quotes
  • Multiple consecutive spaces
  • Trailing/leading spaces
  • Unclosed quotes
  • Empty strings
  • Commands with only spaces

Given that other utility functions in this package have comprehensive test coverage (e.g., backups/cron.test.ts and backups/retention.test.ts), this function should follow the same pattern with a colocated cli.test.ts file.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +51
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The splitCommand function doesn't handle unclosed quotes, which will silently consume the rest of the command string. If a user inputs a command like node "index.js (missing closing quote), the function will treat everything after the opening quote as part of a single argument, which may lead to unexpected behavior. Consider either:

  1. Throwing an error when quotes are unclosed
  2. Auto-closing quotes at the end of the string
  3. Documenting this behavior if it's intentional

This is especially important since this function is used in service deployments where malformed commands could cause deployment failures that are difficult to debug.

Copilot uses AI. Check for mistakes.
16 changes: 15 additions & 1 deletion packages/utils/src/docker/Docker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { jsonDockerRequest } from './dockerRequest';
import { dockerRequest, jsonDockerRequest } from './dockerRequest';
import { Client } from 'ssh2';
import { operations, paths } from './schema';

Expand Down Expand Up @@ -131,4 +131,18 @@ export class Docker {
)
)) as paths['/tasks']['get']['responses']['200']['content']['application/json'];
}

async inspectContainer(containerId: string) {
return (await jsonDockerRequest(
this.connection,
`/containers/${containerId}/json`
)) as paths['/containers/{id}/json']['get']['responses']['200']['content']['application/json'];
}

async rmService(serviceId: string) {
return await dockerRequest(this.connection, `/services/${serviceId}`, {
method: 'DELETE',
headers: {},
});
}
}
60 changes: 56 additions & 4 deletions packages/utils/src/docker/swarm/deploySwarmService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { components } from '../schema';
import getBase64AuthForRegistry from '../../registries/getBase64AuthForRegistry';
import { createEnvFromString } from '../common/createEnv';
import { generateVolumeName } from '../common/generateVolumeName';
import { splitCommand } from '../../cli';
import { remoteExec } from '../../interactiveRemoteCommand';
import { sh } from '../../sh';

export const deploySwarmService = async (
connection: Client,
Expand Down Expand Up @@ -98,6 +101,12 @@ export const deploySwarmService = async (
logger.info('Image : ' + service.swarmService.image);

spec.TaskTemplate!.ContainerSpec!.Image = service.swarmService.image;
if (service.swarmService.command)
spec.TaskTemplate!.ContainerSpec!.Command = splitCommand(
service.swarmService.command
);
else spec.TaskTemplate!.ContainerSpec!.Command = [];

spec.TaskTemplate!.Networks = service.networks.map((network) => ({
Target: network.name,
}));
Expand Down Expand Up @@ -126,6 +135,8 @@ export const deploySwarmService = async (
// Build Traefik labels from service domains
const labels: Record<string, string> = { ...(spec.Labels ?? {}) };

labels['app.seastack.serviceId'] = service.id;

// Remove previously generated Traefik HTTP labels to avoid stale routers/services
for (const key of Object.keys(labels)) {
if (key.startsWith('traefik.http.')) delete labels[key];
Expand Down Expand Up @@ -191,9 +202,10 @@ export const deploySwarmService = async (
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));

logger.info("Waiting for the service's tasks to be up and running");

let containerId = '';
let lastTaskId = '';
while (!isUp) {
logger.info("Waiting for the service's tasks to be up and running");
await sleep(1000);
const tasks = (
await docker.listTasks({
Expand All @@ -209,18 +221,58 @@ export const deploySwarmService = async (
break;
}

const firstTask = tasks[0]!;
isUp = firstTask.Status?.State === 'running';
if (lastTaskId) {
const task = tasks.find((t) => t.ID === lastTaskId);
if (task && task.Status && task.Status.State === 'failed') {
logger.error('The task failed - ' + task.Status.Err);
break;
}
}

const firstTask = tasks[0]!;
logger.debug(
`Task status : ${firstTask.Status?.State} - ${firstTask.UpdatedAt}`
);

if (firstTask.Status?.ContainerStatus?.ContainerID)
containerId = firstTask.Status.ContainerStatus.ContainerID;

const statuses = tasks.map((t) => JSON.stringify(t.Status)).join();
logger.debug(`Statuses : ${statuses}`);

if (containerId !== '') {
logger.debug(`Container ID : ${containerId}`);
const containerInfo =
await docker.inspectContainer(containerId);
if (containerInfo.State?.Status === 'exited') {
logger.info('Waiting for container logs');
await sleep(5000);
const command = sh`docker container logs ${containerInfo.Name?.replace('/', '')} --timestamps`;
logger.debug('Running ' + command);
const logs = await remoteExec(connection, command);
logger.info(logs);
logger.error("The container exited with status 'exited'");
break;
}
}

if (firstTask.Status?.Err) {
logger.error(firstTask.Status.Err);
break;
}

if (firstTask.Status?.State === 'running') {
isUp = true;
}

lastTaskId = firstTask.ID!;
}

if (!isUp && !isUpdate) {
logger.info('Removing service');
await docker.rmService(service.id);
}

return isUp;
} catch (e) {
if (e instanceof Error) logger.error(e.message);
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './remote-server';
export * from './backups';
export * from './sh';
export * from './getLogger';
export * from './cli';
Loading