Skip to content

Commit 8e3e6e6

Browse files
feat(stack): warn about container deletion during stack deploy (#1755)
## Summary - Add warning when `mw stack deploy` would delete existing containers not in the new stack definition - Prompt for interactive confirmation in TTY mode before proceeding with deletion - Add `--force` flag to skip confirmation prompt - Refactor `exec` function into smaller, focused methods for better readability Closes #1400 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: martin-helmich <2538958+martin-helmich@users.noreply.github.com>
1 parent dd94ae1 commit 8e3e6e6

3 files changed

Lines changed: 138 additions & 51 deletions

File tree

docs/stack.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,12 @@ Deploys a docker-compose compatible file to a mittwald container stack
5151

5252
```
5353
USAGE
54-
$ mw stack deploy [--token <value>] [-s <value>] [-q] [-c <value> | --from-template <value>] [--env-file <value>]
54+
$ mw stack deploy [--token <value>] [-s <value>] [-q] [-c <value> | --from-template <value>] [--env-file
55+
<value>] [-f]
5556
5657
FLAGS
5758
-c, --compose-file=<value> [default: ./docker-compose.yml] path to a compose file, or "-" to read from stdin
59+
-f, --force do not ask for confirmation when containers will be deleted
5860
-q, --quiet suppress process output and only display a machine-readable summary
5961
-s, --stack-id=<value> ID of a stack; this flag is optional if a default stack is set in the context
6062
--env-file=<value> [default: ./.env] alternative path to file with environment variables
@@ -249,10 +251,12 @@ Deploys a docker-compose compatible file to a mittwald container stack
249251

250252
```
251253
USAGE
252-
$ mw stack up [--token <value>] [-s <value>] [-q] [-c <value> | --from-template <value>] [--env-file <value>]
254+
$ mw stack up [--token <value>] [-s <value>] [-q] [-c <value> | --from-template <value>] [--env-file
255+
<value>] [-f]
253256
254257
FLAGS
255258
-c, --compose-file=<value> [default: ./docker-compose.yml] path to a compose file, or "-" to read from stdin
259+
-f, --force do not ask for confirmation when containers will be deleted
256260
-q, --quiet suppress process output and only display a machine-readable summary
257261
-s, --stack-id=<value> ID of a stack; this flag is optional if a default stack is set in the context
258262
--env-file=<value> [default: ./.env] alternative path to file with environment variables

src/commands/stack/deploy.tsx

Lines changed: 131 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js";
22
import { stackFlags, withStackId } from "../../lib/resources/stack/flags.js";
33
import { ReactNode } from "react";
4-
import { Flags } from "@oclif/core";
4+
import { Flags, ux } from "@oclif/core";
55
import {
66
makeProcessRenderer,
77
processFlags,
@@ -19,13 +19,13 @@ import {
1919
import { sanitizeStackDefinition } from "../../lib/resources/stack/sanitize.js";
2020
import { enrichStackDefinition } from "../../lib/resources/stack/enrich.js";
2121
import { Success } from "../../rendering/react/components/Success.js";
22-
import { Value } from "../../rendering/react/components/Value.js";
2322
import { loadStackFromTemplate } from "../../lib/resources/stack/template-loader.js";
2423
import { parseEnvironmentVariablesFromStr } from "../../lib/util/parser.js";
2524
import { RawStackInput } from "../../lib/resources/stack/types.js";
2625

2726
interface DeployResult {
2827
restartedServices: string[];
28+
deletedServices: string[];
2929
}
3030

3131
type StackRequest =
@@ -65,8 +65,106 @@ This flag is mutually exclusive with --compose-file.`,
6565
summary: "alternative path to file with environment variables",
6666
default: "./.env",
6767
}),
68+
force: Flags.boolean({
69+
char: "f",
70+
summary: "do not ask for confirmation when containers will be deleted",
71+
}),
6872
};
6973

74+
private findServicesToDelete(
75+
existingStack: ContainerStackResponse,
76+
newStackDefinition: RawStackInput,
77+
): string[] {
78+
const existingServiceNames = (existingStack.services ?? []).map(
79+
(s) => s.serviceName,
80+
);
81+
const newServiceNames = Object.keys(newStackDefinition.services ?? {});
82+
83+
return existingServiceNames.filter(
84+
(name) => !newServiceNames.includes(name),
85+
);
86+
}
87+
88+
private async getExistingStack(
89+
stackId: string,
90+
renderer: ReturnType<typeof makeProcessRenderer>,
91+
): Promise<ContainerStackResponse> {
92+
return renderer.runStep("retrieving current stack state", async () => {
93+
const resp = await this.apiClient.container.getStack({ stackId });
94+
assertStatus(resp, 200);
95+
return resp.data;
96+
});
97+
}
98+
99+
private async confirmDeletion(
100+
servicesToDelete: string[],
101+
renderer: ReturnType<typeof makeProcessRenderer>,
102+
): Promise<boolean> {
103+
if (servicesToDelete.length === 0) {
104+
return true;
105+
}
106+
107+
renderer.addInfo(
108+
`the following containers will be deleted: ${servicesToDelete.join(", ")}`,
109+
);
110+
111+
if (this.flags.force) {
112+
return true;
113+
}
114+
115+
const confirmed = await renderer.addConfirmation(
116+
"do you want to continue and delete these containers?",
117+
);
118+
119+
if (!confirmed) {
120+
await renderer.error("deployment cancelled by user");
121+
ux.exit(1);
122+
}
123+
124+
return confirmed;
125+
}
126+
127+
private async deployStack(
128+
stackId: string,
129+
stackDefinition: RawStackInput,
130+
renderer: ReturnType<typeof makeProcessRenderer>,
131+
): Promise<ContainerStackResponse> {
132+
return renderer.runStep("deploying stack", async () => {
133+
const resp = await this.apiClient.container.declareStack({
134+
stackId,
135+
data: stackDefinition as StackRequest,
136+
});
137+
assertStatus(resp, 200);
138+
return resp.data;
139+
});
140+
}
141+
142+
private async recreateServices(
143+
stackId: string,
144+
declaredStack: ContainerStackResponse,
145+
renderer: ReturnType<typeof makeProcessRenderer>,
146+
): Promise<string[]> {
147+
const restartedServices: string[] = [];
148+
149+
for (const service of declaredStack.services ?? []) {
150+
if (service.requiresRecreate) {
151+
await renderer.runStep(
152+
`recreating service ${service.serviceName}`,
153+
async () => {
154+
const resp = await this.apiClient.container.recreateService({
155+
stackId,
156+
serviceId: service.id,
157+
});
158+
assertSuccess(resp);
159+
restartedServices.push(service.serviceName);
160+
},
161+
);
162+
}
163+
}
164+
165+
return restartedServices;
166+
}
167+
70168
private async loadStackDefinition(
71169
source: { template: string } | { composeFile: string },
72170
envFile: string,
@@ -122,20 +220,13 @@ This flag is mutually exclusive with --compose-file.`,
122220
} = this.flags;
123221
const r = makeProcessRenderer(this.flags, "Deploying container stack");
124222

125-
const existingStack = await r.runStep(
126-
"retrieving current stack state",
127-
async () => {
128-
const resp = await this.apiClient.container.getStack({ stackId });
129-
assertStatus(resp, 200);
130-
131-
return resp.data;
132-
},
133-
);
134-
135-
const result: DeployResult = { restartedServices: [] };
223+
const existingStack = await this.getExistingStack(stackId, r);
224+
const stackSource = fromTemplate
225+
? { template: fromTemplate }
226+
: { composeFile };
136227

137228
let stackDefinition = await this.loadStackDefinition(
138-
fromTemplate ? { template: fromTemplate } : { composeFile },
229+
stackSource,
139230
envFile,
140231
existingStack,
141232
r,
@@ -146,46 +237,38 @@ This flag is mutually exclusive with --compose-file.`,
146237
enrichStackDefinition(stackDefinition),
147238
);
148239

149-
const declaredStack = await r.runStep("deploying stack", async () => {
150-
const resp = await this.apiClient.container.declareStack({
151-
stackId,
152-
data: stackDefinition as StackRequest,
153-
});
154-
155-
assertStatus(resp, 200);
156-
return resp.data;
157-
});
158-
159-
for (const service of declaredStack.services ?? []) {
160-
if (service.requiresRecreate) {
161-
await r.runStep(
162-
`recreating service ${service.serviceName}`,
163-
async () => {
164-
const resp = await this.apiClient.container.recreateService({
165-
stackId,
166-
serviceId: service.id,
167-
});
168-
assertSuccess(resp);
169-
result.restartedServices.push(service.serviceName);
170-
},
171-
);
172-
}
240+
const servicesToDelete = this.findServicesToDelete(
241+
existingStack,
242+
stackDefinition,
243+
);
244+
const confirmed = await this.confirmDeletion(servicesToDelete, r);
245+
if (!confirmed) {
246+
return { restartedServices: [], deletedServices: [] };
173247
}
174248

175-
return result;
176-
}
249+
const declaredStack = await this.deployStack(stackId, stackDefinition, r);
250+
const restartedServices = await this.recreateServices(
251+
stackId,
252+
declaredStack,
253+
r,
254+
);
177255

178-
protected render({ restartedServices }: DeployResult): ReactNode {
179-
if (restartedServices.length === 0) {
180-
return (
181-
<Success>Deployment successful. No services were restarted.</Success>
182-
);
183-
}
256+
return { restartedServices, deletedServices: servicesToDelete };
257+
}
184258

259+
protected render({
260+
restartedServices,
261+
deletedServices,
262+
}: DeployResult): ReactNode {
185263
return (
186264
<Success>
187-
Deployment successful. The following services were restarted:{" "}
188-
<Value>{restartedServices.join(", ")}</Value>
265+
Deployment successful.{" "}
266+
{restartedServices.length > 0
267+
? `The following services were restarted: ${restartedServices.join(", ")}`
268+
: "No services were restarted."}{" "}
269+
{deletedServices.length > 0
270+
? `The following services were deleted: ${deletedServices.join(", ")}`
271+
: "No services were deleted."}
189272
</Success>
190273
);
191274
}

src/rendering/process/components/ProcessStateIcon.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const ProcessStateIcon: React.FC<{ step: ProcessStep }> = ({ step }) => {
1010
step.type === "input" ||
1111
step.type === "select"
1212
) {
13-
return <Text></Text>;
13+
return <Text> </Text>;
1414
} else if (step.phase === "completed") {
1515
return <Text></Text>;
1616
} else if (step.phase === "aborted") {

0 commit comments

Comments
 (0)