insta 0.0.76 → 0.0.78
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/dist/commands/compute.js +86 -17
- package/dist/commands/domain.js +58 -8
- package/dist/commands/secrets.js +59 -15
- package/dist/commands/ssh-config.js +32 -1
- package/dist/index.js +15 -0
- package/package.json +1 -1
package/dist/commands/compute.js
CHANGED
|
@@ -750,7 +750,7 @@ import { homedir } from 'node:os';
|
|
|
750
750
|
import { dirname, join } from 'node:path';
|
|
751
751
|
import { execFileSync } from 'node:child_process';
|
|
752
752
|
import { createHash, randomUUID } from 'node:crypto';
|
|
753
|
-
import { aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, } from './ssh-config.js';
|
|
753
|
+
import { aliasFor, certifiesPublicKey, hasOwnedBlock, isSafeAlias, isSafeConfigValue, isSafeSSHHost, isSafeSSHUsername, isSafeTimestamp, isSSHCertificateRecord, mayWidenCAHost, parseCAPublicKey, planCertAuthority, renderConfigBlock, revertCertAuthority, upsertConfigBlock, ownedBlock, ownedBlockIsFirst } from './ssh-config.js';
|
|
754
754
|
/** Where this CLI keeps its own SSH material. Deliberately NOT ~/.ssh: we never
|
|
755
755
|
* touch a key the user already had, and a dedicated key pairs with
|
|
756
756
|
* IdentitiesOnly to avoid being identified by the wrong one. */
|
|
@@ -769,6 +769,15 @@ export const instaAliasStorePath = () => join(instaSSHDir(), 'aliases.json');
|
|
|
769
769
|
export const userKnownHostsPath = () => join(homedir(), '.ssh', 'known_hosts');
|
|
770
770
|
/** The renewal-hook command prefix. ssh-config.ts appends the validated alias. */
|
|
771
771
|
export const ENSURE_CERT_COMMAND = 'insta __ssh-ensure-cert';
|
|
772
|
+
/** The gateway's public port when the plane does not say: :2222, the port the
|
|
773
|
+
* lane shipped on (:22 waits for a compliance exception). The plane's answer
|
|
774
|
+
* in a mint response ALWAYS wins over this; it exists for stores and planes
|
|
775
|
+
* that predate the `port` field, so a working alias does not stop working. */
|
|
776
|
+
export const DEFAULT_SSH_PORT = 2222;
|
|
777
|
+
/** A TCP port as the plane returns it: an integer, 1..65535. */
|
|
778
|
+
export function isValidPort(v) {
|
|
779
|
+
return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 65535;
|
|
780
|
+
}
|
|
772
781
|
/** Never throws: a missing or hand-mangled store must degrade to "nothing is
|
|
773
782
|
* set up", not break `ssh` for every alias. */
|
|
774
783
|
export function readAliasStore(path = instaAliasStorePath()) {
|
|
@@ -800,6 +809,7 @@ export function isValidAliasRecord(r) {
|
|
|
800
809
|
return typeof v.projectId === 'string' && v.projectId !== ''
|
|
801
810
|
&& typeof v.serviceId === 'string' && v.serviceId !== ''
|
|
802
811
|
&& (v.branch === undefined || typeof v.branch === 'string')
|
|
812
|
+
&& (v.port === undefined || isValidPort(v.port))
|
|
803
813
|
&& isSafeConfigValue(v.host) && isSafeConfigValue(v.username);
|
|
804
814
|
}
|
|
805
815
|
/** Refuse to point an existing alias at a different service.
|
|
@@ -839,7 +849,7 @@ export function hostEntries(store) {
|
|
|
839
849
|
// sit where the rendering does.
|
|
840
850
|
.filter(([alias, r]) => isSafeAlias(alias) && isValidAliasRecord(r))
|
|
841
851
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
842
|
-
.map(([alias, r]) => ({ alias, hostName: r.host, user: r.username, certificateFile: instaCertPath(alias) }));
|
|
852
|
+
.map(([alias, r]) => ({ alias, hostName: r.host, user: r.username, port: r.port ?? DEFAULT_SSH_PORT, certificateFile: instaCertPath(alias) }));
|
|
843
853
|
}
|
|
844
854
|
/**
|
|
845
855
|
* When `ssh-keygen -L` output says the certificate stops being valid, or
|
|
@@ -969,6 +979,11 @@ export function validateCertResponse(body) {
|
|
|
969
979
|
// are refused rather than rendered.
|
|
970
980
|
if (!isSafeTimestamp(b.expiresAt))
|
|
971
981
|
throw new Error('the platform returned an unusable certificate expiry');
|
|
982
|
+
// Optional only because planes predating the field exist; PRESENT and wrong
|
|
983
|
+
// is refused, never coerced -- a port we made up is a connection that hangs.
|
|
984
|
+
if (b.port !== undefined && !isValidPort(b.port)) {
|
|
985
|
+
throw new Error(`the platform returned an unusable ssh port: ${JSON.stringify(String(b.port).slice(0, 16))}`);
|
|
986
|
+
}
|
|
972
987
|
// Parsed here rather than at install time so a malformed key fails before
|
|
973
988
|
// anything is written, instead of after the alias is already recorded.
|
|
974
989
|
if (b.caPublicKey !== undefined)
|
|
@@ -1235,6 +1250,40 @@ function withLockedFile(name, fn, { waitMs, staleMs, sleep, busy }) {
|
|
|
1235
1250
|
sleep(LOCK_POLL_MS);
|
|
1236
1251
|
}
|
|
1237
1252
|
}
|
|
1253
|
+
/** The block ssh_config SHOULD carry for this store, rendered the one way. */
|
|
1254
|
+
function renderInstalledBlock(store) {
|
|
1255
|
+
return renderConfigBlock({
|
|
1256
|
+
entries: hostEntries(store),
|
|
1257
|
+
identityFile: instaKeyPath(),
|
|
1258
|
+
knownHostsFile: userKnownHostsPath(),
|
|
1259
|
+
ensureCertCommand: ENSURE_CERT_COMMAND,
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
/** Whether the installed block differs from what the store renders -- the ONE
|
|
1263
|
+
* predicate for "rewrite ssh_config". A host move, a port the plane changed,
|
|
1264
|
+
* and a stanza written before the Port line existed all show up here; a
|
|
1265
|
+
* renewal whose response changed nothing does not, which matters because
|
|
1266
|
+
* OpenSSH is reading the file while the hook runs and a rename over an open
|
|
1267
|
+
* file fails on Windows. No installed block = nothing to repair (setup owns
|
|
1268
|
+
* the first write). */
|
|
1269
|
+
function configBlockStale(store) {
|
|
1270
|
+
let existing;
|
|
1271
|
+
try {
|
|
1272
|
+
existing = readFileSync(join(homedir(), '.ssh', 'config'), 'utf8');
|
|
1273
|
+
}
|
|
1274
|
+
catch {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
const installed = ownedBlock(existing);
|
|
1278
|
+
if (installed === undefined)
|
|
1279
|
+
return false;
|
|
1280
|
+
// Position is content too: a block that has slid below other configuration
|
|
1281
|
+
// is shadowed keyword by keyword (first obtained value wins), whatever its
|
|
1282
|
+
// text says. The rewrite puts it back where setup wrote it, at the top.
|
|
1283
|
+
if (!ownedBlockIsFirst(existing))
|
|
1284
|
+
return true;
|
|
1285
|
+
return installed.replace(/\n+$/, '') !== renderInstalledBlock(store).replace(/\n+$/, '');
|
|
1286
|
+
}
|
|
1238
1287
|
function installConfigBlock(store) {
|
|
1239
1288
|
const cfg = join(homedir(), '.ssh', 'config');
|
|
1240
1289
|
mkdirSync(dirname(cfg), { recursive: true, mode: 0o700 });
|
|
@@ -1246,12 +1295,7 @@ function installConfigBlock(store) {
|
|
|
1246
1295
|
// target says.
|
|
1247
1296
|
const existedBefore = pathExists(cfg);
|
|
1248
1297
|
const existing = readUserText(cfg);
|
|
1249
|
-
const block =
|
|
1250
|
-
entries: hostEntries(store),
|
|
1251
|
-
identityFile: instaKeyPath(),
|
|
1252
|
-
knownHostsFile: userKnownHostsPath(),
|
|
1253
|
-
ensureCertCommand: ENSURE_CERT_COMMAND,
|
|
1254
|
-
});
|
|
1298
|
+
const block = renderInstalledBlock(store);
|
|
1255
1299
|
// Backed up: this is the file that decides whether the user can ssh anywhere
|
|
1256
1300
|
// at all, and our block goes at the TOP of it.
|
|
1257
1301
|
writeFileAtomicSync(cfg, upsertConfigBlock(existing, block), { mode: 0o600, backup: true });
|
|
@@ -1370,6 +1414,20 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
|
|
|
1370
1414
|
try {
|
|
1371
1415
|
if (!isSafeAlias(alias))
|
|
1372
1416
|
return;
|
|
1417
|
+
// Repair BEFORE the renewal gate, because the stanza can lag the store
|
|
1418
|
+
// with no certificate due: an alias set up before the Port line existed
|
|
1419
|
+
// dials :22 on every connection, and waiting for its certificate to age
|
|
1420
|
+
// would leave it failing for up to the certificate's lifetime. The
|
|
1421
|
+
// predicate is content drift, so the common case -- an up-to-date stanza --
|
|
1422
|
+
// is one file read and a string compare, with no lock taken; only a stale
|
|
1423
|
+
// stanza takes the alias-store lock, re-checks under it and rewrites.
|
|
1424
|
+
if (configBlockStale(readAliasStore())) {
|
|
1425
|
+
withAliasStoreLock(() => {
|
|
1426
|
+
const store = readAliasStore();
|
|
1427
|
+
if (store[alias] && configBlockStale(store))
|
|
1428
|
+
installConfigBlock(store);
|
|
1429
|
+
}, { waitMs: KNOWN_HOSTS_LOCK_WAIT_MS });
|
|
1430
|
+
}
|
|
1373
1431
|
if (!certNeedsRenewal(instaCertPath(alias)))
|
|
1374
1432
|
return;
|
|
1375
1433
|
// An IDE opens several connections at once and `scp` adds more, so the
|
|
@@ -1433,9 +1491,16 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
|
|
|
1433
1491
|
return;
|
|
1434
1492
|
if (!certNeedsRenewal(instaCertPath(alias)))
|
|
1435
1493
|
return;
|
|
1494
|
+
// The plane's answer wins, on every field it answers. `moved` is the
|
|
1495
|
+
// host/user half and keeps its CA requirement below (a new host needs
|
|
1496
|
+
// an anchor); the port has no such requirement but is part of the
|
|
1497
|
+
// record all the same, and a legacy record without one gains it here
|
|
1498
|
+
// so the stanza can carry the line.
|
|
1499
|
+
const port = out.port ?? held.port ?? DEFAULT_SSH_PORT;
|
|
1436
1500
|
const moved = held.host !== out.host || held.username !== out.username;
|
|
1437
|
-
const
|
|
1438
|
-
|
|
1501
|
+
const recordChanged = moved || held.port !== port;
|
|
1502
|
+
const store = recordChanged
|
|
1503
|
+
? { ...before, [alias]: { ...held, host: out.host, username: out.username, port } }
|
|
1439
1504
|
: before;
|
|
1440
1505
|
const installed = configBlockInstalled();
|
|
1441
1506
|
const ca = out.caPublicKey;
|
|
@@ -1463,11 +1528,13 @@ export async function ensureCertForAlias(alias, timeoutMs = RENEWAL_REQUEST_TIME
|
|
|
1463
1528
|
// host into a renewal that gives up -- one failed renewal and a
|
|
1464
1529
|
// `--setup` to repair it, never a half-moved alias.
|
|
1465
1530
|
const steps = [];
|
|
1466
|
-
if (
|
|
1531
|
+
if (recordChanged)
|
|
1467
1532
|
steps.push(() => { const back = snapshotForUndo(instaAliasStorePath()); writeAliasStore(store); return back; });
|
|
1468
1533
|
if (ca !== undefined)
|
|
1469
1534
|
steps.push(() => installCertAuthority(hostPatternFor(out.host), ca));
|
|
1470
|
-
|
|
1535
|
+
// Drift, not "moved": a changed port rewrites the stanza; an unchanged
|
|
1536
|
+
// response leaves the file alone (see configBlockStale).
|
|
1537
|
+
if (installed && configBlockStale(store))
|
|
1471
1538
|
steps.push(() => installConfigBlock(store));
|
|
1472
1539
|
steps.push(() => out.staged.commit());
|
|
1473
1540
|
// ONE section from reading the anchor to committing the certificate
|
|
@@ -1752,7 +1819,7 @@ export async function computeSSH(serviceName, opts, deps = {}) {
|
|
|
1752
1819
|
assertAliasFree(before, alias, { projectId: p.projectId, serviceId: svc.id, branch });
|
|
1753
1820
|
const store = {
|
|
1754
1821
|
...before,
|
|
1755
|
-
[alias]: { projectId: p.projectId, ...(branch ? { branch } : {}), serviceId: svc.id, host: out.host, username: out.username },
|
|
1822
|
+
[alias]: { projectId: p.projectId, ...(branch ? { branch } : {}), serviceId: svc.id, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT },
|
|
1756
1823
|
};
|
|
1757
1824
|
// Whether ~/.ssh is BACKING this store, not merely whether --setup was
|
|
1758
1825
|
// passed. The store and the certificate are rewritten by every issuance,
|
|
@@ -1811,9 +1878,9 @@ export async function computeSSH(serviceName, opts, deps = {}) {
|
|
|
1811
1878
|
out.staged.discard();
|
|
1812
1879
|
}
|
|
1813
1880
|
if (opts.json)
|
|
1814
|
-
return printJson({ alias, host: out.host, username: out.username, expiresAt: out.expiresAt, configured: installed });
|
|
1881
|
+
return printJson({ alias, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT, expiresAt: out.expiresAt, configured: installed });
|
|
1815
1882
|
for (const line of sshAdvice({
|
|
1816
|
-
alias, host: out.host, username: out.username, expiresAt: out.expiresAt, serviceName: svc.name,
|
|
1883
|
+
alias, host: out.host, username: out.username, port: out.port ?? DEFAULT_SSH_PORT, expiresAt: out.expiresAt, serviceName: svc.name,
|
|
1817
1884
|
configured: installed, identityFile: instaKeyPath(), certificateFile: instaCertPath(alias),
|
|
1818
1885
|
}))
|
|
1819
1886
|
emit(line);
|
|
@@ -1830,7 +1897,7 @@ export async function computeSSH(serviceName, opts, deps = {}) {
|
|
|
1830
1897
|
*/
|
|
1831
1898
|
export function sshAdvice(r) {
|
|
1832
1899
|
const head = r.configured
|
|
1833
|
-
? [`ssh ${r.alias} → ${r.username}@${r.host}`]
|
|
1900
|
+
? [`ssh ${r.alias} → ${r.username}@${r.host} (port ${r.port})`]
|
|
1834
1901
|
: [
|
|
1835
1902
|
// Every option here is load-bearing. The key lives at
|
|
1836
1903
|
// ~/.insta/ssh/id_ed25519 and the certificate at <alias>-cert.pub;
|
|
@@ -1839,7 +1906,9 @@ export function sshAdvice(r) {
|
|
|
1839
1906
|
// this command just issued -- it fails, having printed success.
|
|
1840
1907
|
// IdentitiesOnly stops a loaded agent from spending the server's
|
|
1841
1908
|
// MaxAuthTries on unrelated keys before ours is ever tried.
|
|
1842
|
-
|
|
1909
|
+
// -p is not optional: the gateway is on :2222 and :22 is closed, so a
|
|
1910
|
+
// pasted command without it fails before the credential is ever tried.
|
|
1911
|
+
`ssh -p ${r.port} -i ${shQuote(r.identityFile)} -o CertificateFile=${shQuote(r.certificateFile)} -o IdentitiesOnly=yes ${shQuote(`${r.username}@${r.host}`)}`,
|
|
1843
1912
|
` run \`insta compute ssh ${r.serviceName} --setup\` once for the shorter \`ssh ${r.alias}\`, automatic renewal, and scp/-L support`,
|
|
1844
1913
|
];
|
|
1845
1914
|
return [...head, ` certificate valid until ${r.expiresAt}`];
|
package/dist/commands/domain.js
CHANGED
|
@@ -34,15 +34,8 @@ export async function domainSearch(keyword, opts, deps) {
|
|
|
34
34
|
info(`buy one: insta domain buy ${buyable.domainName}`);
|
|
35
35
|
}
|
|
36
36
|
export async function domainBuy(name, opts, deps) {
|
|
37
|
+
const years = whole('--years', opts.years);
|
|
37
38
|
const { api, project: p } = await domainDeps(deps);
|
|
38
|
-
// JSON.stringify drops undefined but keeps NaN as null, which the platform rejects as a type
|
|
39
|
-
// error rather than a bad term — so a malformed --years is refused here, with the reason.
|
|
40
|
-
let years;
|
|
41
|
-
if (opts.years !== undefined) {
|
|
42
|
-
years = Number(opts.years);
|
|
43
|
-
if (!Number.isInteger(years))
|
|
44
|
-
die(`--years must be a whole number of years, not ${opts.years}`);
|
|
45
|
-
}
|
|
46
39
|
const res = await api.rawRequest('POST', `/projects/${p.projectId}/domains/orders`, { domainName: name, years });
|
|
47
40
|
if (handleApproval(res, opts.json))
|
|
48
41
|
return;
|
|
@@ -138,4 +131,61 @@ export async function domainStatus(name, opts, deps) {
|
|
|
138
131
|
for (const line of domain ? domainLines(domain) : orderStatusLines(order))
|
|
139
132
|
info(line);
|
|
140
133
|
}
|
|
134
|
+
export function recordLines(records) {
|
|
135
|
+
const w = (pick) => Math.max(...records.map((r) => pick(r).length));
|
|
136
|
+
const idW = w((r) => String(r.id)), typeW = w((r) => r.type), nameW = w((r) => r.fqdn), answerW = w((r) => r.answer), ttlW = w((r) => String(r.ttl));
|
|
137
|
+
return records.map((r) => {
|
|
138
|
+
const line = ` ${String(r.id).padEnd(idW)} ${r.type.padEnd(typeW)} ${r.fqdn.padEnd(nameW)} ${r.answer.padEnd(answerW)} ${String(r.ttl).padEnd(ttlW)} ${r.priority === undefined ? '' : String(r.priority)}`.trimEnd();
|
|
139
|
+
if (r.managed)
|
|
140
|
+
return `${line} (managed${r.hostname ? ` — published for ${r.hostname}` : ' — no hostname claims it; remove drops it'})`;
|
|
141
|
+
return line;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const recordPath = (orgId, domainName) => `/orgs/${orgId}/domains/${encodeURIComponent(domainName)}/records`;
|
|
145
|
+
// Number() reads "" as 0 and "1e3" as 1000, and keeps NaN, which the platform refuses as a type error naming no flag.
|
|
146
|
+
function whole(flag, value) {
|
|
147
|
+
if (value === undefined)
|
|
148
|
+
return undefined;
|
|
149
|
+
const digits = value.trim();
|
|
150
|
+
if (!/^\d+$/.test(digits))
|
|
151
|
+
die(`${flag} must be a whole number, not ${JSON.stringify(value)}`);
|
|
152
|
+
return Number(digits);
|
|
153
|
+
}
|
|
154
|
+
export async function domainRecordsList(domainName, opts, deps) {
|
|
155
|
+
const { api, orgId } = await orgDeps(opts, deps);
|
|
156
|
+
const r = await api.request('GET', recordPath(orgId, domainName));
|
|
157
|
+
if (opts.json)
|
|
158
|
+
return printJson(r);
|
|
159
|
+
if (!r.items.length)
|
|
160
|
+
return info(`no records in ${domainName} — add one: insta domain records add ${domainName} A @ <ip>`);
|
|
161
|
+
for (const line of recordLines(r.items))
|
|
162
|
+
info(line);
|
|
163
|
+
}
|
|
164
|
+
export async function domainRecordsAdd(domainName, type, name, content, opts, deps) {
|
|
165
|
+
const body = { type: type.toUpperCase(), host: name, answer: content, ttl: whole('--ttl', opts.ttl), priority: whole('--priority', opts.priority) };
|
|
166
|
+
const { api, orgId } = await orgDeps(opts, deps);
|
|
167
|
+
const r = await api.request('POST', recordPath(orgId, domainName), body);
|
|
168
|
+
if (opts.json)
|
|
169
|
+
return printJson(r);
|
|
170
|
+
for (const line of recordLines([r]))
|
|
171
|
+
info(line);
|
|
172
|
+
}
|
|
173
|
+
export async function domainRecordsSet(domainName, id, opts, deps) {
|
|
174
|
+
const body = { type: opts.type?.toUpperCase(), host: opts.name, answer: opts.content, ttl: whole('--ttl', opts.ttl), priority: whole('--priority', opts.priority) };
|
|
175
|
+
if (Object.values(body).every((v) => v === undefined))
|
|
176
|
+
die('nothing to change — pass --type, --name, --content, --ttl or --priority');
|
|
177
|
+
const { api, orgId } = await orgDeps(opts, deps);
|
|
178
|
+
const r = await api.request('PATCH', `${recordPath(orgId, domainName)}/${encodeURIComponent(id)}`, body);
|
|
179
|
+
if (opts.json)
|
|
180
|
+
return printJson(r);
|
|
181
|
+
for (const line of recordLines([r]))
|
|
182
|
+
info(line);
|
|
183
|
+
}
|
|
184
|
+
export async function domainRecordsRemove(domainName, id, opts, deps) {
|
|
185
|
+
const { api, orgId } = await orgDeps(opts, deps);
|
|
186
|
+
const r = await api.request('DELETE', `${recordPath(orgId, domainName)}/${encodeURIComponent(id)}`);
|
|
187
|
+
if (opts.json)
|
|
188
|
+
return printJson(r);
|
|
189
|
+
info(`removed record ${id} from ${domainName}`);
|
|
190
|
+
}
|
|
141
191
|
//# sourceMappingURL=domain.js.map
|
package/dist/commands/secrets.js
CHANGED
|
@@ -162,27 +162,66 @@ async function readStdin() {
|
|
|
162
162
|
data += chunk;
|
|
163
163
|
return data.trim();
|
|
164
164
|
}
|
|
165
|
+
// Every platform SkipReason (applyTargets.ts) except 'unknown-service', which means the plan
|
|
166
|
+
// could not resolve the service, not that it correctly chose to skip it.
|
|
167
|
+
const BENIGN_SKIP_REASONS = new Set(['no-image', 'other-branch', 'caller-deploying', 'skip-deploy']);
|
|
168
|
+
// Design doc §5's three outcomes: an unwritten entry is a hard failure; a written entry with a
|
|
169
|
+
// failed or unexplained-skip service is durable but not fully live; anything else is success.
|
|
170
|
+
export function applyVerdict(entries, services) {
|
|
171
|
+
if (entries.some((e) => !e.written))
|
|
172
|
+
return 'not-written';
|
|
173
|
+
const degraded = services.some((s) => s.result === 'failed' || (s.result === 'skipped' && !BENIGN_SKIP_REASONS.has(s.reason ?? '')));
|
|
174
|
+
return degraded ? 'degraded' : 'ok';
|
|
175
|
+
}
|
|
176
|
+
// util.ts already claims 1 (plain failure) and 2 (nothing ran, re-run is safe); this state fits
|
|
177
|
+
// neither, so it takes the next code rather than overload one of theirs.
|
|
178
|
+
export function applyExitCode(verdict) {
|
|
179
|
+
return verdict === 'ok' ? 0 : verdict === 'not-written' ? 1 : 3;
|
|
180
|
+
}
|
|
181
|
+
// Pure — one line per service, extending `branch.ts`'s create/skip convention; `started` matters most, it means new billing.
|
|
182
|
+
export function applyServiceLines(services) {
|
|
183
|
+
return services.map((s) => {
|
|
184
|
+
if (s.result === 'started')
|
|
185
|
+
return ` + ${s.serviceId} started (was stopped — this now bills)`;
|
|
186
|
+
if (s.result === 'deployed')
|
|
187
|
+
return ` ~ ${s.serviceId} redeployed`;
|
|
188
|
+
if (s.result === 'failed')
|
|
189
|
+
return ` ! ${s.serviceId} failed${s.reason ? `: ${s.reason}` : ''}`;
|
|
190
|
+
return ` = ${s.serviceId}${s.reason ? ` (${s.reason})` : ''}`;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
165
193
|
// Set a user secret. Project-wide by default; --branch scopes it to one branch. --service binds
|
|
166
194
|
// it to a branch service instead, which implies the current branch (binding requires one). Value
|
|
167
195
|
// comes from the argument, or stdin when omitted (keeps secret values out of shell history).
|
|
168
|
-
export async function secretsSet(name, value, opts) {
|
|
196
|
+
export async function secretsSet(name, value, opts, deps) {
|
|
169
197
|
// An empty --service must not fall through to a project-wide WRITE. The scoping test below is a
|
|
170
198
|
// truthiness check, so `--service ''` (a client interpolating an absent variable) would have put
|
|
171
199
|
// the secret at a WIDER scope than the caller asked for, visible to every service on the branch.
|
|
172
200
|
assertServiceRef(opts.service);
|
|
173
|
-
const
|
|
174
|
-
const p = await requireProject();
|
|
201
|
+
const d = deps ?? (await loadDeps());
|
|
175
202
|
const v = value ?? (await readStdin());
|
|
176
203
|
if (!v)
|
|
177
204
|
die('value is required (pass as an argument or on stdin)');
|
|
178
|
-
const branch = opts.service ? (opts.branch ??
|
|
179
|
-
|
|
180
|
-
const
|
|
205
|
+
const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
|
|
206
|
+
// The batch always deploys on ONE branch, so the top level falls back to the linked one even when the entry itself is project-wide.
|
|
207
|
+
const entry = { kind: 'set', name, value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
|
|
208
|
+
const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
|
|
181
209
|
if (handleApproval(res, opts.json))
|
|
182
210
|
return;
|
|
211
|
+
const entries = res.body.entries ?? [];
|
|
212
|
+
const services = res.body.services ?? [];
|
|
213
|
+
const verdict = applyVerdict(entries, services);
|
|
214
|
+
process.exitCode = applyExitCode(verdict);
|
|
183
215
|
if (opts.json)
|
|
184
|
-
return printJson({ ok:
|
|
216
|
+
return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
|
|
185
217
|
info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
|
|
218
|
+
for (const line of applyServiceLines(services))
|
|
219
|
+
info(line);
|
|
220
|
+
// Verdict note on stderr, after the per-service lines: not success, and not the same failure either.
|
|
221
|
+
if (verdict === 'not-written')
|
|
222
|
+
process.stderr.write(`error: ${name} was not written\n`);
|
|
223
|
+
else if (verdict === 'degraded')
|
|
224
|
+
process.stderr.write(`warning: ${name} is saved but not applied everywhere — retry the deploy for the services marked failed above, do not resend this value\n`);
|
|
186
225
|
}
|
|
187
226
|
// Remove a user secret. --service removes only THAT service's copy (the platform has always
|
|
188
227
|
// honoured ?service= here; without the flag a name several services define stays defined).
|
|
@@ -192,21 +231,26 @@ export async function secretsUnset(name, opts, deps) {
|
|
|
192
231
|
// Service scoping REQUIRES a branch (a service exists on a branch, so the platform rejects the
|
|
193
232
|
// pair without one) — so --service defaults to the linked branch, exactly as `secrets set` does.
|
|
194
233
|
const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
parts.push(`branch=${encodeURIComponent(branch)}`);
|
|
198
|
-
if (opts.service)
|
|
199
|
-
parts.push(`service=${encodeURIComponent(opts.service)}`);
|
|
200
|
-
const qs = parts.length ? `?${parts.join('&')}` : '';
|
|
201
|
-
const res = await d.api.rawRequest('DELETE', `/projects/${d.projectId}/secrets/${encodeURIComponent(name)}${qs}`);
|
|
234
|
+
const entry = { kind: 'delete', name, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
|
|
235
|
+
const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
|
|
202
236
|
if (handleApproval(res, opts.json))
|
|
203
237
|
return;
|
|
238
|
+
const entries = res.body.entries ?? [];
|
|
239
|
+
const services = res.body.services ?? [];
|
|
240
|
+
const verdict = applyVerdict(entries, services);
|
|
241
|
+
process.exitCode = applyExitCode(verdict);
|
|
204
242
|
// The EFFECTIVE branch, not the flag: with --service and no --branch the scope that was deleted
|
|
205
243
|
// is the linked branch's, and the output has to say which scope it actually touched.
|
|
206
244
|
if (opts.json)
|
|
207
|
-
return printJson({ ok:
|
|
245
|
+
return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
|
|
208
246
|
const scope = opts.service ? `${opts.service}, branch ${branch}` : branch ? `branch ${branch}` : 'project-wide';
|
|
209
247
|
info(`unset ${name} (${scope})`);
|
|
248
|
+
for (const line of applyServiceLines(services))
|
|
249
|
+
info(line);
|
|
250
|
+
if (verdict === 'not-written')
|
|
251
|
+
process.stderr.write(`error: ${name} was not removed\n`);
|
|
252
|
+
else if (verdict === 'degraded')
|
|
253
|
+
process.stderr.write(`warning: ${name} is removed but not applied everywhere — retry the deploy for the services marked failed above\n`);
|
|
210
254
|
}
|
|
211
255
|
export async function secretsBind(envName, source, opts) {
|
|
212
256
|
if (!opts.to)
|
|
@@ -115,10 +115,12 @@ export function renderConfigBlock(o) {
|
|
|
115
115
|
throw new Error(`refusing to write an unsafe ssh HostName into ssh_config: ${JSON.stringify(e.hostName)}`);
|
|
116
116
|
if (!isSafeConfigValue(e.user))
|
|
117
117
|
throw new Error(`refusing to write an unsafe ssh User into ssh_config: ${JSON.stringify(e.user)}`);
|
|
118
|
+
if (!Number.isInteger(e.port) || e.port < 1 || e.port > 65535)
|
|
119
|
+
throw new Error(`refusing to write an unusable ssh Port into ssh_config: ${JSON.stringify(e.port)}`);
|
|
118
120
|
lines.push(`Host ${e.alias}`,
|
|
119
121
|
// Without HostName and User the alias is not routing at all: ssh resolves
|
|
120
122
|
// `api.insta` in DNS and logs in as the local OS username.
|
|
121
|
-
` HostName ${e.hostName}`, ` User ${e.user}`, ` IdentityFile ${quoteConfigPath(o.identityFile)}`, ` CertificateFile ${quoteConfigPath(e.certificateFile)}`,
|
|
123
|
+
` HostName ${e.hostName}`, ` User ${e.user}`, ` Port ${e.port}`, ` IdentityFile ${quoteConfigPath(o.identityFile)}`, ` CertificateFile ${quoteConfigPath(e.certificateFile)}`,
|
|
122
124
|
// WRITTEN, not left to the default, and this is the one keyword where
|
|
123
125
|
// being first in the file does not save us. First-wins settles a keyword
|
|
124
126
|
// two blocks both set; a keyword we never set at all goes on being filled
|
|
@@ -210,6 +212,35 @@ export function upsertConfigBlock(existing, block) {
|
|
|
210
212
|
export function hasOwnedBlock(existing) {
|
|
211
213
|
return existing.split('\n').some(isMarkerLine(BLOCK_BEGIN));
|
|
212
214
|
}
|
|
215
|
+
/** Whether nothing that OpenSSH would read precedes the owned block. OpenSSH
|
|
216
|
+
* takes the FIRST obtained value for each keyword, so a `Host *` stanza -- or a
|
|
217
|
+
* bare global `Port 22` -- above our block silently overrides the block's
|
|
218
|
+
* Port, HostName, User and credential; that is why upsertConfigBlock writes
|
|
219
|
+
* the block at the top. Comments and blank lines above it are harmless and do
|
|
220
|
+
* not count. false when there is no owned block at all. */
|
|
221
|
+
export function ownedBlockIsFirst(existing) {
|
|
222
|
+
const lines = existing.split('\n');
|
|
223
|
+
const begin = lines.findIndex(isMarkerLine(BLOCK_BEGIN));
|
|
224
|
+
if (begin === -1)
|
|
225
|
+
return false;
|
|
226
|
+
return lines.slice(0, begin).every((l) => /^\s*(#.*)?$/.test(l));
|
|
227
|
+
}
|
|
228
|
+
/** The installed owned block, BEGIN through END marker inclusive, exactly as it
|
|
229
|
+
* sits in the file; undefined when there is none (or an unterminated one). It
|
|
230
|
+
* exists so a writer can compare what IS installed with what it WOULD render
|
|
231
|
+
* and touch the file only when the two differ -- a stanza written before a
|
|
232
|
+
* keyword existed (the Port line) is the case, and "nothing changed in the
|
|
233
|
+
* response" is not the same question as "nothing would change in the file". */
|
|
234
|
+
export function ownedBlock(existing) {
|
|
235
|
+
const lines = existing.split('\n');
|
|
236
|
+
const begin = lines.findIndex(isMarkerLine(BLOCK_BEGIN));
|
|
237
|
+
if (begin === -1)
|
|
238
|
+
return undefined;
|
|
239
|
+
const end = lines.findIndex((l, n) => n > begin && isMarkerLine(BLOCK_END)(l));
|
|
240
|
+
if (end === -1)
|
|
241
|
+
return undefined;
|
|
242
|
+
return lines.slice(begin, end + 1).join('\n');
|
|
243
|
+
}
|
|
213
244
|
/** A marker is a WHOLE LINE, never a substring of one.
|
|
214
245
|
*
|
|
215
246
|
* Matching the marker text wherever it occurred made a user's comment that
|
package/dist/index.js
CHANGED
|
@@ -375,6 +375,21 @@ dom.command('list').description("Domains bought through InstaCloud in this org
|
|
|
375
375
|
.action(guard((o) => domainCmd.domainList(o)));
|
|
376
376
|
dom.command('status <name>').description("A bought domain's order and attach state").option('--json')
|
|
377
377
|
.action(guard((name, o) => domainCmd.domainStatus(name, o)));
|
|
378
|
+
const rec = dom.command('records').description('DNS records of a bought domain — the zone InstaCloud holds at the registrar');
|
|
379
|
+
rec.command('list <domain>').description('Every record in the zone, managed ones marked')
|
|
380
|
+
.option('--org <id>', "target org (default: linked project's org)").option('--json')
|
|
381
|
+
.action(guard((domain, o) => domainCmd.domainRecordsList(domain, o)));
|
|
382
|
+
rec.command('add <domain> <type> <name> <content>').description('Add a record — type A|AAAA|CNAME|ANAME|MX|TXT|SRV|NS; name "@" for the domain itself, a label like "www", or the full hostname under it')
|
|
383
|
+
.option('--ttl <seconds>', 'time to live in seconds (default 300)').option('--priority <n>', 'MX and SRV only')
|
|
384
|
+
.option('--org <id>', "target org (default: linked project's org)").option('--json')
|
|
385
|
+
.action(guard((domain, type, name, content, o) => domainCmd.domainRecordsAdd(domain, type, name, content, o)));
|
|
386
|
+
rec.command('set <domain> <id>').description('Change a record by its id (from `records list`); fields you omit keep their value')
|
|
387
|
+
.option('--type <t>', 'A|AAAA|CNAME|ANAME|MX|TXT|SRV|NS').option('--name <host>', '"@" for the domain itself, a label like "www", or the full hostname under it').option('--content <value>', 'the answer').option('--ttl <seconds>', 'time to live in seconds').option('--priority <n>', 'MX and SRV only')
|
|
388
|
+
.option('--org <id>', "target org (default: linked project's org)").option('--json')
|
|
389
|
+
.action(guard((domain, id, o) => domainCmd.domainRecordsSet(domain, id, o)));
|
|
390
|
+
rec.command('remove <domain> <id>').description('Remove a record by its id (a record InstaCloud published for a live hostname is refused)')
|
|
391
|
+
.option('--org <id>', "target org (default: linked project's org)").option('--json')
|
|
392
|
+
.action(guard((domain, id, o) => domainCmd.domainRecordsRemove(domain, id, o)));
|
|
378
393
|
const bill = program.command('billing').description('Current billing cycle overview (tier / used / included / overage / credits / forecast + per-dimension & per-project breakdown)')
|
|
379
394
|
.option('--org <id>', 'target org (default: linked project\'s org)').option('--json')
|
|
380
395
|
.action(guard((o) => billing(o)));
|