runcloud 0.2.189 → 0.2.191

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.
@@ -4,7 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
6
  import { promisify } from 'node:util';
7
- import { ApiClient, friendlyApiError } from '../api.js';
7
+ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
8
8
  import { requireCredentials } from '../config.js';
9
9
  const execFileAsync = promisify(execFile);
10
10
  export const DOGFOOD_SECRET_GROUP = 'newly-dogfood';
@@ -171,26 +171,57 @@ async function projectOrgId(client, projectId) {
171
171
  }
172
172
  export async function pushDogfoodCreds(client, projectId, entries) {
173
173
  const orgId = await projectOrgId(client, projectId);
174
- await client.put(`/orgs/${encodeURIComponent(orgId)}/secret-groups/${DOGFOOD_SECRET_GROUP}`, {
175
- entries,
176
- exposeToSandbox: true,
177
- force: true,
178
- });
174
+ const path = `/orgs/${encodeURIComponent(orgId)}/secret-groups/${DOGFOOD_SECRET_GROUP}`;
175
+ if (Object.keys(entries).length === 0) {
176
+ try {
177
+ await client.delete(path);
178
+ }
179
+ catch (err) {
180
+ if (!(err instanceof ApiError && err.status === 404))
181
+ throw err;
182
+ }
183
+ return;
184
+ }
185
+ await client.put(path, { entries, exposeToSandbox: true, force: true });
186
+ }
187
+ export async function fetchServerCredentials(client) {
188
+ try {
189
+ const status = await client.get('/dogfood');
190
+ const provided = status?.serverCredentials;
191
+ return { github: provided?.github === true, gcp: provided?.gcp === true };
192
+ }
193
+ catch (err) {
194
+ if (err instanceof ApiError && err.status === 404)
195
+ return { github: false, gcp: false };
196
+ throw err;
197
+ }
198
+ }
199
+ export function credentialsToSupply(server, opts) {
200
+ return { github: Boolean(opts.githubTokenFile) || !server.github, gcp: opts.gcp && !server.gcp };
179
201
  }
180
202
  async function credsOnce(opts) {
181
203
  const { client, apiUrl } = api();
182
204
  const projectId = projectIdFrom(opts.project, apiUrl);
205
+ const supply = credentialsToSupply(await fetchServerCredentials(client), opts);
206
+ if (!supply.github && !supply.gcp) {
207
+ await pushDogfoodCreds(client, projectId, {});
208
+ console.log(`${apiUrl} mints the sessions' GitHub and GCP tokens itself; cleared any stored ones.`);
209
+ return false;
210
+ }
183
211
  const entries = {};
184
- const github = await readGithubToken({ tokenFile: opts.githubTokenFile });
185
- entries.GH_TOKEN = github.token;
186
- if (github.source === 'gh-cli') {
187
- console.log('GitHub: using your gh CLI token (all of its scopes). A fine-grained token is safer: --github-token-file.');
212
+ if (supply.github) {
213
+ const github = await readGithubToken({ tokenFile: opts.githubTokenFile });
214
+ entries.GH_TOKEN = github.token;
215
+ if (github.source === 'gh-cli') {
216
+ console.log('GitHub: using your gh CLI token (all of its scopes). A fine-grained token is safer: --github-token-file.');
217
+ }
188
218
  }
189
- if (opts.gcp)
219
+ if (supply.gcp)
190
220
  entries.GOOGLE_OAUTH_ACCESS_TOKEN = await mintDogfoodGcpToken();
191
221
  await pushDogfoodCreds(client, projectId, entries);
192
222
  console.log(`${new Date().toISOString()} credentials pushed to project ${projectId}: ${Object.keys(entries).join(', ')}` +
193
- (opts.gcp ? ' (GCP token valid ~1h)' : ''));
223
+ (supply.gcp ? ' (GCP token valid ~1h)' : ''));
224
+ return supply.gcp;
194
225
  }
