duoops 0.0.0 → 0.1.0

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/README.md CHANGED
@@ -146,6 +146,18 @@ duoops portal
146
146
 
147
147
  Open your browser to `http://localhost:3000`.
148
148
 
149
+ ### 6. Runner Utilities
150
+
151
+ Once you've provisioned a runner with `duoops init`, you can inspect and manage it directly from the CLI:
152
+
153
+ ```bash
154
+ # Tail the runner's systemd logs (defaults to gitlab-runner service)
155
+ duoops runner:logs --lines 300
156
+
157
+ # Reinstall the DuoOps CLI on the VM (uses the current CLI version by default)
158
+ duoops runner:reinstall-duoops --version 0.1.0
159
+ ```
160
+
149
161
  ## 🛠️ Development
150
162
 
151
163
  ### Project Structure
@@ -2,4 +2,7 @@ import { Command } from '@oclif/core';
2
2
  export default class Init extends Command {
3
3
  static description: string;
4
4
  run(): Promise<void>;
5
+ private collectExistingRunnerInfo;
6
+ private configureGitLabProject;
7
+ private provisionGcpRunner;
5
8
  }
@@ -1,14 +1,195 @@
1
1
  import { BigQuery } from '@google-cloud/bigquery';
2
- import { confirm, input, password } from '@inquirer/prompts';
2
+ import { confirm, input, password, select } from '@inquirer/prompts';
3
3
  import { Command } from '@oclif/core';
4
+ import axios from 'axios';
5
+ import { bold, gray, green } from 'kleur/colors';
6
+ import { execSync } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
4
10
  import { configManager } from '../lib/config.js';
