Skip to content

Commit dd50bad

Browse files
committed
feat: allow deploying single services
1 parent 51ce324 commit dd50bad

3 files changed

Lines changed: 85 additions & 6 deletions

File tree

cli/src/commands/deploy.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ async function execute(ctx: DeployContext): Promise<void> {
266266
let deployFailed = false;
267267
let stackDeployed = false;
268268
let uploadPlan: UploadRollbackPlan | null = null;
269+
let previousSymlink: string | null = null;
269270
let auditMessage = `Deploy ${ctx.deployVersion} to ${ctx.env}`;
270271

271272
try {
@@ -277,14 +278,29 @@ async function execute(ctx: DeployContext): Promise<void> {
277278

278279
uploadPlan = await uploadFiles(ctx);
279280

280-
await Promise.all([
281-
ctx.releases.createRelease(ctx.stackName, ctx.deployVersion, Compose.serialize(compose), {
281+
// When --only targets specific services, build the release from the local
282+
// compose (preserves new services and config changes) but borrow image tags
283+
// from the server release for non-targeted services so the release reflects
284+
// what is actually running for those services.
285+
// Falls back to the local compose as-is if no release exists yet.
286+
let releaseCompose = compose;
287+
if (ctx.options.only) {
288+
const currentContent = await ctx.releases.getCurrentComposeContent(ctx.stackName);
289+
if (currentContent) {
290+
const filter = ctx.options.only.split(',').map((s: string) => s.trim());
291+
releaseCompose = Compose.syncNonTargetedImageTags(compose, Compose.loadFromString(currentContent), filter);
292+
}
293+
}
294+
295+
const [releaseResult] = await Promise.all([
296+
ctx.releases.createRelease(ctx.stackName, ctx.deployVersion, Compose.serialize(releaseCompose), {
282297
project_name: ctx.config.project_name, version: ctx.deployVersion, env: ctx.env,
283298
timestamp: new Date().toISOString(), epoch: Math.floor(Date.now() / 1000),
284299
performer: getPerformer(), branch: ctx.branchName,
285300
}),
286301
deployAccessories(ctx),
287302
]);
303+
previousSymlink = releaseResult.previousSymlink;
288304

289305
await deployApp(ctx, compose);
290306
stackDeployed = ctx.deployApp !== false;
@@ -297,7 +313,7 @@ async function execute(ctx: DeployContext): Promise<void> {
297313
} catch (err) {
298314
deployFailed = true;
299315
auditMessage = `Deploy ${ctx.deployVersion} to ${ctx.env} failed: ${err instanceof Error ? err.message : String(err)}`;
300-
await ctx.releases.removeRelease(ctx.stackName, ctx.deployVersion).catch(() => {});
316+
await ctx.releases.removeRelease(ctx.stackName, ctx.deployVersion, previousSymlink).catch(() => {});
301317

302318
if (uploadPlan) {
303319
await rollbackUploads(uploadPlan).catch((e) => printWarning(`Upload rollback failed: ${e instanceof Error ? e.message : String(e)}`));

cli/src/services/compose.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,34 @@ export function filterServices(compose: ParsedCompose, filter: string[]): Parsed
563563
};
564564
}
565565

566+
/**
567+
* Build a release compose for a partial deploy (--only).
568+
*
569+
* Starts from the local compose (preserves new services, config changes), then
570+
* for services NOT being deployed that also exist in the server release, replaces
571+
* their image tag with the one from the server so the release reflects what is
572+
* actually running for those services.
573+
*
574+
* Services only in local (new, not yet on server) → keep local image tag.
575+
* Services only in server (removed locally) → absent from release (correct).
576+
*/
577+
export function syncNonTargetedImageTags(
578+
local: ParsedCompose,
579+
server: ParsedCompose,
580+
targetedServices: string[],
581+
): ParsedCompose {
582+
const targeted = new Set(targetedServices);
583+
const services = { ...local.services };
584+
585+
for (const [name, svc] of Object.entries(services)) {
586+
if (targeted.has(name)) continue;
587+
const serverImage = server.services[name]?.image as string | undefined;
588+
if (serverImage) services[name] = { ...svc, image: serverImage };
589+
}
590+
591+
return { ...local, services };
592+
}
593+
566594
/**
567595
* Extract all external network names from a compose object.
568596
*/

cli/src/services/release.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,15 @@ export class Release {
5555
version: string,
5656
composeYaml: string,
5757
metadata: ReleaseMetadata,
58-
): Promise<void> {
58+
): Promise<{ previousSymlink: string | null }> {
5959
const dir = this.releaseDir(stackName, version);
6060
const metaJson = JSON.stringify(metadata, null, 2);
6161
const stackDir = this.stackDir(stackName);
6262

63+
// Read previous symlink target before overwriting — used to restore on failure
64+
const prevResult = await sshExec(this.connection, `readlink "${stackDir}/current" 2>/dev/null || echo ""`);
65+
const previousSymlink = prevResult.stdout.trim() || null;
66+
6367
await sshExec(this.connection, `mkdir -p "${dir}"`);
6468

6569
// Write compose and metadata via stdin (no shell escaping needed)
@@ -74,6 +78,19 @@ export class Release {
7478
await sshExec(this.connection, `ln -sfn "${dir}" "${stackDir}/current"`);
7579

7680
printDebug(`Release ${version} created at ${dir}`);
81+
return { previousSymlink };
82+
}
83+
84+
/**
85+
* Read the compose file from the current release symlink.
86+
* Returns null if no release exists yet.
87+
*/
88+
async getCurrentComposeContent(stackName: string): Promise<string | null> {
89+
const result = await sshExec(
90+
this.connection,
91+
`cat "${this.stackDir(stackName)}/current/docker-compose.yml" 2>/dev/null`,
92+
);
93+
return result.exitCode === 0 && result.stdout.trim() ? result.stdout : null;
7794
}
7895

7996
/**
@@ -186,10 +203,28 @@ export class Release {
186203

187204
/**
188205
* Remove a single release directory.
206+
* If restoreTo is provided and the `current` symlink points to this version,
207+
* restores it to the given target (or removes the symlink if restoreTo is null).
189208
*/
190-
async removeRelease(stackName: string, version: string): Promise<void> {
209+
async removeRelease(stackName: string, version: string, restoreTo?: string | null): Promise<void> {
191210
const dir = this.releaseDir(stackName, version);
192-
await sshExec(this.connection, `rm -rf "${dir}"`);
211+
const stackDir = this.stackDir(stackName);
212+
213+
if (restoreTo !== undefined) {
214+
await sshExec(
215+
this.connection,
216+
`currentTarget=$(readlink "${stackDir}/current" 2>/dev/null); ` +
217+
`rm -rf "${dir}"; ` +
218+
`if [ "$currentTarget" = "${dir}" ]; then ` +
219+
(restoreTo
220+
? `ln -sfn "${restoreTo}" "${stackDir}/current"; `
221+
: `rm -f "${stackDir}/current"; `) +
222+
`fi`,
223+
);
224+
} else {
225+
await sshExec(this.connection, `rm -rf "${dir}"`);
226+
}
227+
193228
printDebug(`Removed release ${version}`);
194229
}
195230

0 commit comments

Comments
 (0)