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
115 changes: 115 additions & 0 deletions bin/cli/commands/sync-import.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { readFileSync } from "node:fs";
import { emit } from "../output.mjs";

// Local side of `omniroute sync import` (the upload lives in sync.mjs): reading and
// sanity-checking the backup, and the dry run, which must never reach the server.

const SQLITE_MAGIC = Buffer.from("SQLite format 3\0", "latin1");
const MIN_SQLITE_BYTES = 4096;
// Kept in sync with REQUIRED_TABLES in src/app/api/db-backups/import/route.ts.
const IMPORT_REQUIRED_TABLES = ["provider_connections", "provider_nodes", "combos", "api_keys"];

function importFail(message, exitCode = 2) {
process.stderr.write(`Error: sync import: ${message}\n`);
return exitCode;
}

/** Cheap local check run before any upload: is this file a SQLite database at all? */
function checkSqliteShape(buf) {
if (
buf.length < SQLITE_MAGIC.length ||
!buf.subarray(0, SQLITE_MAGIC.length).equals(SQLITE_MAGIC)
) {
return (
"the file is not a SQLite database. `sync import` restores a .sqlite backup " +
"(e.g. from `omniroute backup` or the dashboard); the JSON written by `sync bundle` " +
"cannot be imported here."
);
}
if (buf.length < MIN_SQLITE_BYTES) return "the file is too small to be a valid SQLite database.";
return null;
}

/**
* Open the backup read-only and run the checks the server would (integrity + required
* tables), returning what an import would load. `skipped` when no local SQLite driver is
* available — the dry run then rests on the header check alone.
*/
async function inspectBackupLocally(filePath) {
let withReadonlySqlite;
try {
({ withReadonlySqlite } = await import("../sqlite.mjs"));
} catch (err) {
return { skipped: err?.message || "SQLite driver unavailable" };
}
try {
return await withReadonlySqlite(filePath, (db) => {
const integrity = db.pragma("integrity_check", { simple: true });
if (integrity !== "ok") {
return { error: "database integrity check failed; the file may be corrupted." };
}
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all()
.map((r) => r.name);
const missing = IMPORT_REQUIRED_TABLES.filter((t) => !tables.includes(t));
if (missing.length > 0) {
return { error: `not an OmniRoute database; missing tables: ${missing.join(", ")}` };
}
const count = (table) => db.prepare(`SELECT COUNT(*) AS cnt FROM ${table}`).get()?.cnt ?? 0;
const schemaVersion = tables.includes("_omniroute_migrations")
? (db
.prepare(
"SELECT version FROM _omniroute_migrations ORDER BY CAST(version AS INTEGER) DESC, version DESC LIMIT 1"
)
.get()?.version ?? null)
: null;
return {
connectionCount: count("provider_connections"),
nodeCount: count("provider_nodes"),
comboCount: count("combos"),
apiKeyCount: count("api_keys"),
schemaVersion,
};
});
} catch (err) {
return { error: `cannot open the file as SQLite: ${err?.message || "unknown error"}` };
}
}

/**
* Validate the backup locally and print what an import would load. NEVER contacts the
* server: an older server ignores `dryRun` and would replace the live database.
*/
export async function runImportDryRun(filePath, buf, globalOpts) {
const report = await inspectBackupLocally(filePath);
if (report.error) return importFail(report.error);
const summary = {
dryRun: true,
valid: true,
file: filePath,
sizeBytes: buf.length,
...(report.skipped
? { deepChecks: `skipped (${report.skipped}); only the SQLite header was verified` }
: report),
note: "No request was sent to the server; the live database was not modified.",
};
emit(summary, globalOpts);
return 0;
}

