Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/006-install-identity"
"feature_directory": "specs/007-restore-on-install"
}
34 changes: 33 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,38 @@ install as **Docker Compose** stacks, orchestrated by a server and routed by
`ALREADY_INSTALLED` (`details.code`, same `CONFLICT` top-level shape as
`PROVIDER_EXISTS`) so the wizard/CLI render a choice instead of a
surface-neutral message the caller has to parse.
- **Restore-on-install (spec 007).** At install, an operator may name an
existing deployment of the same app on this host as a restore source
(`CreateDraftRequest.restoreFrom`, catalog path only — install-by-ref
refuses it, `RESTORE_NOT_SUPPORTED`, since there's no catalog upgrade
metadata to judge the candidate's version against). The choice is resolved
and validated once at draft creation (seeding `appEnv` via the existing
`mergeUpgradeAppEnv` three-case rule when configuration is carried) and
re-validated at `createFromDraft`, because the candidate may have changed
since. The restore itself runs inside `runLifecycleJob`, after
`composePull` and before `composeUp` — never at create time (Constitution
III): assert the target data root is empty, quiesce and capture the source
with the existing `backup@1` pre/post hooks, extract into the target
(root-relative, no intermediate copy), assert the payload landed (a
post-condition, not a subtree search — this codebase's archives are
root-relative on both ends), apply the app's declared `discard` paths,
rewrite the install-identity marker, write pending OIDC credentials (moved
here from its usual pre-restore position so extraction can't destroy it),
start only the declared hook's service with `composeUp({ services, wait:
true })`, and run the restore hook fail-closed. A failed restore fails the
whole install — no partial-success start. An app declares how it wants to
be restored per **backup participation** (`restore` block in the bundle
manifest, reusing `AppBackupHook` verbatim — no second hook format): no
`restore@1` in `accepts` means not offered; `accepts: ["restore@1"]` with no
block means a plain file copy is sufficient; a block adds `discard` paths
(a live database's file-level copy is a smear across the capture window,
not a snapshot) and a reload hook. No capability contract, grant, or
contract endpoint is added — `restore@1` here is a participation marker an
app declares, not a contract the platform brokers (`CONTRACTS` is
unchanged, FR-047). `hola install --restore-from <id|latest>`, with
`--restore-list` reading the same candidates route with no draft created;
the non-interactive default is always **no restore** — a candidate existing
is never itself consent to use it.

## Conventions

Expand Down Expand Up @@ -172,5 +204,5 @@ Full guide: `docs/MCP_VM_TESTING.md`.
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan:
`specs/006-install-identity/plan.md`
`specs/007-restore-on-install/plan.md`
<!-- SPECKIT END -->
43 changes: 43 additions & 0 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,49 @@ existing provider and telling you to uninstall it first. A pair recorded
before this rule existed (rare) is flagged as a warning on the Backups page
rather than silently resolved — uninstall one of them.

### Restore-on-install

During an **install**, you can pick an existing deployment of the same app on
this host as a restore source. The server quiesces it with the same pre/post
hooks App backups already use, captures its data root, and lays that data down
into the new install after images are pulled and before any container starts —
the one moment an app's life when the data root is empty and no process holds
it open. It closes [#429](https://github.com/try-hola/hola/issues/429): the
fastest way to get a second, independently-addressable copy of a running app
holding its data.

**Where it lives.** The install wizard's first step, before Configuration — a
restore choice determines what configuration gets pre-filled, so it has to
come first. From the CLI: `hola install <app> --restore-from <id|latest>`
(`--restore-list` to see candidates first, with no draft created).

**What it carries.** Files, always. Configuration — including secrets — only
when you ask for it (on by default when the candidate has one recorded); an
app that generates its own secrets on first boot will mint fresh ones for data
that was encrypted under the originals unless you carry the recorded values
forward too. The confirmation step names this explicitly: a restore carries
**data and credentials**, and any jobs, webhooks or integrations the app runs
may fire the moment it starts holding that data.

**Why it refuses.** A restore fails the whole install rather than starting an
app on empty or partial data — there is no partial-success state. Common
reasons: the candidate was captured on a newer version than you're installing;
the app's own upgrade rules block the version hop; the candidate no longer
exists or isn't in a settled (running/stopped) state by the time the install
actually runs; or a restore hook failed (a database load with `ON_ERROR_STOP`
enabled reports the load failure, not the app's later confusion about missing
tables). Every refusal is a specific answer, not a generic install failure — a
new deployment left in `error` with its data root intact, so you can inspect it
before deciding to retry.

**What an app has to declare.** Nothing, for the common case: most catalog
apps (anything SQLite or flat-file) restore correctly with a plain file copy,
and need no manifest changes at all. A database-backed app declares a small
`restore` block in its manifest naming which paths to discard after the files
land (a live database's file-level copy is a smear across the capture window,
not a snapshot) and the `psql`-style command that reloads the dump — the same
hook shape the backup declaration already uses.

### Container logs

`container-logs@1` is a **provisioned** contract: a log collector app from the
Expand Down
94 changes: 93 additions & 1 deletion packages/cli/src/__tests__/opts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import sade from 'sade';

import { camelKeys, streamOpts } from '../lib/opts';
import { camelKeys, parseOpts, streamOpts } from '../lib/opts';

describe('camelKeys', () => {
it('camelCases kebab-case flag keys (the sade/mri multi-word fix)', () => {
Expand Down Expand Up @@ -39,3 +42,92 @@ describe('streamOpts (--no-stream normalization)', () => {
expect(streamOpts({ noStream: true }).noStream).toBe(true);
});
});

/**
* `--carry-env` is tri-state, so it is the one flag that cannot be registered
* with a `false` default — and that default is what would otherwise put its
* name in mri's boolean list. Without it mri treated the flag as value-taking
* and swallowed the next positional (#488). These parse the real flag shapes
* through sade + `parseOpts()` rather than asserting on a hand-built opts bag,
* because the bug lives in parsing, not in normalization.
*/
describe('parseOpts (--carry-env tri-state, #488)', () => {
/** Mirrors `install`'s restore-flag registration in src/index.ts. */
const installProg = () =>
sade('hola')
.command('install <appId>')
.option('--restore-from', 'Restore from an existing deployment of this app')
.option('--no-restore', 'Explicitly install with no restore', false)
.option('--restore-list', 'List restore candidates and exit', false)
// No default — the tri-state flag under test.
.option('--carry-env', 'Carry the restore candidate\'s configuration')
.option('--no-carry-env', 'Do not carry the restore candidate\'s configuration', false)
.option('--ack', 'Acknowledge a restore risk by code')
.action(() => {});

/** Parse an `hola …` argv the way index.ts does, without running a handler. */
function parseInstall(argv: string[]) {
const parsed = installProg().parse(['node', 'hola', ...argv], { ...parseOpts(), lazy: true }) as
| { args: unknown[] }
| undefined;
if (!parsed) throw new Error('sade refused the argv'); // e.g. "Insufficient arguments!"
const args = parsed.args;
return {
appId: args[0] as string,
opts: camelKeys(args[args.length - 1] as Record<string, unknown>),
};
}

it('does not swallow the positional after a bare --carry-env (the issue repro)', () => {
// `hola install --carry-env gitea --restore-from latest` used to abort with
// sade's "Insufficient arguments!" because mri assigned carryEnv: 'gitea'.
const { appId, opts } = parseInstall(['install', '--carry-env', 'gitea', '--restore-from', 'latest']);
expect(appId).toBe('gitea');
expect(opts.carryEnv).toBe(true);
expect(opts.restoreFrom).toBe('latest');
});

it('still reads --carry-env when it follows the positional', () => {
const { appId, opts } = parseInstall(['install', 'gitea', '--carry-env', '--restore-from', 'latest']);
expect(appId).toBe('gitea');
expect(opts.carryEnv).toBe(true);
});

it('reads --no-carry-env as an explicit false, in either position', () => {
expect(parseInstall(['install', '--no-carry-env', 'gitea']).opts.carryEnv).toBe(false);
const trailing = parseInstall(['install', 'gitea', '--no-carry-env', '--ack', 'restore-env-not-carried']);
expect(trailing.appId).toBe('gitea');
expect(trailing.opts.carryEnv).toBe(false);
expect(trailing.opts.ack).toBe('restore-env-not-carried');
});

it('leaves carryEnv undefined when neither flag is given — NOT false', () => {
// The whole constraint: `undefined` means "default to the candidate's own
// carriesEnv" (contracts/cli.md). Collapsing it to `false` would silently
// stop carrying configuration on every restore that did not ask.
const { appId, opts } = parseInstall(['install', 'gitea', '--restore-from', 'latest']);
expect(appId).toBe('gitea');
expect(opts.carryEnv).toBeUndefined();
});

it('reads an explicit --carry-env=false as false, not as truthy "false"', () => {
expect(parseInstall(['install', 'gitea', '--carry-env=false']).opts.carryEnv).toBe(false);
expect(parseInstall(['install', 'gitea', '--carry-env=true']).opts.carryEnv).toBe(true);
});

it('returns a fresh object each call (sade and mri both mutate what they get)', () => {
const a = parseOpts();
const b = parseOpts();
expect(a).not.toBe(b);
expect(a.boolean).not.toBe(b.boolean);
});

it('src/index.ts actually hands parseOpts() to prog.parse, for every flag it names', () => {
// The mirror above only proves the mechanism; this proves the real CLI uses it.
const src = readFileSync(join(__dirname, '../index.ts'), 'utf8');
expect(src).toMatch(/prog\.parse\(process\.argv,\s*parseOpts\(\)\)/);
for (const name of parseOpts().boolean) {
expect(src).toContain(`.option('--${name}'`);
}
});
});
173 changes: 173 additions & 0 deletions packages/cli/src/__tests__/restore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

import { runInstall, parseAcks } from '../commands/install/install';
import { reportDeployError } from '../lib/deploy-flow';
import { HolaApiError } from '@hola/sdk';
import type { HolaSdk } from '@hola/sdk';
import type { ListRestoreCandidatesResponse } from '@hola/shared';

function candidate(id: string, lineageId: string, carriesEnv = true) {
return {
deploymentId: id,
lineageId,
app: 'mealie',
name: id,
subdomain: id,
host: `${id}.example.com`,
appVersion: '1.0.0',
channel: 'stable',
carriesEnv,
capturedAt: '2026-01-01T00:00:00.000Z',
hasIdentityRecord: true,
skew: { kind: 'ok' as const },
requiredAcknowledgements: [],
warnings: [],
};
}

function makeSdk(overrides: { drafts?: Record<string, unknown>; restoreCandidatesResp?: ListRestoreCandidatesResponse } = {}) {
const calls: string[] = [];
const oneLineage: ListRestoreCandidatesResponse = {
appId: 'mealie',
lineages: [{ lineageId: 'l1', candidates: [candidate('mealie-aaa', 'l1')] }],
defaultCandidateId: 'mealie-aaa',
requiresExplicitChoice: false,
};
return {
calls,
drafts: {
create: vi.fn(async () => { calls.push('create'); return { draftId: 'd1' }; }),
byId: vi.fn(async () => { calls.push('byId'); return { draftId: 'd1', appEnv: [] }; }),
update: vi.fn(async () => { calls.push('update'); return { ok: true }; }),
validate: vi.fn(async () => { calls.push('validate'); return { ok: true, errors: [], warnings: [] }; }),
preflight: vi.fn(async () => { calls.push('preflight'); return { ok: true, checks: [] }; }),
finalize: vi.fn(async () => { calls.push('finalize'); return { spec: {}, checksum: 'x' }; }),
...(overrides.drafts ?? {}),
},
deployments: {
create: vi.fn(async () => { calls.push('deploy'); return { deploymentId: 'dep1', releaseId: 'r1', jobId: 'j1' }; }),
},
jobs: { byId: vi.fn(async () => ({ status: 'completed' })) },
restoreCandidates: vi.fn(async () => overrides.restoreCandidatesResp ?? oneLineage),
};
}

describe('restore-on-install CLI (spec 007)', () => {
beforeEach(() => { process.exitCode = 0; });
afterEach(() => { process.exitCode = 0; vi.restoreAllMocks(); });

// ---- parseAcks mirrors parseGrants exactly (scenario 52) ----
it('scenario 52: parseAcks parses repeated and comma-separated values exactly like --grant', () => {
expect(parseAcks(undefined)).toBeUndefined();
expect(parseAcks('a')).toEqual(['a']);
expect(parseAcks(['a', 'b'])).toEqual(['a', 'b']);
expect(parseAcks('a,b, c')).toEqual(['a', 'b', 'c']);
expect(parseAcks(['a,b', 'b'])).toEqual(['a', 'b']); // deduped
});

// ---- scenario 49: each flag behaves as specified ----
it('scenario 49: --restore-from <id> resolves that exact candidate and sends restoreFrom on create', async () => {
const sdk = makeSdk();
await runInstall('mealie', { restoreFrom: 'mealie-aaa', ack: 'restore-env-not-carried', noStream: true }, { sdk: sdk as unknown as HolaSdk });

expect(sdk.restoreCandidates).toHaveBeenCalledWith('mealie', 'latest', undefined, undefined);
expect(sdk.drafts.create).toHaveBeenCalledWith(expect.objectContaining({
appId: 'mealie',
restoreFrom: { candidateId: 'mealie-aaa', carryEnv: true, acknowledge: ['restore-env-not-carried'] },
}));
});

it('scenario 49: --restore-from latest refuses across two-or-more unrelated lineages', async () => {
const sdk = makeSdk({
restoreCandidatesResp: {
appId: 'mealie',
lineages: [
{ lineageId: 'l1', candidates: [candidate('mealie-aaa', 'l1')] },
{ lineageId: 'l2', candidates: [candidate('mealie-bbb', 'l2')] },
],
defaultCandidateId: null,
requiresExplicitChoice: true,
},
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const res = await runInstall('mealie', { restoreFrom: 'latest', noStream: true }, { sdk: sdk as unknown as HolaSdk });

expect(res).toBeUndefined();
expect(process.exitCode).toBe(1);
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('ambiguous'));
expect(sdk.drafts.create).not.toHaveBeenCalled();
});

it('scenario 49: --restore-list lists candidates and creates no draft', async () => {
const sdk = makeSdk();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const res = await runInstall('mealie', { restoreList: true, noStream: true }, { sdk: sdk as unknown as HolaSdk });

expect(res).toBeUndefined();
expect(sdk.restoreCandidates).toHaveBeenCalledWith('mealie', 'latest', undefined, undefined);
expect(sdk.drafts.create).not.toHaveBeenCalled();
expect(sdk.deployments.create).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('mealie-aaa'));
logSpy.mockRestore();
});

it('scenario 49: --no-restore is a no-op — same outcome as no flag at all', async () => {
const sdk = makeSdk();
await runInstall('mealie', { restore: false, noStream: true }, { sdk: sdk as unknown as HolaSdk });

expect(sdk.restoreCandidates).not.toHaveBeenCalled();
expect(sdk.drafts.create).toHaveBeenCalledWith({ appId: 'mealie', version: 'latest' });
});

// ---- scenario 50: no restore flag at all -> no restore, even with candidates present ----
it('scenario 50: with no restore flag, no restore happens even when candidates exist (FR-044)', async () => {
const sdk = makeSdk(); // restoreCandidates would report a candidate if asked
await runInstall('mealie', { noStream: true }, { sdk: sdk as unknown as HolaSdk });

// The candidates route is never even consulted — silence must never guess.
expect(sdk.restoreCandidates).not.toHaveBeenCalled();
expect(sdk.drafts.create).toHaveBeenCalledWith({ appId: 'mealie', version: 'latest' });
});

// ---- scenario 51 (HIGHEST-adjacent): every hint is built from details, never the message ----
it('scenario 51: every RESTORE_* hint is built from details alone, with the message blanked', () => {
const cases: Array<{ code: string; details: Record<string, unknown>; expect: string }> = [
{ code: 'RESTORE_SOURCE_NEWER', details: { candidateVersion: '2.0.0', targetVersion: '1.0.0' }, expect: '2.0.0' },
{ code: 'RESTORE_UPGRADE_PATH', details: { suggestedVersion: '1.5.0' }, expect: '1.5.0' },
{ code: 'RESTORE_ENV_REQUIRED', details: { missingKeys: ['DB_PASSWORD'] }, expect: 'DB_PASSWORD' },
{ code: 'RESTORE_ACK_REQUIRED', details: { required: ['restore-env-not-carried'] }, expect: '--ack restore-env-not-carried' },
{ code: 'RESTORE_CANDIDATE_GONE', details: {}, expect: '--restore-list' },
{ code: 'RESTORE_CANDIDATE_BUSY', details: {}, expect: '--restore-list' },
{ code: 'RESTORE_NOT_SUPPORTED', details: {}, expect: 'install-by-ref' },
// #490: the hint names the candidate and the address it still holds, and
// asks for --name — it never invents a suffixed address as the answer.
{ code: 'RESTORE_ADDRESS_REQUIRED', details: { candidateId: 'mealie-aaa', candidateName: 'Recipes', subdomain: 'recipes' }, expect: '--name' },
];

for (const c of cases) {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Message deliberately blank — the hint must not depend on it at all.
const err = new HolaApiError('', 409, { details: { code: c.code, ...c.details } });
reportDeployError(err);
const hintCall = errSpy.mock.calls.find(call => String(call[0]).startsWith('Hint:'));
expect(hintCall, `no Hint printed for ${c.code}`).toBeTruthy();
expect(String(hintCall![0])).toContain(c.expect);
errSpy.mockRestore();
}
});

// ---- #490: the RESTORE_ADDRESS_REQUIRED hint names what is in the way ----
it('the RESTORE_ADDRESS_REQUIRED hint names the candidate and the address it holds', () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
reportDeployError(
new HolaApiError('', 409, {
details: { code: 'RESTORE_ADDRESS_REQUIRED', candidateId: 'mealie-aaa', candidateName: 'Recipes', subdomain: 'recipes' },
}),
);
const hint = String(errSpy.mock.calls.find(call => String(call[0]).startsWith('Hint:'))?.[0] ?? '');
expect(hint).toContain('Recipes');
expect(hint).toContain('recipes');
expect(hint).toContain('--name');
errSpy.mockRestore();
});
});
Loading
Loading