Skip to content

Commit 11599ab

Browse files
authored
feat: add command field to Swarm services and improve service deployments (#56)
* feat: add `command` field to Swarm services and improve service deployment - Introduced optional `command` field in Swarm service schema and API procedures. - Added schema migrations to include `command` column in the database. - Implemented `splitCommand` utility for parsing shell commands. - Updated service deployment logic to handle `command` field, passing parsed commands to the Swarm TaskTemplate. - Enhanced service overview form to allow specifying a command for deployment. * feat: enhance swarm service deployment with container inspection and failure handling - Added container inspection in `deploySwarmService` for better debugging on task failure. - Improved service task monitoring by logging detailed container statuses and logs. - Added support for removing services upon deployment failure when not updating. - Enhanced error handling in `updateSwarmServiceOverview` by simplifying the command assignment logic. - Introduced `inspectContainer` and `rmService` utilities in Docker module. * feat: add getContainerLogs to services router * fix: correct Prisma paths in Dockerfile for apps/web - Updated Prisma schema and config paths to align with the apps/web directory structure. - Adjusted `PRISMA_SCHEMA_PATH` environment variable accordingly. * chore: bump apps/web version to 0.11.5
1 parent 1b27500 commit 11599ab

11 files changed

Lines changed: 147 additions & 9 deletions

File tree

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,13 @@ COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
8686
# Public assets used at runtime
8787
COPY --from=build /app/apps/web/public ./apps/web/public
8888
# Copy prisma schema and migrations
89-
COPY ./packages/db/prisma ./prisma
90-
COPY ./packages/db/prisma.config.ts /app/apps/web/prisma.config.ts
89+
COPY ./packages/db/prisma ./apps/web/prisma
90+
COPY ./packages/db/prisma.config.ts ./apps/web/prisma.config.ts
9191

9292
# The app listens on PORT (defaults to 3000)
9393
ENV PORT=3000
9494
# Path to Prisma schema (copied from packages/db/prisma)
95-
ENV PRISMA_SCHEMA_PATH=/app/prisma/schema.prisma
95+
ENV PRISMA_SCHEMA_PATH=/app/apps/web/prisma/schema.prisma
9696
EXPOSE 3000
9797

9898
# Switch to the app directory inside standalone output and run the server

apps/web/app/dashboard/services/[serviceId]/components/tabs/overview/service-overview-form-swarm.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,21 @@ export default function ServiceOverviewFormSwarm({
130130
</FormItem>
131131
)}
132132
/>
133+
134+
<FormField
135+
control={form.control}
136+
name='command'
137+
defaultValue={swarmService.command ?? ''}
138+
render={({ field }) => (
139+
<FormItem>
140+
<FormLabel>Command</FormLabel>
141+
<FormControl>
142+
<Input placeholder='node index.js' {...field} />
143+
</FormControl>
144+
<FormMessage />
145+
</FormItem>
146+
)}
147+
/>
133148
<div className='flex justify-end'>
134149
<Button type={'submit'}>Save</Button>
135150
</div>

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "web",
3-
"version": "0.11.4",
3+
"version": "0.11.5",
44
"type": "module",
55
"private": true,
66
"scripts": {

packages/api/src/routers/services/updateSwarmServiceOverview.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const updateSwarmServiceOverview = protectedProcedure
4242
data: {
4343
image: input.image,
4444
registryId: input.registryId,
45+
command: input.command?.trim() || null,
4546
},
4647
});
4748
} catch (e) {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "SwarmService" ADD COLUMN "command" TEXT;

packages/db/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ model SwarmService {
220220
id String @id
221221
service Service @relation(fields: [id], references: [id], onDelete: Cascade)
222222
image String
223+
command String?
223224
replicas Int?
224225
registry Registry? @relation(fields: [registryId], references: [id])
225226
registryId String?

packages/schemas/src/services.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const serviceIdSchema = z.object({
1515
export const updateSwarmServiceOverviewSchema = serviceIdSchema.extend({
1616
image: z.string().optional(),
1717
registryId: z.string().nullable().optional(),
18+
command: z.string().optional(),
1819
});
1920

2021
export const addNetworkToServiceSchema = serviceIdSchema.extend({

packages/utils/src/cli.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
export function splitCommand(cmd: string): string[] {
2+
const result: string[] = [];
3+
let current = '';
4+
let quote: "'" | '"' | null = null;
5+
let escaped = false;
6+
7+
for (let i = 0; i < cmd.length; i++) {
8+
const c = cmd[i];
9+
10+
if (escaped) {
11+
current += c;
12+
escaped = false;
13+
continue;
14+
}
15+
16+
if (c === '\\') {
17+
escaped = true;
18+
continue;
19+
}
20+
21+
if (quote) {
22+
if (c === quote) {
23+
quote = null;
24+
} else {
25+
current += c;
26+
}
27+
continue;
28+
}
29+
30+
if (c === "'" || c === '"') {
31+
quote = c;
32+
continue;
33+
}
34+
35+
if (c === ' ') {
36+
if (current.length > 0) {
37+
result.push(current);
38+
current = '';
39+
}
40+
continue;
41+
}
42+
43+
current += c;
44+
}
45+
46+
if (current.length > 0) {
47+
result.push(current);
48+
}
49+
50+
return result;
51+
}

packages/utils/src/docker/Docker.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { jsonDockerRequest } from './dockerRequest';
1+
import { dockerRequest, jsonDockerRequest } from './dockerRequest';
22
import { Client } from 'ssh2';
33
import { operations, paths } from './schema';
44

@@ -131,4 +131,18 @@ export class Docker {
131131
)
132132
)) as paths['/tasks']['get']['responses']['200']['content']['application/json'];
133133
}
134+
135+
async inspectContainer(containerId: string) {
136+
return (await jsonDockerRequest(
137+
this.connection,
138+
`/containers/${containerId}/json`
139+
)) as paths['/containers/{id}/json']['get']['responses']['200']['content']['application/json'];
140+
}
141+
142+
async rmService(serviceId: string) {
143+
return await dockerRequest(this.connection, `/services/${serviceId}`, {
144+
method: 'DELETE',
145+
headers: {},
146+
});
147+
}
134148
}

packages/utils/src/docker/swarm/deploySwarmService.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { components } from '../schema';
99
import getBase64AuthForRegistry from '../../registries/getBase64AuthForRegistry';
1010
import { createEnvFromString } from '../common/createEnv';
1111
import { generateVolumeName } from '../common/generateVolumeName';
12+
import { splitCommand } from '../../cli';
13+
import { remoteExec } from '../../interactiveRemoteCommand';
14+
import { sh } from '../../sh';
1215

1316
export const deploySwarmService = async (
1417
connection: Client,
@@ -98,6 +101,12 @@ export const deploySwarmService = async (
98101
logger.info('Image : ' + service.swarmService.image);
99102

100103
spec.TaskTemplate!.ContainerSpec!.Image = service.swarmService.image;
104+
if (service.swarmService.command)
105+
spec.TaskTemplate!.ContainerSpec!.Command = splitCommand(
106+
service.swarmService.command
107+
);
108+
else spec.TaskTemplate!.ContainerSpec!.Command = [];
109+
101110
spec.TaskTemplate!.Networks = service.networks.map((network) => ({
102111
Target: network.name,
103112
}));
@@ -126,6 +135,8 @@ export const deploySwarmService = async (
126135
// Build Traefik labels from service domains
127136
const labels: Record<string, string> = { ...(spec.Labels ?? {}) };
128137

138+
labels['app.seastack.serviceId'] = service.id;
139+
129140
// Remove previously generated Traefik HTTP labels to avoid stale routers/services
130141
for (const key of Object.keys(labels)) {
131142
if (key.startsWith('traefik.http.')) delete labels[key];
@@ -191,9 +202,10 @@ export const deploySwarmService = async (
191202
const sleep = (ms: number) =>
192203
new Promise((resolve) => setTimeout(resolve, ms));
193204

194-
logger.info("Waiting for the service's tasks to be up and running");
195-
205+
let containerId = '';
206+
let lastTaskId = '';
196207
while (!isUp) {
208+
logger.info("Waiting for the service's tasks to be up and running");
197209
await sleep(1000);
198210
const tasks = (
199211
await docker.listTasks({
@@ -209,18 +221,58 @@ export const deploySwarmService = async (
209221
break;
210222
}
211223

212-
const firstTask = tasks[0]!;
213-
isUp = firstTask.Status?.State === 'running';
224+
if (lastTaskId) {
225+
const task = tasks.find((t) => t.ID === lastTaskId);
226+
if (task && task.Status && task.Status.State === 'failed') {
227+
logger.error('The task failed - ' + task.Status.Err);
228+
break;
229+
}
230+
}
214231

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

237+
if (firstTask.Status?.ContainerStatus?.ContainerID)
238+
containerId = firstTask.Status.ContainerStatus.ContainerID;
239+
240+
const statuses = tasks.map((t) => JSON.stringify(t.Status)).join();
241+
logger.debug(`Statuses : ${statuses}`);
242+
243+
if (containerId !== '') {
244+
logger.debug(`Container ID : ${containerId}`);
245+
const containerInfo =
246+
await docker.inspectContainer(containerId);
247+
if (containerInfo.State?.Status === 'exited') {
248+
logger.info('Waiting for container logs');
249+
await sleep(5000);
250+
const command = sh`docker container logs ${containerInfo.Name?.replace('/', '')} --timestamps`;
251+
logger.debug('Running ' + command);
252+
const logs = await remoteExec(connection, command);
253+
logger.info(logs);
254+
logger.error("The container exited with status 'exited'");
255+
break;
256+
}
257+
}
258+
219259
if (firstTask.Status?.Err) {
220260
logger.error(firstTask.Status.Err);
221261
break;
222262
}
263+
264+
if (firstTask.Status?.State === 'running') {
265+
isUp = true;
266+
}
267+
268+
lastTaskId = firstTask.ID!;
269+
}
270+
271+
if (!isUp && !isUpdate) {
272+
logger.info('Removing service');
273+
await docker.rmService(service.id);
223274
}
275+
224276
return isUp;
225277
} catch (e) {
226278
if (e instanceof Error) logger.error(e.message);

0 commit comments

Comments
 (0)