/**
* Read the backup and run the cheap header check every import starts with. Returns
* `{ buf }`, or `{ exitCode }` after printing why the file cannot be imported.
*/
export function readImportFile(filePath) {
let buf;
try {
buf = readFileSync(filePath);
} catch (err) {
return { exitCode: importFail(`cannot read ${filePath}: ${err?.code || err?.message}`, 1) };
}
const shapeError = checkSqliteShape(buf);
if (shapeError) return { exitCode: importFail(shapeError) };
return { buf };
}
79 changes: 54 additions & 25 deletions bin/cli/commands/sync.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { readFileSync, writeFileSync } from "node:fs";
import { writeFileSync } from "node:fs";
import { basename } from "node:path";
import { createInterface } from "node:readline";
import { apiFetch, getBaseUrl, isRouteUnavailableStatus } from "../api.mjs";
import { apiFetch, isRouteUnavailableStatus } from "../api.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
import { readImportFile, runImportDryRun } from "./sync-import.mjs";

function fmtTs(v) {
if (!v) return "-";
Expand All @@ -20,12 +21,6 @@ async function confirm(q) {
});
}

function authHeaders(opts) {
const h = { accept: "application/json" };
if (opts.apiKey) h["Authorization"] = `Bearer ${opts.apiKey}`;
return h;
}