195
226
  export function dogfoodSessionBody(opts) {
196
227
  const body = {
@@ -341,25 +372,40 @@ export function registerDogfood(program) {
341
372
  .action((opts) => run(async () => {
342
373
  const { client, apiUrl } = api();
343
374
  console.log(`control plane: ${apiUrl}`);
344
- console.log('1/4 GCP: minting a dogfood-agent token by impersonation...');
345
- const gcpToken = await mintDogfoodGcpToken();
346
- console.log(' ok');
347
- console.log('2/4 SOPS: dev must decrypt, prod must be refused...');
348
- const root = await repoRoot();
349
- if (root) {
350
- const result = await verifySopsBoundary(root, gcpToken);
351
- console.log(result.detail.replace(/^/gm, ' '));
352
- if (!result.prodRefused)
353
- throw new Error('the dogfood token can decrypt prod — stop and report this');
354
- if (!result.dev)
355
- throw new Error('the dogfood token cannot decrypt dev; see the error above');
375
+ const supply = credentialsToSupply(await fetchServerCredentials(client), { githubTokenFile: opts.githubTokenFile, gcp: true });
376
+ const entries = {};
377
+ if (supply.gcp) {
378
+ console.log('1/4 GCP: minting a dogfood-agent token by impersonation...');
379
+ const gcpToken = await mintDogfoodGcpToken();
380
+ entries.GOOGLE_OAUTH_ACCESS_TOKEN = gcpToken;
381
+ console.log(' ok');
382
+ console.log('2/4 SOPS: dev must decrypt, prod must be refused...');
383
+ const root = await repoRoot();
384
+ if (root) {
385
+ const result = await verifySopsBoundary(root, gcpToken);
386
+ console.log(result.detail.replace(/^/gm, ' '));
387
+ if (!result.prodRefused)
388
+ throw new Error('the dogfood token can decrypt prod — stop and report this');
389
+ if (!result.dev)
390
+ throw new Error('the dogfood token cannot decrypt dev; see the error above');
391
+ }
392
+ else {
393
+ console.log(' skipped: run setup from a monorepo checkout to verify (needs secrets/*.yaml and sops >= 3.10)');
394
+ }
356
395
  }
357
396
  else {
358
- console.log(' skipped: run setup from a monorepo checkout to verify (needs secrets/*.yaml and sops >= 3.10)');
397
+ console.log("1/4 GCP: the control plane mints the dogfood agent's token itself (dev sops key only)");
398
+ console.log("2/4 SOPS: nothing to prove from here; the agent account's IAM is the boundary");
399
+ }
400
+ if (supply.github) {
401
+ console.log('3/4 GitHub: reading a token...');
402
+ const github = await readGithubToken({ tokenFile: opts.githubTokenFile });
403
+ entries.GH_TOKEN = github.token;
404
+ console.log(` ok (${github.source})`);
405
+ }
406
+ else {
407
+ console.log('3/4 GitHub: the control plane gives sessions a Newly GitHub App token (the dogfood repo only)');
359
408
  }
360
- console.log('3/4 GitHub: reading a token...');
361
- const github = await readGithubToken({ tokenFile: opts.githubTokenFile });
362
- console.log(` ok (${github.source})`);
363
409
  console.log('4/4 Project...');
364
410
  let projectId = opts.project ?? (readState()?.apiUrl === apiUrl ? readState()?.projectId : undefined);
365
411
  if (!projectId) {
@@ -377,10 +423,17 @@ export function registerDogfood(program) {
377
423
  console.log(` using ${projectId}`);
378
424
  }
379
425
  writeState({ projectId, apiUrl });
380
- await pushDogfoodCreds(client, projectId, { GH_TOKEN: github.token, GOOGLE_OAUTH_ACCESS_TOKEN: gcpToken });
381
- console.log(' credentials stored as the project\'s "newly-dogfood" secret group (dogfood sessions only)');
382
- console.log('\nNext: `newly dogfood creds --watch` in a spare terminal (keeps the GCP token fresh),');
383
- console.log('then `newly dogfood start "<what to do>"`.');
426
+ await pushDogfoodCreds(client, projectId, entries);
427
+ console.log(Object.keys(entries).length > 0
428
+ ? ' credentials stored as the project\'s "newly-dogfood" secret group (dogfood sessions only)'
429
+ : ' no credentials stored: the control plane supplies them (any earlier ones were removed)');
430
+ if (supply.gcp) {
431
+ console.log('\nNext: `newly dogfood creds --watch` in a spare terminal (keeps the GCP token fresh),');
432
+ console.log('then `newly dogfood start "<what to do>"`.');
433
+ }
434
+ else {
435
+ console.log('\nNext: `newly dogfood start "<what to do>"`.');
436
+ }
384
437
  }));
385
438
  dogfood
386
439
  .command('creds')
@@ -390,8 +443,8 @@ export function registerDogfood(program) {
390
443
  .option('--no-gcp', 'do not mint a GCP token (sessions then cannot use sops)')
391
444
  .option('--watch', `keep refreshing every ${CREDS_REFRESH_MS / 60_000} minutes`, false)
392
445
  .action((opts) => run(async () => {
393
- await credsOnce(opts);
394
- if (!opts.watch)
446
+ const mintedGcp = await credsOnce(opts);
447
+ if (!opts.watch || !mintedGcp)
395
448
  return;
396
449
  for (;;) {
397
450
  await new Promise((resolve) => setTimeout(resolve, CREDS_REFRESH_MS));
@@ -0,0 +1,24 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ const path = '/run-cloud/admin/fleet-release-control';
3
+ export function releaseControlRequest(file) {
4
+ const stat = statSync(file);
5
+ if (!stat.isFile() || stat.size > 512 * 1024)
6
+ throw new Error('Use a JSON request file smaller than 512 KiB');
7
+ const value = JSON.parse(readFileSync(file, 'utf8'));
8
+ if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.reason !== 'string' || !value.reason.trim()
9
+ || !Number.isSafeInteger(value.expectedRevision) || Number(value.expectedRevision) < 0
10
+ || !['bootstrap', 'enroll', 'announce', 'override', 'pause', 'resume', 'rollback', 'defer'].includes(String(value.action))) {
11
+ throw new Error('Request needs action, reason and expectedRevision from the current release-control status');
12
+ }
13
+ return value;
14
+ }
15
+ export function registerFleetReleaseControlCommands(fleet, client, action) {
16
+ const release = fleet.command('release-control').description('Audited fleet targets and coordinated health-gated serve-sim rollouts');
17
+ release.command('status').description('Read targets, rollout stages, gate values and audit history').action(() => action(async () => {
18
+ console.log(JSON.stringify(await client().get(path, { signal: AbortSignal.timeout(30000) }), null, 2));
19
+ }));
20
+ release.command('apply <request.json>').description('Apply an audited target or rollout request; pause stops new grants while existing holders finish safely')
21
+ .action((file) => action(async () => {
22
+ console.log(JSON.stringify(await client().post(path, releaseControlRequest(file), { signal: AbortSignal.timeout(30000) }), null, 2));
23
+ }));
24
+ }
@@ -5,6 +5,7 @@ import { ApiClient, ApiError } from '../api.js';
5
5
  import { requireCredentials } from '../config.js';
6
6
  import { surveyFleet, surveyText } from './run-cloud-fleet-survey.js';
7
7
  import { listReleaseChannels, promoteReleaseChannel, releaseText } from './run-cloud-fleet-releases.js';
8
+ import { registerFleetReleaseControlCommands } from './run-cloud-fleet-release-control.js';
8
9
  const ROOT = '/run-cloud/admin/simulators';
9
10
  const RANGES = ['1h', '6h', '24h', '7d', '30d', '90d', '1y', '2y'];
10
11
  export const ACCESSIBILITY_CODES = ['accessibility_device_unavailable', 'accessibility_capture_failed'];
@@ -303,6 +304,7 @@ function printReport(report, json) {
303
304
  }
304
305
  export function registerRunCloudFleet(program) {
305
306
  const fleet = program.command('fleet').description('Admin fleet telemetry, diagnosis, events, failure evidence and serve-sim release channels');
307
+ registerFleetReleaseControlCommands(fleet, credentialsClient, action);
306
308
  fleet.command('survey [hostId]').description('Survey all fleet signals in seconds; authenticated admin reads, no local services')
307
309
  .option('--json', 'Structured metrics, diagnosis, observations and coverage')
308
310
  .option('--range <range>', 'Alert/input-lag window: 1h, 6h, 24h, 7d', '24h')
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.2.189';
1
+ export const CLI_VERSION = '0.2.191';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.2.189",
3
+ "version": "0.2.191",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
Binary file
Binary file