@canner-ca/cli 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.mjs +230 -13
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
28
28
|
import { stdin as input, stdout as output } from 'node:process';
|
|
29
29
|
import { create as tarCreate } from 'tar';
|
|
30
30
|
|
|
31
|
-
const VERSION = '0.5.
|
|
31
|
+
const VERSION = '0.5.3';
|
|
32
32
|
const DEFAULT_API_BASE = 'https://api.canner.ca';
|
|
33
33
|
const CONFIG_DIR = join(homedir(), '.canner');
|
|
34
34
|
const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials');
|
|
@@ -1119,8 +1119,11 @@ function sluggify(s) {
|
|
|
1119
1119
|
const BOOLEAN_FLAGS = new Set([
|
|
1120
1120
|
'json', 'reveal', 'sensitive', 'follow', 'dashboard', 'version', 'help',
|
|
1121
1121
|
'replace', 'yes', 'no-wait', 'enable', 'disable', 'restore', 'download', 'rm', 'delete', 'instance', 'pitr',
|
|
1122
|
+
'read-only', 'read-write', 'replication', 'group', 'login', 'adopt', 'strict',
|
|
1122
1123
|
]);
|
|
1123
1124
|
|
|
1125
|
+
const splitList = (v) => String(v).split(',').map((x) => x.trim()).filter(Boolean);
|
|
1126
|
+
|
|
1124
1127
|
// A flag repeated on the command line (e.g. `--allow a --allow b`) accumulates
|
|
1125
1128
|
// into an array instead of the last one silently winning.
|
|
1126
1129
|
function setArg(out, key, value) {
|
|
@@ -1174,6 +1177,10 @@ function parseArgs(argv) {
|
|
|
1174
1177
|
// canner db migrate-out [--from <name>] --to-url <url>|--to-env VAR|--to-url - --yes
|
|
1175
1178
|
// canner db copy <from> <to> [--to-project SLUG] [--replace]
|
|
1176
1179
|
// canner db ops [<id>] recent operations / one operation
|
|
1180
|
+
// canner db roles [<name>] [create|member-of|audit|rotate|rm|write-exclusions …] roles, groups and logins
|
|
1181
|
+
// canner db inject [<name>] [<login>|owner|off|on] which credential the app receives
|
|
1182
|
+
// canner db least-privilege [<name>] [--label app] --yes run the app as a limited role; the owner goes to the build only
|
|
1183
|
+
// canner db check [<name>] [--category security|performance] [--strict] [--json] security and performance check
|
|
1177
1184
|
//
|
|
1178
1185
|
// Long operations (export, import, migrate, copy) run on the platform: the CLI
|
|
1179
1186
|
// starts one, then waits and shows progress. Pass --no-wait to return the
|
|
@@ -1299,7 +1306,7 @@ async function cmdDb(args) {
|
|
|
1299
1306
|
info(`${pad(bold('NAME'), 22)}${pad(bold('POSTGRES'), 11)}${pad(bold('SIZE'), 11)}${bold('STATUS')}`);
|
|
1300
1307
|
for (const d of rows) {
|
|
1301
1308
|
const status = d.over_quota_at ? red('write-locked (over quota)') : green('ok');
|
|
1302
|
-
info(`${pad(d.is_primary ? `${d.name} ${dim(
|
|
1309
|
+
info(`${pad(d.is_primary ? `${d.name} ${dim(`(DATABASE_URL${d.inject_role ? ` as ${d.inject_role}` : ''})`)}` : d.name, 22)}${pad(d.pg_version, 11)}${pad(`${d.size_mb} MB`, 11)}${status}`);
|
|
1303
1310
|
}
|
|
1304
1311
|
const l = body?.limits;
|
|
1305
1312
|
if (l && !l.advanced_enabled) muted('Additional databases, import and migration are available on paid plans.');
|
|
@@ -2015,19 +2022,110 @@ async function cmdDb(args) {
|
|
|
2015
2022
|
return;
|
|
2016
2023
|
}
|
|
2017
2024
|
|
|
2025
|
+
if (sub === 'check') {
|
|
2026
|
+
// A read-only security and performance check of one database (catalog only, never data). Works on every plan.
|
|
2027
|
+
const label = args._[1] ?? 'primary';
|
|
2028
|
+
if (args.category !== undefined && args.category !== 'security' && args.category !== 'performance') die('--category must be security or performance.', 2);
|
|
2029
|
+
const r = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(label)}/security-check`);
|
|
2030
|
+
const findings = typeof args.category === 'string' ? r.check.findings.filter((f) => f.category === args.category) : r.check.findings;
|
|
2031
|
+
const warn = findings.filter((f) => f.severity === 'warn').length;
|
|
2032
|
+
const note = findings.length - warn;
|
|
2033
|
+
if (JSON_MODE) emitJSON({ ...r.check, findings, summary: { warn, info: note } });
|
|
2034
|
+
else {
|
|
2035
|
+
info(`${bold(label)} ${warn > 0 ? yellow(`${warn} warning${warn === 1 ? '' : 's'}`) : green('no warnings')} ${dim(`· ${note} note${note === 1 ? '' : 's'}`)}`);
|
|
2036
|
+
for (const category of ['security', 'performance']) {
|
|
2037
|
+
const group = findings.filter((f) => f.category === category);
|
|
2038
|
+
if (group.length === 0) continue;
|
|
2039
|
+
info('');
|
|
2040
|
+
info(bold(category.toUpperCase()));
|
|
2041
|
+
for (const f of group) {
|
|
2042
|
+
const objs = f.objects.length > 0 ? ` ${dim(`[${f.objects.join(', ')}${f.more ? ', …' : ''}]`)}` : '';
|
|
2043
|
+
info(` ${f.severity === 'warn' ? yellow('warn') : dim('info')} ${f.message}${objs}`);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
if (findings.length === 0) info(green('nothing to report'));
|
|
2047
|
+
}
|
|
2048
|
+
// For CI: --strict fails the run when anything is a warning.
|
|
2049
|
+
if (args.strict === true && warn > 0) process.exitCode = 1;
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
if (sub === 'inject') {
|
|
2054
|
+
// Which credential the app receives as DATABASE_URL (or DATABASE_URL_<NAME>): the owner's (the default) or one of your logins.
|
|
2055
|
+
const label = args._[1] ?? 'primary';
|
|
2056
|
+
const what = args._[2];
|
|
2057
|
+
const envVar = label === 'primary' ? 'DATABASE_URL' : `DATABASE_URL_${label.toUpperCase()}`;
|
|
2058
|
+
if (what === undefined) {
|
|
2059
|
+
const body = await apiJSON(creds, 'GET', `${base}/databases`);
|
|
2060
|
+
const d = (body?.databases ?? []).find((x) => x.name === label);
|
|
2061
|
+
if (!d) die(`no database named "${label}".`);
|
|
2062
|
+
if (JSON_MODE) return emitJSON({ name: d.name, auto_inject: d.auto_inject, inject_role: d.inject_role ?? null });
|
|
2063
|
+
info(`${bold(envVar)} ${!d.auto_inject ? yellow('not injected') : d.inject_role ? `the login ${green(d.inject_role)}` : 'the database owner'}`);
|
|
2064
|
+
muted('Change it: canner db inject <db> <login> | owner | off | on');
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
const patch = what === 'on' ? { auto_inject: true } : what === 'off' ? { auto_inject: false } : what === 'owner' ? { inject_role: null } : { inject_role: what };
|
|
2068
|
+
const r = await apiJSON(creds, 'PATCH', `${base}/databases/${encodeURIComponent(label)}/settings`, patch);
|
|
2069
|
+
if (JSON_MODE) return emitJSON(r.settings);
|
|
2070
|
+
const st = r.settings;
|
|
2071
|
+
info(`${green('saved')} ${bold(envVar)} ${!st.auto_inject ? yellow('not injected') : st.inject_role ? `the login ${green(st.inject_role)}` : 'the database owner'}`);
|
|
2072
|
+
muted('Takes effect on the next deploy.');
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
if (sub === 'least-privilege') {
|
|
2077
|
+
// One step to the usual safe arrangement: the app logs in as a limited role, the owner (which can change the schema) is
|
|
2078
|
+
// given to the BUILD only, as DATABASE_URL_MIGRATE, and every migration ledger stays read-only for the app's role.
|
|
2079
|
+
const label = args._[1] ?? 'primary';
|
|
2080
|
+
requireYes(args, 'The app will run as a limited role (it can read and write data but cannot change the schema) from its next deploy, and the database owner will be given to the build only, as DATABASE_URL_MIGRATE: run your migrations there.');
|
|
2081
|
+
const r = await apiJSON(creds, 'POST', `${base}/databases/${encodeURIComponent(label)}/least-privilege`, {
|
|
2082
|
+
confirm: true,
|
|
2083
|
+
...(typeof args.label === 'string' ? { label: args.label } : {}),
|
|
2084
|
+
});
|
|
2085
|
+
if (JSON_MODE) return emitJSON(r.least_privilege);
|
|
2086
|
+
const lp = r.least_privilege;
|
|
2087
|
+
const envVar = label === 'primary' ? 'DATABASE_URL' : `DATABASE_URL_${label.toUpperCase()}`;
|
|
2088
|
+
info(`${green(lp.role.created ? 'created' : 'using')} the role ${bold(lp.role.label)}; ${bold(envVar)} will carry it from the next deploy`);
|
|
2089
|
+
if (lp.protected_tables.length) info(` kept read-only for it: ${lp.protected_tables.join(', ')}`);
|
|
2090
|
+
for (const t of lp.suggestions ?? []) info(` ${yellow('look at')} ${t} ${dim(`(looks like a ledger; keep it read-only with: canner db roles ${label} write-exclusions ${lp.role.label} ${t})`)}`);
|
|
2091
|
+
info(` ${bold(lp.migration_var)} is available to the build only: run your migrations with it (for example ${dim(`DATABASE_URL=$${lp.migration_var} npx prisma migrate deploy`)}).`);
|
|
2092
|
+
muted('Run it again after your first migration: a ledger created later is also kept read-only for the app, within minutes, without this.');
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2018
2096
|
if (sub === 'roles') {
|
|
2019
2097
|
const label = args._[1] ?? 'primary';
|
|
2020
2098
|
const action = args._[2];
|
|
2021
2099
|
const path = `${base}/databases/${encodeURIComponent(label)}/roles`;
|
|
2022
2100
|
if (action === 'create') {
|
|
2023
2101
|
const name = args._[3];
|
|
2024
|
-
if (!name) die('usage: canner db roles <db> create <name> --read-only | --read-write | --replication', 2);
|
|
2025
|
-
const picked = ['read-only', 'read-write', 'replication'].filter((k) => args[k] === true);
|
|
2026
|
-
if (picked.length !== 1) die('choose exactly one of --read-only, --read-write or --
|
|
2027
|
-
const
|
|
2102
|
+
if (!name) die('usage: canner db roles <db> create <name> --read-only | --read-write | --replication | --group | --login [--member-of g1,g2] [--adopt]', 2);
|
|
2103
|
+
const picked = ['read-only', 'read-write', 'replication', 'group', 'login'].filter((k) => args[k] === true);
|
|
2104
|
+
if (picked.length !== 1) die('choose exactly one of --read-only, --read-write, --replication, --group or --login.', 2);
|
|
2105
|
+
const kind = picked[0].replace('-', '_');
|
|
2106
|
+
const body = { label: name, kind };
|
|
2107
|
+
if (args['member-of'] !== undefined) {
|
|
2108
|
+
if (kind !== 'login') die('--member-of only applies to --login.', 2);
|
|
2109
|
+
body.member_of = splitList(args['member-of']);
|
|
2110
|
+
}
|
|
2111
|
+
if (args.adopt === true) {
|
|
2112
|
+
if (kind !== 'group') die('--adopt only applies to --group.', 2);
|
|
2113
|
+
body.adopt = true;
|
|
2114
|
+
}
|
|
2115
|
+
const r = await apiJSON(creds, 'POST', path, body);
|
|
2028
2116
|
if (JSON_MODE) return emitJSON({ ...r.role, write_exclusion_suggestions: r.write_exclusion_suggestions });
|
|
2029
|
-
|
|
2117
|
+
const kindWord = { read_write: 'read-write', replication: 'replication', read_only: 'read-only', group: 'group', login: 'login' }[r.role.kind];
|
|
2118
|
+
info(`${green(args.adopt === true ? 'adopted' : 'created')} ${kindWord} ${kind === 'group' || kind === 'login' ? '' : 'role '}${bold(r.role.label)}`);
|
|
2119
|
+
if (r.role.kind === 'group') {
|
|
2120
|
+
muted('Nothing logs in as a group. Grant it privileges (and attach row-level security policies) in your migrations, then give an app login membership:');
|
|
2121
|
+
info(` ${dim('canner db roles')} ${label} ${dim('create <name> --login --member-of')} ${bold(name)}`);
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2030
2124
|
info(` ${cyan(r.role.connection_string)}`);
|
|
2125
|
+
if (r.role.kind === 'login') {
|
|
2126
|
+
info(` ${dim('member of:')} ${r.role.member_of?.length ? r.role.member_of.join(', ') : dim('no groups yet: it can connect and nothing else')}`);
|
|
2127
|
+
muted(`To run the app as this login: canner db inject ${label} ${name}`);
|
|
2128
|
+
}
|
|
2031
2129
|
if (r.role.write_excluded_tables?.length) {
|
|
2032
2130
|
info(` ${dim('kept SELECT-only:')} ${r.role.write_excluded_tables.join(', ')}`);
|
|
2033
2131
|
}
|
|
@@ -2045,7 +2143,14 @@ async function cmdDb(args) {
|
|
|
2045
2143
|
if (action === 'rotate' || action === 'rm') {
|
|
2046
2144
|
const name = args._[3];
|
|
2047
2145
|
if (!name) die(`usage: canner db roles <db> ${action} <name> --yes`, 2);
|
|
2048
|
-
|
|
2146
|
+
let rmMessage = `This drops the role "${name}"; anything using its connection string stops working.`;
|
|
2147
|
+
if (action === 'rm') {
|
|
2148
|
+
const cur = (await apiJSON(creds, 'GET', path)).roles?.find((x) => x.label === name);
|
|
2149
|
+
if (cur?.kind === 'group') {
|
|
2150
|
+
rmMessage = `This deletes the group "${name}", the grants you made to it in this database, and any row-level security policy that applies only to it. Logins that were members lose what it gave them.`;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
requireYes(args, action === 'rm' ? rmMessage : `This gives "${name}" a new password; anything using the old one stops working.`);
|
|
2049
2154
|
if (action === 'rm') {
|
|
2050
2155
|
await apiJSON(creds, 'DELETE', `${path}/${encodeURIComponent(name)}`);
|
|
2051
2156
|
if (JSON_MODE) return emitJSON({ ok: true, deleted: name });
|
|
@@ -2057,6 +2162,60 @@ async function cmdDb(args) {
|
|
|
2057
2162
|
}
|
|
2058
2163
|
return;
|
|
2059
2164
|
}
|
|
2165
|
+
if (action === 'member-of') {
|
|
2166
|
+
const name = args._[3];
|
|
2167
|
+
const list = args._[4];
|
|
2168
|
+
if (!name || list === undefined) die('usage: canner db roles <db> member-of <login> <group>[,<group>...] (use "none" to remove every membership)', 2);
|
|
2169
|
+
const groups = list === 'none' ? [] : splitList(list);
|
|
2170
|
+
const r = await apiJSON(creds, 'PATCH', `${path}/${encodeURIComponent(name)}`, { member_of: groups });
|
|
2171
|
+
if (JSON_MODE) return emitJSON(r.role);
|
|
2172
|
+
info(`${green('updated')} ${bold(name)} ${dim('member of:')} ${r.role.member_of?.length ? r.role.member_of.join(', ') : dim('no groups')}`);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
if (action === 'audit') {
|
|
2176
|
+
const r = await apiJSON(creds, 'GET', `${path}/audit`);
|
|
2177
|
+
const au = r.audit;
|
|
2178
|
+
if (JSON_MODE) emitJSON(au);
|
|
2179
|
+
else {
|
|
2180
|
+
info(`${bold('the app is given')} ${au.injected === 'owner' ? yellow('the owner') : green(au.injected)}${au.injected === 'owner' ? dim(' (change it with `canner db inject`)') : ''}`);
|
|
2181
|
+
info('');
|
|
2182
|
+
info(bold('roles'));
|
|
2183
|
+
for (const x of au.roles) {
|
|
2184
|
+
const flags = [
|
|
2185
|
+
x.can_login ? 'can log in' : 'no login', x.reads_all_data && 'reads all data', x.writes_all_data && 'writes all data',
|
|
2186
|
+
x.bypass_rls && 'bypasses RLS', x.superuser && 'superuser', x.create_role && 'creates roles', x.create_db && 'creates databases',
|
|
2187
|
+
].filter(Boolean);
|
|
2188
|
+
info(` ${pad(bold(x.label), 24)}${pad(x.kind, 13)}${dim(flags.join(', '))}${x.member_of?.length ? ` ${dim('member of')} ${x.member_of.join(', ')}` : ''}`);
|
|
2189
|
+
}
|
|
2190
|
+
if (au.tables.length > 0) {
|
|
2191
|
+
info('');
|
|
2192
|
+
info(bold('tables'));
|
|
2193
|
+
info(dim(' S select I insert U update D delete'));
|
|
2194
|
+
for (const tb of au.tables) {
|
|
2195
|
+
const rls = tb.rls_enabled ? (tb.rls_forced ? 'RLS on, forced' : 'RLS on') : 'RLS off';
|
|
2196
|
+
const pol = tb.policies.length ? ` · ${tb.policies.length} polic${tb.policies.length === 1 ? 'y' : 'ies'}` : '';
|
|
2197
|
+
info(` ${bold(tb.schema === 'public' ? tb.table : `${tb.schema}.${tb.table}`)} ${dim(`owner ${tb.owner} · ${rls}${pol}`)}`);
|
|
2198
|
+
for (const x of au.roles) {
|
|
2199
|
+
const pr = tb.privileges[x.label];
|
|
2200
|
+
if (!pr) continue;
|
|
2201
|
+
const letters = `${pr.select ? 'S' : '-'}${pr.insert ? 'I' : '-'}${pr.update ? 'U' : '-'}${pr.delete ? 'D' : '-'}`;
|
|
2202
|
+
info(` ${pad(x.label, 24)}${letters === '----' ? dim(letters) : letters}`);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
info('');
|
|
2207
|
+
if (au.findings.length === 0) info(green('no findings'));
|
|
2208
|
+
else {
|
|
2209
|
+
info(bold('findings'));
|
|
2210
|
+
for (const f of au.findings) {
|
|
2211
|
+
info(` ${f.severity === 'warn' ? yellow('warn') : dim('info')} ${f.message}${f.tables?.length ? ` ${dim(`[${f.tables.join(', ')}]`)}` : ''}${f.roles?.length ? ` ${dim(`(${f.roles.join(', ')})`)}` : ''}`);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
// For CI: --strict fails the run when anything is a warning.
|
|
2216
|
+
if (args.strict === true && au.findings.some((f) => f.severity === 'warn')) process.exitCode = 1;
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2060
2219
|
if (action === 'write-exclusions') {
|
|
2061
2220
|
const name = args._[3];
|
|
2062
2221
|
const table = args._[4];
|
|
@@ -2068,9 +2227,12 @@ async function cmdDb(args) {
|
|
|
2068
2227
|
}
|
|
2069
2228
|
const r = await apiJSON(creds, 'GET', path);
|
|
2070
2229
|
if (JSON_MODE) return emitJSON(r.roles);
|
|
2071
|
-
if (r.roles.length === 0) { muted(`no roles on ${label}. Create one with \`canner db roles ${label} create <name> --read-only
|
|
2230
|
+
if (r.roles.length === 0) { muted(`no roles on ${label}. Create one with \`canner db roles ${label} create <name> --read-only\`, or manage your own privileges with --group and --login.`); return; }
|
|
2072
2231
|
for (const x of r.roles) {
|
|
2073
|
-
|
|
2232
|
+
const kindLabel = x.kind === 'read_write' ? yellow('read-write') : x.kind === 'replication' ? cyan('replication') : x.kind === 'group' ? bold('group') : x.kind === 'login' ? green('login') : green('read-only');
|
|
2233
|
+
info(`${pad(bold(x.label), 22)}${kindLabel} ${dim(relTime(x.created_at))}`);
|
|
2234
|
+
if (x.kind === 'group') { info(` ${dim('no login: holds the grants and policies you give it')}`); continue; }
|
|
2235
|
+
if (x.kind === 'login') info(` ${dim('member of:')} ${x.member_of?.length ? x.member_of.join(', ') : dim('no groups yet')}`);
|
|
2074
2236
|
info(` ${cyan(x.connection_string)}`);
|
|
2075
2237
|
if (x.external_connection_string) info(` ${cyan(x.external_connection_string)}`);
|
|
2076
2238
|
if (x.write_excluded_tables?.length) info(` ${dim('kept SELECT-only:')} ${x.write_excluded_tables.join(', ')}`);
|
|
@@ -2229,7 +2391,46 @@ async function cmdDb(args) {
|
|
|
2229
2391
|
return;
|
|
2230
2392
|
}
|
|
2231
2393
|
|
|
2232
|
-
|
|
2394
|
+
if (action === 'upgrade') {
|
|
2395
|
+
const what = args._[2];
|
|
2396
|
+
const up = `${path}/upgrade`;
|
|
2397
|
+
if (!what || what === 'check') {
|
|
2398
|
+
const body = await apiJSON(creds, 'GET', up);
|
|
2399
|
+
if (JSON_MODE) return emitJSON(body);
|
|
2400
|
+
info(`${bold('Postgres')} ${body.current}${body.targets.length ? dim(` can be upgraded to ${body.targets.join(', ')}`) : dim(' (already on the newest version offered)')}`);
|
|
2401
|
+
for (const b of body.blockers ?? []) info(` ${yellow('blocked')} ${b.message}`);
|
|
2402
|
+
if (body.targets.length && !(body.blockers ?? []).length) muted('Upgrade with `canner db instance upgrade run --yes` (every database on the instance goes offline for a few minutes).');
|
|
2403
|
+
if (body.rollback) info(` ${dim(`the previous version (Postgres ${body.rollback.to_version}) is kept until ${body.rollback.until}: \`canner db instance upgrade rollback --yes\` goes back, \`... finish --yes\` discards it`)}`);
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
if (what === 'run') {
|
|
2407
|
+
requireYes(args, 'Upgrading restarts every database on the instance on a newer Postgres: they are offline for a few minutes. Roles, grants, policies and extensions carry over and the connection strings do not change. Point-in-time recovery starts a fresh window. If it fails, the instance is left as it was.');
|
|
2408
|
+
const done = await startAndWait(creds, base, args, 'POST', up, { ...(args.pg ? { target: String(args.pg) } : {}), confirm: true }, 'upgrading');
|
|
2409
|
+
if (!done) return;
|
|
2410
|
+
if (JSON_MODE) return emitJSON(done);
|
|
2411
|
+
info(`${green('upgraded')} the instance from Postgres ${done.result?.from_pg_version} to ${done.result?.to_pg_version}`);
|
|
2412
|
+
muted('The previous version is kept for 7 days in case you need to roll back: `canner db instance upgrade rollback --yes`.');
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
if (what === 'rollback') {
|
|
2416
|
+
requireYes(args, 'A rollback goes back to the version before the upgrade and LOSES everything written since. Point-in-time recovery starts a fresh window.');
|
|
2417
|
+
const done = await startAndWait(creds, base, args, 'POST', `${up}/rollback`, { confirm: true }, 'rolling back');
|
|
2418
|
+
if (!done) return;
|
|
2419
|
+
if (JSON_MODE) return emitJSON(done);
|
|
2420
|
+
info(`${green('rolled back')} the instance to Postgres ${done.result?.to_pg_version}`);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
if (what === 'finish') {
|
|
2424
|
+
requireYes(args, 'This discards the previous version, so a rollback is no longer possible.');
|
|
2425
|
+
const body = await apiJSON(creds, 'POST', `${up}/finish`, { confirm: true });
|
|
2426
|
+
if (JSON_MODE) return emitJSON(body);
|
|
2427
|
+
info(`${green('done')}: the previous version was removed`);
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
die('usage: canner db instance upgrade [check] | run [--pg 18] --yes | rollback --yes | finish --yes', 2);
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
die('usage: canner db instance [show] | create [--pg 17|18] [--pitr] [--window DAYS] | start | stop --yes | upgrade [check|run|rollback|finish] | destroy --yes', 2);
|
|
2233
2434
|
}
|
|
2234
2435
|
|
|
2235
2436
|
if (sub === 'pitr') {
|
|
@@ -2327,7 +2528,7 @@ async function cmdDb(args) {
|
|
|
2327
2528
|
return;
|
|
2328
2529
|
}
|
|
2329
2530
|
|
|
2330
|
-
die(`unknown db subcommand "${sub}". Use: list | init | create | info | url | rm | pg-version | export | import | migrate-in | migrate-out | copy | ops | external | rotate | backup-policy | backup | backups | diff | inspect | alerts | roles | offsite | metrics | monitoring | insights | logs | resources | extensions | tuning | restart | replication | subscriptions | instance | pitr | pitr-restore | move.`, 2);
|
|
2531
|
+
die(`unknown db subcommand "${sub}". Use: list | init | create | info | url | rm | pg-version | export | import | migrate-in | migrate-out | copy | ops | external | rotate | backup-policy | backup | backups | diff | inspect | alerts | roles | least-privilege | offsite | metrics | monitoring | insights | logs | resources | extensions | tuning | restart | replication | subscriptions | instance | pitr | pitr-restore | move.`, 2);
|
|
2331
2532
|
}
|
|
2332
2533
|
|
|
2333
2534
|
function printHelp() {
|
|
@@ -2438,14 +2639,30 @@ ${bold('Databases:')}
|
|
|
2438
2639
|
${bold('db')} monitoring [on|off --yes] [--slow-ms N] Query insights + logs on the dedicated instance (restarts it).
|
|
2439
2640
|
${bold('db')} insights [<name>] [--sort total|mean|calls|max] [--limit N] | --reset --yes
|
|
2440
2641
|
${bold('db')} logs [<name>] [--level ERROR,WARNING] [--min-ms N] [--search TEXT] [--limit N]
|
|
2441
|
-
${bold('db')} roles [<name>] List a database's roles (
|
|
2642
|
+
${bold('db')} roles [<name>] List a database's roles (presets, groups and logins).
|
|
2442
2643
|
${bold('db')} roles <name> create <role> --read-only | --read-write
|
|
2443
2644
|
A read-write role keeps a migration ledger (schema_migrations and similar)
|
|
2444
2645
|
SELECT-only automatically; the response may suggest other candidates by name.
|
|
2646
|
+
${bold('db')} roles <name> create <role> --group [--adopt]
|
|
2647
|
+
A role nobody logs in as, named by you: your migrations grant to it and attach row-level
|
|
2648
|
+
security policies to it. --adopt takes over an existing NOLOGIN role of that name.
|
|
2649
|
+
${bold('db')} roles <name> create <role> --login [--member-of g1,g2]
|
|
2650
|
+
A login that can connect and nothing else: it gets what its groups hold (no blanket read).
|
|
2651
|
+
${bold('db')} roles <name> member-of <login> <g1,g2|none> Set the groups a login belongs to, live.
|
|
2652
|
+
${bold('db')} roles <name> audit [--strict] [--json]
|
|
2653
|
+
What every role can actually do here (tables, row-level security, policies) and what looks
|
|
2654
|
+
wrong; --strict fails the run on any warning, for CI.
|
|
2445
2655
|
${bold('db')} roles <name> rotate <role> --yes
|
|
2446
2656
|
${bold('db')} roles <name> rm <role> --yes
|
|
2447
2657
|
${bold('db')} roles <name> write-exclusions <role> <table>
|
|
2448
2658
|
Keep one more table SELECT-only for a read-write role, live — no recreation.
|
|
2659
|
+
${bold('db')} check [<name>] [--category security|performance] [--strict] [--json]
|
|
2660
|
+
A read-only security and performance check of a database (every plan): policies that allow every
|
|
2661
|
+
row, functions anyone can run as their owner, views that bypass row-level security, missing primary
|
|
2662
|
+
keys and indexes, duplicate indexes. --strict fails the run on any warning, for CI.
|
|
2663
|
+
${bold('db')} inject [<name>] [<login> | owner | off | on]
|
|
2664
|
+
Which credential the app receives as DATABASE_URL: the owner's (default) or one of your
|
|
2665
|
+
logins. Takes effect on the next deploy.
|
|
2449
2666
|
${bold('db')} offsite List the off-site backup destinations (S3-compatible buckets).
|
|
2450
2667
|
${bold('db')} offsite add <name> --endpoint HOST --bucket NAME --access-key KEY --secret-key SECRET [--region R] [--folder F]
|
|
2451
2668
|
${bold('db')} offsite edit <name> [--new-name N] [--endpoint HOST] [--bucket NAME] [--access-key KEY] [--secret-key SECRET] [--region R] [--folder F]
|