diff --git a/Dockerfile b/Dockerfile index ee90a0e..5eff054 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/apps/web/app/dashboard/services/[serviceId]/components/tabs/overview/service-overview-form-swarm.tsx b/apps/web/app/dashboard/services/[serviceId]/components/tabs/overview/service-overview-form-swarm.tsx index 4b32357..7aeba82 100644 --- a/apps/web/app/dashboard/services/[serviceId]/components/tabs/overview/service-overview-form-swarm.tsx +++ b/apps/web/app/dashboard/services/[serviceId]/components/tabs/overview/service-overview-form-swarm.tsx @@ -130,6 +130,21 @@ export default function ServiceOverviewFormSwarm({ )} /> + + ( + + Command + + + + + + )} + />
diff --git a/apps/web/package.json b/apps/web/package.json index bcec6f9..0cec4ff 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.11.4", + "version": "0.11.5", "type": "module", "private": true, "scripts": { diff --git a/packages/api/src/routers/services/updateSwarmServiceOverview.ts b/packages/api/src/routers/services/updateSwarmServiceOverview.ts index 47863c7..17c125c 100644 --- a/packages/api/src/routers/services/updateSwarmServiceOverview.ts +++ b/packages/api/src/routers/services/updateSwarmServiceOverview.ts @@ -42,6 +42,7 @@ export const updateSwarmServiceOverview = protectedProcedure data: { image: input.image, registryId: input.registryId, + command: input.command?.trim() || null, }, }); } catch (e) { diff --git a/packages/db/prisma/migrations/20251210015009_add_command_on_swarm_service/migration.sql b/packages/db/prisma/migrations/20251210015009_add_command_on_swarm_service/migration.sql new file mode 100644 index 0000000..96a15b8 --- /dev/null +++ b/packages/db/prisma/migrations/20251210015009_add_command_on_swarm_service/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "SwarmService" ADD COLUMN "command" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index d3bc8f4..eef2fa7 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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? diff --git a/packages/schemas/src/services.ts b/packages/schemas/src/services.ts index a4f8e40..fe10f13 100644 --- a/packages/schemas/src/services.ts +++ b/packages/schemas/src/services.ts @@ -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({ diff --git a/packages/utils/src/cli.ts b/packages/utils/src/cli.ts new file mode 100644 index 0000000..f399807 --- /dev/null +++ b/packages/utils/src/cli.ts @@ -0,0 +1,51 @@ +export function splitCommand(cmd: string): string[] { + 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; +} diff --git a/packages/utils/src/docker/Docker.ts b/packages/utils/src/docker/Docker.ts index f36ca25..08d72d5 100644 --- a/packages/utils/src/docker/Docker.ts +++ b/packages/utils/src/docker/Docker.ts @@ -1,4 +1,4 @@ -import { jsonDockerRequest } from './dockerRequest'; +import { dockerRequest, jsonDockerRequest } from './dockerRequest'; import { Client } from 'ssh2'; import { operations, paths } from './schema'; @@ -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: {}, + }); + } } diff --git a/packages/utils/src/docker/swarm/deploySwarmService.ts b/packages/utils/src/docker/swarm/deploySwarmService.ts index 204ac62..e89ef83 100644 --- a/packages/utils/src/docker/swarm/deploySwarmService.ts +++ b/packages/utils/src/docker/swarm/deploySwarmService.ts @@ -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, @@ -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, })); @@ -126,6 +135,8 @@ export const deploySwarmService = async ( // Build Traefik labels from service domains const labels: Record = { ...(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]; @@ -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({ @@ -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); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7b52fd7..a9ad28f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -13,3 +13,4 @@ export * from './remote-server'; export * from './backups'; export * from './sh'; export * from './getLogger'; +export * from './cli';