11
+ const hasBinary = (command) => {
12
+ try {
13
+ execSync(`${command} --version`, { stdio: 'ignore' });
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ };
20
+ const detectGitRemotePath = () => {
21
+ try {
22
+ const remote = execSync('git remote get-url origin', { encoding: 'utf8' }).trim();
23
+ if (remote.startsWith('git@')) {
24
+ const [, repoPath] = remote.split(':');
25
+ return repoPath?.replace(/\.git$/, '');
26
+ }
27
+ if (remote.startsWith('http')) {
28
+ const url = new URL(remote);
29
+ return url.pathname.replace(/^\/+/, '').replace(/\.git$/, '');
30
+ }
31
+ }
32
+ catch { /* ignore */ }
33
+ };
34
+ const detectGcpProject = () => {
35
+ try {
36
+ const value = execSync('gcloud config get-value project', { encoding: 'utf8' }).trim();
37
+ return value && value !== '(unset)' ? value : undefined;
38
+ }
39
+ catch { }
40
+ };
41
+ const normalizeProjectPath = (project) => project.replace(/^\/+/, '');
42
+ const normalizeGitLabUrl = (url) => url.replace(/\/+$/, '');
43
+ const setGitlabVariable = async (auth, projectPath, variable) => {
44
+ const base = normalizeGitLabUrl(auth.baseUrl);
45
+ const project = encodeURIComponent(normalizeProjectPath(projectPath));
46
+ const apiUrl = `${base}/api/v4/projects/${project}/variables`;
47
+ await axios
48
+ .delete(`${apiUrl}/${variable.key}`, {
49
+ headers: { 'PRIVATE-TOKEN': auth.token },
50
+ })
51
+ .catch(() => { });
52
+ await axios.post(apiUrl, {
53
+ key: variable.key,
54
+ masked: Boolean(variable.masked),
55
+ protected: false,
56
+ value: variable.value,
57
+ }, { headers: { 'PRIVATE-TOKEN': auth.token } });
58
+ };
59
+ const ensureServiceAccount = (projectId, serviceAccount) => {
60
+ try {
61
+ execSync(`gcloud iam service-accounts describe ${serviceAccount} --project=${projectId}`, {
62
+ stdio: 'ignore',
63
+ });
64
+ }
65
+ catch {
66
+ const name = serviceAccount.split('@')[0] ?? 'duoops-runner';
67
+ execSync(`gcloud iam service-accounts create ${name} --project=${projectId} --display-name="DuoOps Runner"`, { stdio: 'inherit' });
68
+ }
69
+ try {
70
+ execSync(`gcloud projects add-iam-policy-binding ${projectId} --member="serviceAccount:${serviceAccount}" --role="roles/monitoring.viewer" --quiet`, { stdio: 'ignore' });
71
+ }
72
+ catch { /* ignore */ }
73
+ };
74
+ const createServiceAccountKey = (projectId, serviceAccount) => {
75
+ const tmpPath = path.join(os.tmpdir(), `duoops-sa-${Date.now()}.json`);
76
+ execSync(`gcloud iam service-accounts keys create ${tmpPath} --iam-account=${serviceAccount} --project=${projectId}`, { stdio: 'inherit' });
77
+ const contents = fs.readFileSync(tmpPath);
78
+ fs.unlinkSync(tmpPath);
79
+ return contents.toString('base64');
80
+ };
81
+ const ensureComputeApiEnabled = (projectId, log) => {
82
+ try {
83
+ log?.(gray('Ensuring Compute Engine API is enabled...'));
84
+ execSync(`gcloud services enable compute.googleapis.com --project=${projectId} --quiet`, {
85
+ stdio: 'ignore',
86
+ });
87
+ }
88
+ catch {
89
+ log?.(gray('Compute API enablement skipped (it may already be enabled).'));
90
+ }
91
+ };
92
+ const sleep = (ms) => new Promise((resolve) => {
93
+ setTimeout(resolve, ms);
94
+ });
95
+ const runWithRetries = async (command, options, attempts, log) => {
96
+ let delay = 2000;
97
+ for (let attempt = 1; attempt <= attempts; attempt++) {
98
+ try {
99
+ execSync(command, options);
100
+ return;
101
+ }
102
+ catch (error) {
103
+ if (attempt === attempts) {
104
+ throw error;
105
+ }
106
+ log?.(gray(`Command failed (attempt ${attempt}/${attempts}). Retrying in ${Math.round(delay / 1000)}s...`));
107
+ // eslint-disable-next-line no-await-in-loop
108
+ await sleep(delay);
109
+ delay *= 2;
110
+ }
111
+ }
112
+ };
113
+ const buildStartupScript = (opts) => `#!/usr/bin/env bash
114
+ set -euo pipefail
115
+ apt-get update
116
+ apt-get install -y curl ca-certificates git python3
117
+
118
+ # Install Node.js 20.x
119
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
120
+ apt-get install -y nodejs
121
+ npm install -g duoops@latest
122
+
123
+ # Install GitLab Runner
124
+ curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | bash
125
+ apt-get install -y gitlab-runner
126
+
127
+ INSTANCE_ID=$(curl -s -H "Metadata-Flavor: Google" http://metadata.google.internal/computeMetadata/v1/instance/id)
128
+ TOKEN=$(curl -s -H "Metadata-Flavor: Google" http://metadata.google.internal/computeMetadata/v1/instance/attributes/runner-token)
129
+
130
+ gitlab-runner register \\
131
+ --non-interactive \\
132
+ --url "${opts.gitlabUrl}" \\
133
+ --token "$TOKEN" \\
134
+ --executor shell \\
135
+ --description "${opts.vmName}"
136
+
137
+ echo "$INSTANCE_ID" > /opt/gitlab-runner-instance-id.txt
138
+
139
+ TAG_VALUE=$(curl -s -f -H "Metadata-Flavor: Google" http://metadata.google.internal/computeMetadata/v1/instance/attributes/runner-tag || echo "${opts.runnerTag}")
140
+ CONFIG_FILE=/etc/gitlab-runner/config.toml
141
+
142
+ if [ -f "$CONFIG_FILE" ]; then
143
+ TAG_ESCAPED=$(printf '%s' "$TAG_VALUE" | sed 's/"/\\"/g')
144
+ CONFIG_FILE="$CONFIG_FILE" TAG_VALUE="$TAG_ESCAPED" python3 <<'PY'
145
+ import os
146
+ import re
147
+ from pathlib import Path
148
+
149
+ config_path = Path(os.environ.get('CONFIG_FILE', '/etc/gitlab-runner/config.toml'))
150
+ tag_value = os.environ.get('TAG_VALUE', 'gcp')
151
+ if not config_path.exists():
152
+ raise SystemExit(0)
153
+
154
+ text = config_path.read_text()
155
+ pattern = re.compile('(\\[\\[runners\\]\\][\\s\\S]*?)(?=\\n\\[\\[|$)', re.MULTILINE)
156
+
157
+ def transform(block: str) -> str:
158
+ if 'run_untagged' not in block:
159
+ block = block.replace(' executor = "shell"\n', ' executor = "shell"\n run_untagged = false\n', 1)
160
+ block = re.sub(' tags = \\[[^\\]]*\\]\\n', '', block)
161
+ if 'tags =' not in block:
162
+ block = block.rstrip() + f'\n tags = ["{tag_value}","duoops"]\n'
163
+ return block
164
+
165
+ text = pattern.sub(lambda m: transform(m.group(1)), text, count=1)
166
+ config_path.write_text(text)
167
+ PY
168
+ fi
169
+
170
+ systemctl enable gitlab-runner
171
+ systemctl restart gitlab-runner
172
+ `;
173
+ const tryCreateRunnerToken = (projectPath, runnerDescription, runnerTag) => {
174
+ if (!hasBinary('glab')) {
175
+ return;
176
+ }
177
+ try {
178
+ const encoded = encodeURIComponent(normalizeProjectPath(projectPath));
179
+ const projectInfo = JSON.parse(execSync(`glab api "projects/${encoded}"`, { encoding: 'utf8' }));
180
+ const runner = execSync(`glab api --method POST "user/runners" -f runner_type=project_type -f project_id=${projectInfo.id} -f description="${runnerDescription}" -f tag_list="${runnerTag}" -f run_untagged=true`, { encoding: 'utf8' });
181
+ const payload = JSON.parse(runner);
182
+ return payload.token;
183
+ }
184
+ catch {
185
+ }
186
+ };
5
187
  export default class Init extends Command {
6
- static description = 'Initialize the configuration for DuoOps';
188
+ static description = 'Initialize DuoOps, optionally wiring a GitLab project and GCP runner';
7
189
  async run() {
8
- this.log('Welcome to DuoOps! Let\'s set up your configuration.');
190
+ this.log(bold('Welcome to DuoOps!'));
9
191
  this.log('You will need a GitLab Personal Access Token with the "api" scope.');
10
- this.log('You can generate one at: https://gitlab.com/-/user_settings/personal_access_tokens');
11
- this.log('');
192
+ this.log('Create one at https://gitlab.com/-/user_settings/personal_access_tokens\n');
12
193
  const gitlabUrl = await input({
13
194
  default: 'https://gitlab.com',
14
195
  message: 'GitLab URL',
@@ -24,8 +205,9 @@ export default class Init extends Command {
24
205
  let bigqueryTable;
25
206
  let googleProjectId;
26
207
  if (enableMeasure) {
27
- this.log('Note: You must have Google Cloud SDK installed and authenticated (gcloud auth application-default login) or a service account key.');
208
+ this.log(gray('Note: ensure the Google Cloud SDK is authenticated (gcloud auth application-default login) or supply a service account.'));
28
209
  googleProjectId = await input({
210
+ default: detectGcpProject(),
29
211
  message: 'Google Cloud Project ID',
30
212
  });
31
213
  bigqueryDataset = await input({
@@ -39,11 +221,10 @@ export default class Init extends Command {
39
221
  const shouldCreate = await confirm({
40
222
  message: `Attempt to create BigQuery Dataset (${bigqueryDataset}) and Table (${bigqueryTable}) if missing?`,
41
223
  });
42
- if (shouldCreate) {
224
+ if (shouldCreate && googleProjectId) {
43
225
  try {
44
226
  this.log('Checking BigQuery resources...');
45
227
  const bigquery = new BigQuery({ projectId: googleProjectId });
46
- // 1. Create Dataset
47
228
  const dataset = bigquery.dataset(bigqueryDataset);
48
229
  const [datasetExists] = await dataset.exists();
49
230
  if (datasetExists) {
@@ -51,10 +232,8 @@ export default class Init extends Command {
51
232
  }
52
233
  else {
53
234
  this.log(`Creating dataset '${bigqueryDataset}'...`);
54
- await dataset.create({ location: 'US' }); // Default location
55
- this.log('Dataset created.');
235
+ await dataset.create({ location: 'US' });
56
236
  }
57
- // 2. Create Table with Schema
58
237
  const table = dataset.table(bigqueryTable);
59
238
  const [tableExists] = await table.exists();
60
239
  if (tableExists) {
@@ -74,10 +253,9 @@ export default class Init extends Command {
74
253
  { name: 'cpu_utilization_avg', type: 'FLOAT' },
75
254
  { name: 'ram_utilization_avg', type: 'FLOAT' },
76
255
  { name: 'energy_kwh', type: 'FLOAT' },
77
- { name: 'total_emissions_g', type: 'FLOAT' }
256
+ { name: 'total_emissions_g', type: 'FLOAT' },
78
257
  ];
79
258
  await table.create({ schema });
80
- this.log('Table created successfully.');
81
259
  }
82
260
  }
83
261
  catch (error) {
@@ -86,12 +264,243 @@ export default class Init extends Command {
86
264
  }
87
265
  }
88
266
  }
89
- configManager.set({
267
+ const config = {
268
+ ...configManager.get(),
90
269
  gitlabToken,
91
270
  gitlabUrl,
92
271
  measure: enableMeasure ? { bigqueryDataset, bigqueryTable, googleProjectId } : undefined,
272
+ };
273
+ configManager.set(config);
274
+ this.log(green('Configuration saved.'));
275
+ this.log(`Try '${this.config.bin} pipelines:list <project>' to verify GitLab access.\n`);
276
+ const configureProject = await confirm({
277
+ default: true,
278
+ message: 'Configure a GitLab project (CI variables + optional GCP runner) now?',
279
+ });
280
+ if (!configureProject) {
281
+ return;
282
+ }
283
+ const updatedDefaultProject = await this.configureGitLabProject({ baseUrl: gitlabUrl, token: gitlabToken }, config.defaultProjectId);
284
+ if (updatedDefaultProject) {
285
+ const latest = configManager.get();
286
+ configManager.set({ ...latest, defaultProjectId: updatedDefaultProject });
287
+ }
288
+ }
289
+ async collectExistingRunnerInfo() {
290
+ const instanceName = await input({
291
+ message: 'Runner instance name (Compute Engine VM name)',
292
+ });
293
+ const gcpZone = await input({
294
+ message: 'Runner zone',
295
+ });
296
+ const gcpInstanceId = await input({
297
+ message: 'Runner instance ID (numeric)',
298
+ });
299
+ const machineType = await input({
300
+ default: 'e2-standard-4',
301
+ message: 'Runner machine type',
302
+ });
303
+ const runnerTag = await input({
304
+ default: 'gcp',
305
+ message: 'Runner tag (used in your .gitlab-ci.yml)',
306
+ });
307
+ return {
308
+ gcpInstanceId,
309
+ gcpZone,
310
+ instanceName,
311
+ machineType,
312
+ runnerTag,
313
+ };
314
+ }
315
+ async configureGitLabProject(auth, currentDefault) {
316
+ if (!auth.baseUrl || !auth.token) {
317
+ this.warn('GitLab credentials missing, skipping project configuration.');
318
+ return;
319
+ }
320
+ const projectPath = await input({
321
+ default: currentDefault ?? detectGitRemotePath(),
322
+ message: 'GitLab project path (e.g., group/project)',
323
+ });
324
+ if (!projectPath) {
325
+ this.warn('Project path is required to configure CI variables.');
326
+ }
327
+ const setAsDefault = await confirm({
328
+ default: !currentDefault,
329
+ message: 'Use this project as the default for DuoOps commands?',
330
+ });
331
+ const gcpProjectId = await input({
332
+ default: detectGcpProject(),
333
+ message: 'GCP Project ID',
334
+ });
335
+ if (!gcpProjectId) {
336
+ this.warn('GCP Project ID is required to collect metrics.');
337
+ return setAsDefault ? projectPath : undefined;
338
+ }
339
+ let serviceAccountEmail = await input({
340
+ default: `duoops-runner@${gcpProjectId}.iam.gserviceaccount.com`,
341
+ message: 'Service account email for DuoOps measurements',
342
+ });
343
+ serviceAccountEmail = serviceAccountEmail.trim();
344
+ if (!serviceAccountEmail) {
345
+ this.error('Service account email is required.');
346
+ }
347
+ ensureServiceAccount(gcpProjectId, serviceAccountEmail);
348
+ const runnerChoice = (await select({
349
+ choices: [
350
+ { name: 'Provision new GCP runner VM', value: 'provision' },
351
+ { name: 'Use existing runner', value: 'existing' },
352
+ ],
353
+ message: 'Runner setup',
354
+ }));
355
+ const runnerInfo = runnerChoice === 'provision'
356
+ ? await this.provisionGcpRunner(auth, projectPath, gcpProjectId, serviceAccountEmail)
357
+ : await this.collectExistingRunnerInfo();
358
+ const keySource = (await select({
359
+ choices: [
360
+ { name: 'Create a new key with gcloud (recommended)', value: 'create' },
361
+ { name: 'Use an existing JSON key file', value: 'file' },
362
+ ],
363
+ message: 'Service account key for CI (used by duoops measure job)',
364
+ }));
365
+ let saKeyBase64;
366
+ if (keySource === 'create') {
367
+ saKeyBase64 = createServiceAccountKey(gcpProjectId, serviceAccountEmail);
368
+ }
369
+ else {
370
+ const keyPath = await input({
371
+ message: 'Path to service account JSON key',
372
+ });
373
+ if (!keyPath) {
374
+ this.error('Key path is required.');
375
+ }
376
+ if (!fs.existsSync(keyPath)) {
377
+ this.error(`File not found: ${keyPath}`);
378
+ }
379
+ saKeyBase64 = fs.readFileSync(keyPath).toString('base64');
380
+ }
381
+ const variables = [
382
+ { key: 'GCP_PROJECT_ID', value: gcpProjectId },
383
+ { key: 'GCP_ZONE', value: runnerInfo.gcpZone },
384
+ { key: 'GCP_INSTANCE_ID', value: runnerInfo.gcpInstanceId },
385
+ { key: 'MACHINE_TYPE', value: runnerInfo.machineType },
386
+ { key: 'GCP_SA_KEY_BASE64', masked: true, value: saKeyBase64 },
387
+ ];
388
+ this.log('\nUpdating GitLab CI/CD variables...');
389
+ await Promise.all(variables.map(async (variable) => {
390
+ try {
391
+ await setGitlabVariable(auth, projectPath, variable);
392
+ this.log(green(` OK ${variable.key}`));
393
+ }
394
+ catch (error) {
395
+ const message = error instanceof Error ? error.message : String(error);
396
+ this.warn(` WARN Failed to set ${variable.key}: ${message}`);
397
+ }
398
+ }));
399
+ this.log(gray('\nAdd the duoops measure component to your .gitlab-ci.yml (demo/.duoops/measure-component.yml is a good starting point).'));
400
+ this.log(gray(`Use the '${runnerInfo.runnerTag}' tag on jobs that should target this runner.`));
401
+ this.log(green('Project wiring complete!'));
402
+ const currentConfig = configManager.get();
403
+ const existingRunner = currentConfig.runner ?? {};
404
+ const updatedConfig = {
405
+ ...currentConfig,
406
+ gitlabProjectPath: projectPath,
407
+ runner: {
408
+ ...existingRunner,
409
+ gcpInstanceId: runnerInfo.gcpInstanceId,
410
+ gcpProjectId,
411
+ gcpZone: runnerInfo.gcpZone,
412
+ instanceName: runnerInfo.instanceName,
413
+ machineType: runnerInfo.machineType,
414
+ },
415
+ };
416
+ if (setAsDefault) {
417
+ updatedConfig.defaultProjectId = projectPath;
418
+ }
419
+ configManager.set(updatedConfig);
420
+ return setAsDefault ? projectPath : undefined;
421
+ }
422
+ async provisionGcpRunner(auth, projectPath, gcpProjectId, serviceAccountEmail) {
423
+ if (!hasBinary('gcloud')) {
424
+ this.error('gcloud CLI is required to provision a runner. Install it from https://cloud.google.com/sdk');
425
+ }
426
+ const vmName = await input({
427
+ default: 'duoops-runner',
428
+ message: 'Runner VM name',
429
+ });
430
+ const gcpZone = await input({
431
+ default: 'us-central1-a',
432
+ message: 'GCP zone',
93
433
  });
94
- this.log('Configuration saved successfully!');
95
- this.log(`You can now use DuoOps commands. Try running '${this.config.bin} pipelines:list <project-id>'`);
434
+ const machineType = await input({
435
+ default: 'e2-standard-4',
436
+ message: 'Machine type',
437
+ });
438
+ const runnerTag = await input({
439
+ default: 'gcp',
440
+ message: 'Runner tag (GitLab jobs will use this)',
441
+ });
442
+ ensureComputeApiEnabled(gcpProjectId, (message) => this.log(message));
443
+ let runnerToken = tryCreateRunnerToken(projectPath, vmName, runnerTag);
444
+ if (!runnerToken) {
445
+ this.log(gray('Unable to create a runner token automatically. Generate one in your GitLab project (Settings → CI/CD → Runners) and paste it below.'));
446
+ runnerToken = await password({
447
+ mask: '*',
448
+ message: 'Runner token (starts with glrt-)',
449
+ });
450
+ }
451
+ if (!runnerToken) {
452
+ this.error('Runner token required to register the VM.');
453
+ }
454
+ const startupScript = buildStartupScript({
455
+ gitlabUrl: normalizeGitLabUrl(auth.baseUrl),
456
+ runnerTag,
457
+ vmName,
458
+ });
459
+ const scriptPath = path.join(os.tmpdir(), `duoops-runner-${Date.now()}.sh`);
460
+ fs.writeFileSync(scriptPath, startupScript);
461
+ try {
462
+ const metadataArg = `--metadata=runner-token=${JSON.stringify(runnerToken)},runner-tag=${JSON.stringify(runnerTag)}`;
463
+ const metadataFileArg = `--metadata-from-file=startup-script=${JSON.stringify(scriptPath)}`;
464
+ const command = [
465
+ 'gcloud',
466
+ 'compute',
467
+ 'instances',
468
+ 'create',
469
+ vmName,
470
+ `--project=${gcpProjectId}`,
471
+ `--zone=${gcpZone}`,
472
+ `--machine-type=${machineType}`,
473
+ `--service-account=${serviceAccountEmail}`,
474
+ '--scopes=https://www.googleapis.com/auth/cloud-platform',
475
+ metadataArg,
476
+ metadataFileArg,
477
+ '--image-family=debian-12',
478
+ '--image-project=debian-cloud',
479
+ ].join(' ');
480
+ this.log(gray('\nProvisioning VM (this may take a minute)...'));
481
+ await runWithRetries(command, { stdio: 'inherit' }, 3, (message) => {
482
+ this.log(message);
483
+ });
484
+ this.log(green('VM created. Waiting for instance ID...'));
485
+ }
486
+ finally {
487
+ fs.unlinkSync(scriptPath);
488
+ }
489
+ let gcpInstanceId = '';
490
+ try {
491
+ gcpInstanceId = execSync(`gcloud compute instances describe ${vmName} --project=${gcpProjectId} --zone=${gcpZone} --format="value(id)"`, { encoding: 'utf8' })
492
+ .toString()
493
+ .trim();
494
+ }
495
+ catch {
496
+ this.warn('Unable to read instance ID automatically. Provide it manually in CI variables.');
497
+ }
498
+ return {
499
+ gcpInstanceId,
500
+ gcpZone,
501
+ instanceName: vmName,
502
+ machineType,
503
+ runnerTag,
504
+ };
96
505
  }
97
506
  }
@@ -0,0 +1,9 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class RunnerLogs extends Command {
3
+ static description: string;
4
+ static flags: {
5
+ lines: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
6
+ unit: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ };
8
+ run(): Promise<void>;
9
+ }
@@ -0,0 +1,36 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import { execSync } from 'node:child_process';
3
+ import { configManager } from '../../lib/config.js';
4
+ export default class RunnerLogs extends Command {
5
+ static description = 'Show systemd logs from the DuoOps runner VM';
6
+ static flags = {
7
+ lines: Flags.integer({
8
+ char: 'n',
9
+ default: 200,
10
+ description: 'Number of log lines to show',
11
+ }),
12
+ unit: Flags.string({
13
+ char: 'u',
14
+ default: 'gitlab-runner',
15
+ description: 'systemd unit to inspect',
16
+ }),
17
+ };
18
+ async run() {
19
+ const { flags } = await this.parse(RunnerLogs);
20
+ const { runner } = configManager.get();
21
+ if (!runner?.instanceName || !runner?.gcpProjectId || !runner?.gcpZone) {
22
+ this.error('Runner details are missing. Re-run "duoops init" and configure a project so the runner metadata is stored locally.');
23
+ }
24
+ const command = [
25
+ 'gcloud',
26
+ 'compute',
27
+ 'ssh',
28
+ runner.instanceName,
29
+ `--project=${runner.gcpProjectId}`,
30
+ `--zone=${runner.gcpZone}`,
31
+ `--command=${JSON.stringify(`sudo journalctl -u ${flags.unit} -n ${flags.lines} --no-pager`)}`,
32
+ ].join(' ');
33
+ this.log(`Executing: ${command}`);
34
+ execSync(command, { stdio: 'inherit' });
35
+ }
36
+ }
@@ -0,0 +1,8 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class RunnerReinstallDuoops extends Command {
3
+ static description: string;
4
+ static flags: {
5
+ version: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
+ };
7
+ run(): Promise<void>;
8
+ }
@@ -0,0 +1,31 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import { execSync } from 'node:child_process';
3
+ import { configManager } from '../../lib/config.js';
4
+ export default class RunnerReinstallDuoops extends Command {
5
+ static description = 'Reinstall the DuoOps CLI on the runner VM';
6
+ static flags = {
7
+ version: Flags.string({
8
+ description: 'DuoOps version to install on the runner (defaults to current CLI version)',
9
+ }),
10
+ };
11
+ async run() {
12
+ const { flags } = await this.parse(RunnerReinstallDuoops);
13
+ const { runner } = configManager.get();
14
+ if (!runner?.instanceName || !runner?.gcpProjectId || !runner?.gcpZone) {
15
+ this.error('Runner details are missing. Re-run "duoops init" and configure a project so the runner metadata is stored locally.');
16
+ }
17
+ const targetVersion = flags.version ?? this.config.version ?? 'latest';
18
+ const remoteScript = `sudo npm install -g duoops@${targetVersion} && duoops --version`;
19
+ const command = [
20
+ 'gcloud',
21
+ 'compute',
22
+ 'ssh',
23
+ runner.instanceName,
24
+ `--project=${runner.gcpProjectId}`,
25
+ `--zone=${runner.gcpZone}`,
26
+ `--command=${JSON.stringify(remoteScript)}`,
27
+ ].join(' ');
28
+ this.log(`Installing duoops@${targetVersion} on ${runner.instanceName}...`);
29
+ execSync(command, { stdio: 'inherit' });
30
+ }
31
+ }
@@ -1,5 +1,6 @@
1
1
  export interface DuoOpsConfig {
2
2
  defaultProjectId?: string;
3
+ gitlabProjectPath?: string;
3
4
  gitlabToken?: string;
4
5
  gitlabUrl?: string;
5
6
  measure?: {
@@ -7,6 +8,13 @@ export interface DuoOpsConfig {
7
8
  bigqueryTable?: string;
8
9
  googleProjectId?: string;
9
10
  };
11
+ runner?: {
12
+ gcpInstanceId?: string;
13
+ gcpProjectId?: string;
14
+ gcpZone?: string;
15
+ instanceName?: string;
16
+ machineType?: string;
17
+ };
10
18
  }
11
19
  export declare class ConfigManager {
12
20
  private configPath;