Skip to content

Commit 2a42b02

Browse files
feat: support non-interactive appsmithctl restore for automation (#42147)
## Description Adds a non-interactive mode to `appsmithctl restore` so instance restores can run unattended in CI/CD pipelines — requested by a customer for automated disaster recovery. Linear: https://linear.app/appsmith/issue/APP-15482 New surface: ```bash appsmithctl restore --backup-file=<name> [--non-interactive] [--force] ``` - `--backup-file=<name>` — selects a backup by file name (as shown in the interactive listing), skipping the index prompt. Values containing path separators are rejected; the name must match an actual archive in the backup directory. - `--non-interactive` — suppresses every prompt (parity with `appsmithctl backup`). Any input that would have been prompted for must come from a flag or env var, otherwise the command exits 1 with a message naming what is missing — **before** services are stopped or the database is touched. - `--force` — proceeds despite an Appsmith version mismatch between backup and instance (non-interactive mode only; the interactive Enter-to-continue prompt is unchanged). It bypasses only the version gate — missing/wrong passwords, unknown file names, and missing encryption keys still exit 1. - `APPSMITH_BACKUP_ARCHIVE_PASSWORD` — supplies the archive decryption password (single attempt). The password reaches openssl via the child process environment (`-pass env:`), never argv, so it is not visible in the process table. Verified compatible with archives encrypted by the existing backup flow (`-k`), via a real openssl round-trip. - Unencrypted archives restored non-interactively use the instance's existing `APPSMITH_ENCRYPTION_PASSWORD`/`APPSMITH_ENCRYPTION_SALT`; both are validated up-front (`ensureEncryptionKeysPresent`, exported and unit-tested), and the check runs again defensively at the point of use in `restoreDockerEnvFile`. Hardening/fixes riding with the feature (same concern — the new env var and the restore flow): - A failed decryption used to exit **0**; it now exits 1. - `APPSMITH_BACKUP_ARCHIVE_PASSWORD=` is stripped from the `docker.env` bundled into future backup archives (`removeSensitiveEnvData`), so an operator who wrongly persists the transient secret does not leak it into archives. The `=` suffix keeps `APPSMITH_BACKUP_ARCHIVE_LIMIT` intact. - `run()`'s failure path now logs to stderr. ## Impact on existing instances - **Fresh install**: no change; all new behavior is opt-in via flags/env var. - **Upgrade from default**: no change to interactive restore, with one exception — if `APPSMITH_BACKUP_ARCHIVE_PASSWORD` is set in the environment, the interactive password prompt is skipped and the env value is used (single attempt). - **Upgrade from customized**: scripts that (incorrectly) relied on exit code 0 from a failed decryption will now see exit 1 — this was a bug fix; a failed restore should never report success. - **Rollback**: older images silently ignore the new flags (`restore` never rejected unknown args), so a pipeline built on `--non-interactive` will **hang at an interactive prompt** rather than fail loudly. Pipelines must pin an image version at or above this release. ## Deliberate scope decisions - `APPSMITH_BACKUP_ARCHIVE_PASSWORD` is intentionally **not** added to `.env.example`, Helm values, or the Heroku README: it is a per-invocation CI secret, not instance configuration. Persisting it in `docker.env` is exactly the mistake the new strip-list entry guards against. It should be supplied ephemerally, e.g. `docker exec -e APPSMITH_BACKUP_ARCHIVE_PASSWORD=... <container> appsmithctl restore ...`. - `--force` without `--non-interactive` is a no-op (the interactive version prompt still appears). Unattended use requires `--non-interactive`. - Non-interactive restore assumes same-instance encryption keys; restoring another instance's backup requires exporting that instance's `APPSMITH_ENCRYPTION_PASSWORD`/`SALT` into the environment first. - Restore is fail-fast, not atomic: all validation happens before any mutation, but a `mongorestore` failure mid-run still leaves a partially restored instance (pre-existing behavior, unchanged). - Restore-by-index and a positional `restore <file>` form are omitted (the issue allows "CLI flags and/or environment variables"; an index is racy in automation). - Follow-ups tracked separately: backup-side `encryptBackupArchive` still passes its password on the openssl argv (pre-existing, same fix pattern applies); non-interactive `appsmithctl backup` never encrypts, so a fully automated *encrypted* backup→restore pipeline needs a backup-side counterpart; `run()`-level orchestration tests. ## Call sites checked - All six `readlineSync.question` sites in `restore.ts` are gated for non-interactive mode (backup index, decrypt password loop, both encryption-key prompt paths, version-mismatch confirm); the test suite's default `readlineSync.question` mock throws, so any reachable prompt fails CI. - openssl password sinks: restore's `runDecryptCommand` fixed here; backup's `encryptBackupArchive` deliberately deferred (follow-up above). - `removeSensitiveEnvData` is the only path that writes env content into archives; covered. ## CE/EE note `restore.ts` already diverges in EE (S3 archive support), so the hourly sync will conflict on this file. The EE-side end state is prepared on a branch (includes the S3-aware `--backup-file` handling and an EE-only correction of the version-mismatch message, which wrongly named the `appsmith-ce` image) and will be used as the source of truth when resolving the bot's sync PR. ## Testing - New `restore.test.ts` (15 tests): `--backup-file` selection/unknown-name/path-guard, non-interactive-without-file failure, env-password decrypt (asserts password absent from argv and present in child env; single attempt on wrong password), non-interactive-without-env failure, interactive prompt regression tests, `ensureEncryptionKeysPresent` (3 cases), version gate (abort / `--force` / match). - Tests were verified red against the pre-fix source, and mutation-checked (each behavior individually neutered kills its own tests). - Full `src/ctl` jest suite: 54/54 pass; eslint and `tsc --noEmit` clean. - Real openssl round-trip: archive encrypted with `-k` decrypts with `-pass env:`. - In-container end-to-end smoke test against `appsmith/appsmith-ce:release` with this branch's ctl bundle: 10 scenarios, all passing — full results in [this comment](#42147 (comment)). ## Automation /ok-to-test tags="@tag.All" 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: Cypress test results --> > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: <https://github.com/appsmithorg/appsmith/actions/runs/34380403090> > Commit: 4c389a6 > <a href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=34380403090&attempt=2" target="_blank">Cypress dashboard</a>. > Tags: `@tag.All` > Spec: > <hr>Wed, 09 Sep 2026 19:11:12 UTC <!-- end of auto-generated comment: Cypress test results --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added non-interactive restore support with explicit backup-file selection. * Added optional force restore for version mismatches. * Added support for archive passwords supplied through environment settings. * Added validation for encryption keys, backup files, and archive decryption. * **Bug Fixes** * Sensitive archive passwords are no longer included in exported environment files. * Restore errors now fail clearly and return an appropriate error status. * **Tests** * Expanded coverage for backup selection, decryption, encryption validation, and restore compatibility. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cda4e09 commit 2a42b02

4 files changed

Lines changed: 368 additions & 26 deletions

File tree

app/client/packages/rts/src/ctl/backup/backup.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@ describe("Backup Tests", () => {
125125
expect(cleaned).not.toContain("APPSMITH_REDIS_PASSWORD");
126126
});
127127

128+
test("removeSensitiveEnvData strips the backup archive password but keeps the archive limit", () => {
129+
const cleaned = removeSensitiveEnvData(
130+
`APPSMITH_BACKUP_ARCHIVE_PASSWORD=archive-pass\nAPPSMITH_BACKUP_ARCHIVE_LIMIT=4\nAPPSMITH_INSTANCE_NAME=Appsmith\n`,
131+
);
132+
133+
expect(cleaned).not.toContain("archive-pass");
134+
expect(cleaned).not.toContain("APPSMITH_BACKUP_ARCHIVE_PASSWORD=");
135+
expect(cleaned).toContain("APPSMITH_BACKUP_ARCHIVE_LIMIT=4");
136+
expect(cleaned).toContain("APPSMITH_INSTANCE_NAME=Appsmith");
137+
});
138+
128139
test("Backup Archive Limit when env APPSMITH_BACKUP_ARCHIVE_LIMIT is null", () => {
129140
expect(backup.getBackupArchiveLimit()).toBe(4);
130141
});

app/client/packages/rts/src/ctl/backup/links/EnvFileLink.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export function removeSensitiveEnvData(content: string): string {
6060
!line.startsWith("APPSMITH_MONGODB") &&
6161
!line.startsWith("APPSMITH_DB_URL=") &&
6262
!line.startsWith("APPSMITH_REDIS_URL=") &&
63-
!line.startsWith("APPSMITH_REDIS_PASSWORD=")
63+
!line.startsWith("APPSMITH_REDIS_PASSWORD=") &&
64+
!line.startsWith("APPSMITH_BACKUP_ARCHIVE_PASSWORD=")
6465
) {
6566
output_lines.push(line);
6667
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import fsPromises from "fs/promises";
2+
import readlineSync from "readline-sync";
3+
import * as utils from "./utils";
4+
import {
5+
checkRestoreVersionCompatability,
6+
decryptArchive,
7+
ensureEncryptionKeysPresent,
8+
getBackupFileName,
9+
} from "./restore";
10+
11+
jest.mock("./utils", () => ({
12+
...jest.requireActual("./utils"),
13+
execCommand: jest.fn(),
14+
execCommandSilent: jest.fn(),
15+
listLocalBackupFiles: jest.fn(),
16+
getCurrentAppsmithVersion: jest.fn(),
17+
}));
18+
19+
const mockedUtils = utils as jest.Mocked<typeof utils>;
20+
21+
const ORIGINAL_ENV = process.env;
22+
23+
beforeEach(() => {
24+
jest.clearAllMocks();
25+
process.env = { ...ORIGINAL_ENV };
26+
delete process.env.APPSMITH_BACKUP_ARCHIVE_PASSWORD;
27+
readlineSync.question = jest.fn().mockImplementation(() => {
28+
throw new Error("readlineSync.question should not be called");
29+
});
30+
});
31+
32+
afterAll(() => {
33+
process.env = ORIGINAL_ENV;
34+
});
35+
36+
describe("getBackupFileName with --backup-file", () => {
37+
beforeEach(() => {
38+
mockedUtils.listLocalBackupFiles.mockResolvedValue([
39+
"appsmith-backup-0001.tar.gz",
40+
"appsmith-backup-0002.tar.gz.enc",
41+
]);
42+
});
43+
44+
it("returns the local file named by --backup-file without prompting", async () => {
45+
const name = await getBackupFileName([
46+
"--backup-file=appsmith-backup-0002.tar.gz.enc",
47+
]);
48+
49+
expect(name).toBe("appsmith-backup-0002.tar.gz.enc");
50+
expect(readlineSync.question).not.toHaveBeenCalled();
51+
});
52+
53+
it("throws when --backup-file names an unknown archive", async () => {
54+
await expect(
55+
getBackupFileName(["--backup-file=no-such-backup.tar.gz"]),
56+
).rejects.toThrow("no-such-backup.tar.gz");
57+
});
58+
59+
it("throws when --backup-file contains a path instead of a file name", async () => {
60+
await expect(
61+
getBackupFileName(["--backup-file=../../etc/passwd"]),
62+
).rejects.toThrow("file name");
63+
});
64+
65+
it("throws in non-interactive mode when --backup-file is missing", async () => {
66+
await expect(getBackupFileName(["--non-interactive"])).rejects.toThrow(
67+
"--backup-file",
68+
);
69+
});
70+
71+
it("still prompts for an index in interactive mode", async () => {
72+
readlineSync.question = jest.fn().mockReturnValue("0");
73+
74+
const name = await getBackupFileName([]);
75+
76+
expect(name).toBe("appsmith-backup-0001.tar.gz");
77+
expect(readlineSync.question).toHaveBeenCalled();
78+
});
79+
});
80+
81+
describe("decryptArchive", () => {
82+
const encPath = "/backup/archive.tar.gz.enc";
83+
const outPath = "/backup/archive.tar.gz";
84+
85+
it("uses APPSMITH_BACKUP_ARCHIVE_PASSWORD without prompting", async () => {
86+
process.env.APPSMITH_BACKUP_ARCHIVE_PASSWORD = "s3cret";
87+
mockedUtils.execCommandSilent.mockResolvedValue(undefined);
88+
89+
const ok = await decryptArchive(encPath, outPath, []);
90+
91+
expect(ok).toBe(true);
92+
expect(mockedUtils.execCommandSilent).toHaveBeenCalledTimes(1);
93+
// The password must reach openssl via the child environment, not argv.
94+
expect(mockedUtils.execCommandSilent.mock.calls[0][0]).not.toContain(
95+
"s3cret",
96+
);
97+
expect(
98+
mockedUtils.execCommandSilent.mock.calls[0][1].env
99+
.APPSMITH_BACKUP_ARCHIVE_PASSWORD,
100+
).toBe("s3cret");
101+
expect(readlineSync.question).not.toHaveBeenCalled();
102+
});
103+
104+
it("fails after a single attempt when the env password is wrong", async () => {
105+
process.env.APPSMITH_BACKUP_ARCHIVE_PASSWORD = "wrong";
106+
mockedUtils.execCommandSilent.mockRejectedValue(new Error("bad decrypt"));
107+
108+
const ok = await decryptArchive(encPath, outPath, []);
109+
110+
expect(ok).toBe(false);
111+
expect(mockedUtils.execCommandSilent).toHaveBeenCalledTimes(1);
112+
expect(readlineSync.question).not.toHaveBeenCalled();
113+
});
114+
115+
it("fails in non-interactive mode when the env password is missing", async () => {
116+
const ok = await decryptArchive(encPath, outPath, ["--non-interactive"]);
117+
118+
expect(ok).toBe(false);
119+
expect(mockedUtils.execCommandSilent).not.toHaveBeenCalled();
120+
expect(readlineSync.question).not.toHaveBeenCalled();
121+
});
122+
123+
it("still prompts in interactive mode when no env password is set", async () => {
124+
readlineSync.question = jest.fn().mockReturnValue("typed-pass");
125+
mockedUtils.execCommandSilent.mockResolvedValue(undefined);
126+
127+
const ok = await decryptArchive(encPath, outPath, []);
128+
129+
expect(ok).toBe(true);
130+
expect(readlineSync.question).toHaveBeenCalled();
131+
expect(mockedUtils.execCommandSilent.mock.calls[0][0]).not.toContain(
132+
"typed-pass",
133+
);
134+
expect(
135+
mockedUtils.execCommandSilent.mock.calls[0][1].env
136+
.APPSMITH_BACKUP_ARCHIVE_PASSWORD,
137+
).toBe("typed-pass");
138+
});
139+
});
140+
141+
describe("ensureEncryptionKeysPresent", () => {
142+
it("passes when both encryption keys are set", () => {
143+
process.env.APPSMITH_ENCRYPTION_PASSWORD = "pwd";
144+
process.env.APPSMITH_ENCRYPTION_SALT = "salt";
145+
146+
expect(() => ensureEncryptionKeysPresent()).not.toThrow();
147+
});
148+
149+
it("throws when APPSMITH_ENCRYPTION_PASSWORD is missing", () => {
150+
delete process.env.APPSMITH_ENCRYPTION_PASSWORD;
151+
process.env.APPSMITH_ENCRYPTION_SALT = "salt";
152+
153+
expect(() => ensureEncryptionKeysPresent()).toThrow(
154+
"APPSMITH_ENCRYPTION_PASSWORD",
155+
);
156+
});
157+
158+
it("throws when APPSMITH_ENCRYPTION_SALT is missing", () => {
159+
process.env.APPSMITH_ENCRYPTION_PASSWORD = "pwd";
160+
delete process.env.APPSMITH_ENCRYPTION_SALT;
161+
162+
expect(() => ensureEncryptionKeysPresent()).toThrow(
163+
"APPSMITH_ENCRYPTION_SALT",
164+
);
165+
});
166+
});
167+
168+
describe("checkRestoreVersionCompatability in non-interactive mode", () => {
169+
beforeEach(() => {
170+
jest
171+
.spyOn(fsPromises, "readFile")
172+
.mockResolvedValue(JSON.stringify({ appsmithVersion: "v1.0.0" }));
173+
});
174+
175+
afterEach(() => {
176+
jest.restoreAllMocks();
177+
});
178+
179+
it("throws on version mismatch, pointing at --force", async () => {
180+
mockedUtils.getCurrentAppsmithVersion.mockResolvedValue("v2.0.0");
181+
182+
await expect(
183+
checkRestoreVersionCompatability("/contents", ["--non-interactive"]),
184+
).rejects.toThrow("--force");
185+
expect(readlineSync.question).not.toHaveBeenCalled();
186+
});
187+
188+
it("proceeds on version mismatch with --force", async () => {
189+
mockedUtils.getCurrentAppsmithVersion.mockResolvedValue("v2.0.0");
190+
191+
await expect(
192+
checkRestoreVersionCompatability("/contents", [
193+
"--non-interactive",
194+
"--force",
195+
]),
196+
).resolves.toBeUndefined();
197+
expect(readlineSync.question).not.toHaveBeenCalled();
198+
});
199+
200+
it("proceeds when versions match", async () => {
201+
mockedUtils.getCurrentAppsmithVersion.mockResolvedValue("v1.0.0");
202+
203+
await expect(
204+
checkRestoreVersionCompatability("/contents", ["--non-interactive"]),
205+
).resolves.toBeUndefined();
206+
expect(readlineSync.question).not.toHaveBeenCalled();
207+
});
208+
});

0 commit comments

Comments
 (0)