const syncTokenSchema = [
{ key: "id", header: "Token ID", width: 16 },
{ key: "name", header: "Name", width: 24 },
Expand Down Expand Up @@ -242,6 +237,52 @@ export async function runSyncInitialize(globalOpts = {}) {
return 0;
}

const IMPORT_PATH = "/api/db-backups/import";
// Uploading + validating + swapping a large database can outlast the 30 s default timeout.
const IMPORT_TIMEOUT_MS = 300_000;

function importFailureHint(status) {
if (status === 401 || status === 403) {
return (
"importing replaces the whole database: run this on the server host (local CLI " +
"credential), sign in to the dashboard, or pass an API key with the 'manage' scope."
);
}
return routeUnavailableHint(status, `POST ${IMPORT_PATH}`);
}

/**
* `omniroute sync import <file> [--dry-run]` — replace the server's database with a `.sqlite`
* backup via `POST /api/db-backups/import`. With `--dry-run` the file is only validated
* locally and the server is never contacted: an older server ignores `dryRun` and would
* replace the live database.
*/
export async function runSyncImport(filePath, opts = {}, globalOpts = {}) {
const { buf, exitCode: readExit } = readImportFile(filePath);
if (!buf) return readExit;
if (opts.dryRun) return runImportDryRun(filePath, buf, globalOpts);

const name = basename(filePath);
const query = name.endsWith(".sqlite") ? `?filename=${encodeURIComponent(name)}` : "";
const { res, exitCode } = await sendSyncRequest(
"sync import",
`${IMPORT_PATH}${query}`,
globalOpts,
{
method: "POST",
headers: { "content-type": "application/octet-stream" },
body: buf,
timeout: IMPORT_TIMEOUT_MS,
// A replace is not something to replay blindly after a gateway error.
retry: false,
}
);
if (!res) return exitCode;
if (!res.ok) return reportFailure("sync import", res, importFailureHint(res.status));
emit(await res.json(), globalOpts);
return 0;
}

export function registerSync(program) {
const sync = program.command("sync").description(t("sync.description"));

Expand Down Expand Up @@ -276,24 +317,12 @@ export function registerSync(program) {
.description(t("sync.import.description"))
.option("--dry-run", t("sync.import.dryRun"))
.action(async (bundlePath, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const body = readFileSync(bundlePath);
const res = await fetch(
`${getBaseUrl(globalOpts)}/api/db-backups/import?dryRun=${opts.dryRun ? "true" : "false"}`,
{
method: "POST",
headers: {
...authHeaders(globalOpts),
"Content-Type": "application/octet-stream",
},
body,
}
const exitCode = await runSyncImport(
bundlePath,
{ dryRun: !!opts.dryRun },
cmd.optsWithGlobals()
);
if (!res.ok) {
process.stderr.write(`HTTP ${res.status}\n`);
process.exit(1);
}
emit(await res.json(), globalOpts);
if (exitCode !== 0) process.exit(exitCode);
});

sync.command("initialize").action(async (opts, cmd) => {
Expand Down
4 changes: 2 additions & 2 deletions bin/cli/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1179,8 +1179,8 @@
"token": "Sync token (from `sync tokens create`); defaults to OMNIROUTE_SYNC_TOKEN"
},
"import": {
"description": "Import a bundle file",
"dryRun": "Validate without applying"
"description": "Replace the server database with a .sqlite backup",
"dryRun": "Validate the backup locally; never contacts the server"
},
"tokens": {
"description": "Manage sync tokens",
Expand Down
4 changes: 2 additions & 2 deletions bin/cli/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -1179,8 +1179,8 @@
"token": "Token de sincronização (de `sync tokens create`); padrão: OMNIROUTE_SYNC_TOKEN"
},
"import": {
"description": "Importar arquivo bundle",
"dryRun": "Validar sem aplicar"
"description": "Substituir o banco do servidor por um backup .sqlite",
"dryRun": "Validar o backup localmente; nunca contata o servidor"
},
"tokens": {
"description": "Gerenciar tokens de sincronização",
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixes/0000-cli-sync-import-dry-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(cli):** `omniroute sync import --dry-run` no longer replaces the live database. The command sent `?dryRun=true` to `POST /api/db-backups/import`, a flag the route never read, so a "dry run" overwrote the operator's data. The route now honours `dryRun` (query string or multipart field): it runs the same integrity and table checks and returns the row counts and schema version a real import would load, without the pre-import backup and without touching the live database. The CLI goes further and never contacts the server in dry-run mode (an older server would still ignore the flag): it validates the backup locally and prints the summary. A real import now goes through the local CLI credential, which the route accepts next to the dashboard session and `manage`-scoped API keys; JSON files such as the output of `sync bundle` are refused before upload, and 401/403/400 end in a readable error instead of a bare status.
30 changes: 29 additions & 1 deletion docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9963,9 +9963,37 @@ paths:
tags:
- Db backups
summary: "POST db backups › import"
description: >-
Replaces the live database with an uploaded `.sqlite` backup (multipart `file` field,
or the raw bytes with `?filename=`). The upload is checked for SQLite integrity and the
required OmniRoute tables first. Accepts the dashboard session, a `manage`-scoped API
key, or the local CLI credential.
parameters:
- name: dryRun
in: query
description: >-
`true` validates the upload and returns what would be imported (row counts, schema
version) without the pre-import backup and without touching the live database. A
multipart `dryRun` form field is honoured too.
required: false
schema:
type: string
enum: ["true", "false", "1", "0"]
- name: filename
in: query
description: Name of the uploaded file for raw-byte uploads; must end in `.sqlite`.
required: false
schema:
type: string
responses:
"200":
description: OK
description: >-
Imported (`imported: true`), or, for a dry run, validated (`dryRun: true,
valid: true, imported: false`) with the counts the upload would load.
"400":
description: Not a valid OmniRoute SQLite backup (type, size, integrity or tables).
"401":
description: No accepted credential.
/api/db/health:
get:
tags:
Expand Down
16 changes: 8 additions & 8 deletions docs/reference/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1108,14 +1108,14 @@ curl -X PATCH "$OMNIROUTE_URL/api/keys/$KEY_ID" \

### Backup & Export/Import

| Endpoint | Method | Description |
| --------------------------- | ------ | --------------------------------------- |
| `/api/db-backups` | GET | List available backups |
| `/api/db-backups` | PUT | Create a manual backup |
| `/api/db-backups` | POST | Restore from a specific backup |
| `/api/db-backups/export` | GET | Download database as .sqlite file |
| `/api/db-backups/import` | POST | Upload .sqlite file to replace database |
| `/api/db-backups/exportAll` | GET | Download full backup as .tar.gz archive |
| Endpoint | Method | Description |
| --------------------------- | ------ | ----------------------------------------------------------------------- |
| `/api/db-backups` | GET | List available backups |
| `/api/db-backups` | PUT | Create a manual backup |
| `/api/db-backups` | POST | Restore from a specific backup |
| `/api/db-backups/export` | GET | Download database as .sqlite file |
| `/api/db-backups/import` | POST | Upload .sqlite file to replace database (`?dryRun=true` validates only) |
| `/api/db-backups/exportAll` | GET | Download full backup as .tar.gz archive |

`/api/db-backups/export`, `/api/db-backups/exportAll` and `/api/settings/export-json` hand out
every stored credential, so they require the dashboard session or the local CLI (or login
Expand Down
Loading
Loading