runcloud 0.2.179 → 0.2.182

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.
@@ -0,0 +1,61 @@
1
+ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
2
+ import { requireCredentials, resolveProject } from '../config.js';
3
+ export async function getGithubStatus(client, projectId) {
4
+ return (await client.get(`/projects/${encodeURIComponent(projectId)}/github/status?ahead=1`));
5
+ }
6
+ export async function pullGithubBranch(client, projectId, branch) {
7
+ await client.post(`/projects/${encodeURIComponent(projectId)}/git/branches/${encodeURIComponent(branch)}/push`);
8
+ }
9
+ export function formatGithubBranches(status) {
10
+ if (!status.connected)
11
+ return 'This project is not linked to a GitHub repository.';
12
+ const repo = `${status.owner}/${status.name}`;
13
+ if (status.ahead === undefined)
14
+ return `Linked to ${repo}, but GitHub could not be read right now. Try again shortly.`;
15
+ if (status.ahead.length === 0)
16
+ return `Linked to ${repo}. Newly has every GitHub branch at its current tip.`;
17
+ return [
18
+ `Linked to ${repo}. On GitHub, not yet in Newly at their current tip:`,
19
+ ...status.ahead.map((b) => ` ${b}`),
20
+ 'Bring one in with: newly github pull <branch>',
21
+ ].join('\n');
22
+ }
23
+ export function registerGithub(program) {
24
+ const github = program
25
+ .command('github')
26
+ .description("Work with the project's linked GitHub repository");
27
+ const run = (action) => async (opts) => {
28
+ try {
29
+ const creds = requireCredentials();
30
+ const projectId = resolveProject(process.cwd(), opts.project);
31
+ await action(new ApiClient(creds.apiUrl, creds.token), projectId);
32
+ }
33
+ catch (e) {
34
+ console.error(friendlyApiError(e));
35
+ process.exitCode = 1;
36
+ }
37
+ };
38
+ github
39
+ .command('branches')
40
+ .description('List GitHub branches that Newly does not have yet (or has at an older tip)')
41
+ .option('--project <id>', 'project id (defaults to the linked directory)')
42
+ .action(run(async (client, projectId) => {
43
+ console.log(formatGithubBranches(await getGithubStatus(client, projectId)));
44
+ }));
45
+ github
46
+ .command('pull <branch>')
47
+ .description('Bring one GitHub branch into Newly (a two-way sync of that branch) so it can be fetched and checked out')
48
+ .option('--project <id>', 'project id (defaults to the linked directory)')
49
+ .action((branch, opts) => run(async (client, projectId) => {
50
+ try {
51
+ await pullGithubBranch(client, projectId, branch);
52
+ }
53
+ catch (e) {
54
+ if (e instanceof ApiError && e.status === 403) {
55
+ throw new Error(`Pulling ${branch} needs a project admin. Ask the user to pull it from the GitHub pane in the Deploy tab.`);
56
+ }
57
+ throw e;
58
+ }
59
+ console.log(`${branch} is in Newly, synced both ways with GitHub. Run: git fetch origin ${branch} && git checkout ${branch}`);
60
+ })(opts));
61
+ }
@@ -6,6 +6,13 @@ export const num = (value) => typeof value === 'number' && Number.isFinite(value
6
6
  export const stamp = (value) => str(value) && Number.isFinite(Date.parse(String(value))) ? new Date(String(value)).toISOString() : null;
7
7
  export const time = (value) => stamp(value) ? Date.parse(String(value)) : 0;
8
8
  export const validFleetId = (value) => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/.test(value);
9
+ export function fleetReadError(reason, timeoutMs) {
10
+ if (reason === 'HTTP 401' || reason === 'HTTP 403')
11
+ return new Error(`Fleet access denied (${reason}); use an admin identity with read permission`);
12
+ if (reason === 'request_deadline_exceeded')
13
+ return new Error(`Fleet read timed out after ${timeoutMs / 1000}s (request_deadline_exceeded); retry the read and check API latency if it persists`);
14
+ return new Error(`Fleet read unavailable (${reason ?? 'invalid_response'}); check API connectivity and retry the read`);
15
+ }
9
16
  export function safeFleetText(value) {
10
17
  const text = str(value);
11
18
  return text ? text.slice(0, 8000)
@@ -1,6 +1,6 @@
1
1
  import { ApiError } from '../api.js';
2
- import { componentHosts, hostReport, hostedObservation, num, observation, row, rows, safeFleetText, stamp, str, time, validFleetId } from './run-cloud-fleet-observations.js';
3
- export const SURVEY_REQUEST_BUDGET_MS = 8000;
2
+ import { componentHosts, fleetReadError, hostReport, hostedObservation, num, observation, row, rows, safeFleetText, stamp, str, time, validFleetId } from './run-cloud-fleet-observations.js';
3
+ export const SURVEY_REQUEST_BUDGET_MS = 30000;
4
4
  const ROOT = '/run-cloud/admin/simulators';
5
5
  const RANGES = ['1h', '6h', '24h', '7d'];
6
6
  const DEVICE_LIMIT = 512, POINT_LIMIT = 800, TYPE_LIMIT = 100;
@@ -122,19 +122,21 @@ export async function surveyFleet(client, options = {}, now, requestBudgetMs = S
122
122
  throw new Error('Invalid survey request budget');
123
123
  const started = performance.now(), signal = AbortSignal.timeout(requestBudgetMs);
124
124
  const read = async (path, key, body) => {
125
+ const sourceStarted = performance.now();
126
+ const timed = (source) => ({ ...source, durationSeconds: (performance.now() - sourceStarted) / 1000 });
125
127
  try {
126
128
  const value = body ? await client.post(path, body, { signal }) : await client.get(path, { signal });
127
129
  const data = row(value);
128
130
  if (value instanceof Response || !Array.isArray(data[key]))
129
- return unavailable('invalid_response_shape');
131
+ return timed(unavailable('invalid_response_shape'));
130
132
  if (path === '/run-cloud/admin/jenkins' && data.state === 'unavailable')
131
- return { state: 'unavailable', data, reason: 'jenkins_collection_unavailable' };
133
+ return timed({ state: 'unavailable', data, reason: 'jenkins_collection_unavailable' });
132
134
  if (path === '/run-cloud/admin/fleet-operations' && (data.unavailable === true || data.configured === false))
133
- return { state: 'unavailable', data, reason: 'operation_storage_unavailable_or_unconfigured' };
134
- return { state: 'available', data, reason: null };
135
+ return timed({ state: 'unavailable', data, reason: 'operation_storage_unavailable_or_unconfigured' });
136
+ return timed({ state: 'available', data, reason: null });
135
137
  }
136
138
  catch (error) {
137
- return unavailable(error instanceof ApiError ? `HTTP ${error.status}` : signal.aborted ? 'request_budget_exhausted' : 'read_failed');
139
+ return timed(unavailable(error instanceof ApiError ? `HTTP ${error.status}` : signal.aborted ? 'request_deadline_exceeded' : 'read_failed'));
138
140
  }
139
141
  };
140
142
  const fleetPromise = read(ROOT, 'hosts');
@@ -163,7 +165,7 @@ export async function surveyFleet(client, options = {}, now, requestBudgetMs = S
163
165
  fleetPromise, inventoryPromise, sharedPromise, jenkinsPromise, operationsPromise, analyticsPromise, lagPromise,
164
166
  ]);
165
167
  if (fleet.state !== 'available')
166
- throw new Error(`Fleet read unavailable (${fleet.reason}); use an admin identity with read permission`);
168
+ throw fleetReadError(fleet.reason, requestBudgetMs);
167
169
  now ??= Date.now();
168
170
  const live = new Map(rows(fleet.data.hosts).filter(host => validFleetId(host.id)).map(host => [String(host.id), host]));
169
171
  const canonical = new Map(rows(inventory.data.hosts).filter(host => validFleetId(host.id)).map(host => [String(host.id), host]));
@@ -187,7 +189,7 @@ export async function surveyFleet(client, options = {}, now, requestBudgetMs = S
187
189
  const alerts = alertReport(analytics, now, range, options.hostId);
188
190
  const sources = Object.fromEntries(Object.entries({ fleet, inventory, shared, jenkins, operations,
189
191
  analytics: { ...analytics, state: alerts.state, reason: alerts.reason }, inputLag: { ...lag, state: inputLag.state, reason: inputLag.reason } }).map(([key, source]) => [key, {
190
- state: source.state, reason: source.reason, generatedAt: stamp(source.data.generatedAt ?? source.data.observedAt),
192
+ state: source.state, reason: source.reason, durationSeconds: source.durationSeconds ?? null, generatedAt: stamp(source.data.generatedAt ?? source.data.observedAt),
191
193
  responseAgeSeconds: stamp(source.data.generatedAt ?? source.data.observedAt) ? (now - time(source.data.generatedAt ?? source.data.observedAt)) / 1000 : null,
192
194
  meaning: 'response/collector generation time; does not renew component observations',
193
195
  }]));
@@ -214,8 +216,9 @@ export async function surveyFleet(client, options = {}, now, requestBudgetMs = S
214
216
  function age(value) { return `${value.state}, age ${value.ageSeconds === null ? 'unknown' : `${Math.round(value.ageSeconds)}s`}${value.reason ? `, ${value.reason}` : ''}`; }
215
217
  function bytes(value) { return value === null ? 'unknown' : `${(value / 1024 ** 3).toFixed(1)} GiB`; }
216
218
  export function surveyText(report) {
217
- const lines = [`Fleet survey · ${report.scope} · ${report.generatedAt} · ${report.durationSeconds.toFixed(2)}s (request budget ${report.requestBudgetSeconds}s)`,
219
+ const lines = [`Fleet survey · ${report.scope} · ${report.generatedAt} · ${report.durationSeconds.toFixed(2)}s (read deadline ${report.requestBudgetSeconds}s)`,
218
220
  `${report.summary.hosts} machine records (${report.summary.canonicalHosts ?? 'unknown'} canonical); ${report.coverage.sourcesAvailable}/7 sources available.`,
221
+ `Source read times: ${Object.entries(report.sources).map(([name, source]) => `${name}=${source.durationSeconds === null ? 'not requested' : `${source.durationSeconds.toFixed(2)}s`}`).join(', ')}.`,
219
222
  `Diagnosis states: ${Object.entries(report.summary.diagnosisStates).map(([state, count]) => `${state}=${count}`).join(', ')}.`,
220
223
  `CPU mean: ${report.summary.cpu.meanBusyPercent?.toFixed(1) ?? 'unknown'}% from ${report.summary.cpu.freshHosts}/${report.summary.cpu.totalHosts} fresh host samples. ${report.summary.cpu.meaning}.`];
221
224
  if (report.alerts.state === 'available') {
@@ -1,4 +1,4 @@
1
- import { row, rows, str, num, stamp, time, pick, observation, componentHosts, hostReport, safeFleetText, validFleetId } from './run-cloud-fleet-observations.js';
1
+ import { row, rows, str, num, stamp, time, pick, observation, componentHosts, fleetReadError, hostReport, safeFleetText, validFleetId } from './run-cloud-fleet-observations.js';
2
2
  export { safeFleetText } from './run-cloud-fleet-observations.js';
3
3
  import { readFileSync, statSync } from 'node:fs';
4
4
  import { ApiClient, ApiError } from '../api.js';
@@ -25,14 +25,15 @@ function validate(options) {
25
25
  bound(options.eventPages, 1, 10, 'event-pages');
26
26
  }
27
27
  async function readSource(client, path) {
28
+ const signal = AbortSignal.timeout(30000);
28
29
  try {
29
- const data = await client.get(path, { signal: AbortSignal.timeout(30000) });
30
+ const data = await client.get(path, { signal });
30
31
  if (!data || typeof data !== 'object' || Array.isArray(data) || data instanceof Response)
31
32
  return { state: 'unavailable', data: {}, reason: 'invalid_json_response' };
32
33
  return { state: 'available', data: row(data), reason: null };
33
34
  }
34
35
  catch (error) {
35
- return { state: 'unavailable', data: {}, reason: error instanceof ApiError ? `HTTP ${error.status}` : 'read_failed' };
36
+ return { state: 'unavailable', data: {}, reason: error instanceof ApiError ? `HTTP ${error.status}` : signal.aborted ? 'request_deadline_exceeded' : 'read_failed' };
36
37
  }
37
38
  }
38
39
  export async function readFleetEvents(client, options = {}) {
@@ -169,7 +170,7 @@ export async function inspectFleet(client, options = {}, now) {
169
170
  query.set('include', 'details');
170
171
  const fleet = await readSource(client, ROOT);
171
172
  if (fleet.state === 'unavailable' || !Array.isArray(fleet.data.hosts))
172
- throw new Error(`Fleet read unavailable (${fleet.reason ?? 'invalid_response'}); use an admin identity with read permission`);
173
+ throw fleetReadError(fleet.reason, 30000);
173
174
  const [shared, diagnostics, events, history, jenkins] = await Promise.all([
174
175
  readSource(client, `${ROOT}/operator-inventory`),
175
176
  readSource(client, `${ROOT}/diagnostics?${query}`),
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { registerPull } from './commands/pull.js';
10
10
  import { registerDeploy } from './commands/deploy.js';
11
11
  import { registerMisc } from './commands/misc.js';
12
12
  import { registerStack } from './commands/stack.js';
13
+ import { registerGithub } from './commands/github.js';
13
14
  import { registerCreds } from './commands/creds.js';
14
15
  import { registerBuild } from './commands/build.js';
15
16
  import { registerServices } from './commands/services.js';
@@ -37,6 +38,7 @@ registerDeploy(program);
37
38
  registerAi(program);
38
39
  registerMisc(program);
39
40
  registerStack(program);
41
+ registerGithub(program);
40
42
  registerCreds(program);
41
43
  registerBuild(program);
42
44
  registerServices(program);
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.2.179';
1
+ export const CLI_VERSION = '0.2.182';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.2.179",
3
+ "version": "0.2.182",
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