duoops 0.1.0 → 0.1.4

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
@@ -158,6 +158,23 @@ duoops runner:logs --lines 300
158
158
  duoops runner:reinstall-duoops --version 0.1.0
159
159
  ```
160
160
 
161
+ ### 7. Use the GitLab CI Component
162
+
163
+ Instead of copying `.duoops/measure-component.yml` into every project, you can reference the published component directly:
164
+
165
+ ```yaml
166
+ include:
167
+ - component: gitlab.com/youneslaaroussi/duoops/templates/duoops-measure-component@v0.1.0
168
+ inputs:
169
+ gcp_project_id: "my-project"
170
+ gcp_instance_id: "1234567890123456789"
171
+ gcp_zone: "us-central1-a"
172
+ machine_type: "e2-standard-4"
173
+ tags: ["gcp"]
174
+ ```
175
+
176
+ Make sure your runner already has DuoOps installed (the provisioning flow handles this) and that `GCP_SA_KEY_BASE64` plus any optional BigQuery variables are set in the project’s CI/CD settings. Pin to a version tag (`@v0.1.0`) and update when you’re ready to adopt new behavior.
177
+
161
178
  ## 🛠️ Development
162
179
 
163
180
  ### Project Structure
@@ -188,6 +205,13 @@ This compiles the TypeScript CLI and builds the React frontend, copying assets t
188
205
  3. Inspect the publish payload locally with `pnpm pack` (this runs the `prepack` script, which now builds the CLI, portal, and Oclif manifest automatically). Untar the generated `.tgz` if you want to double-check the contents.
189
206
  4. When you're satisfied, publish the package: `pnpm publish --access public`.
190
207
 
208
+ ## 📦 Publishing the CI Component
209
+
210
+ 1. Update `templates/duoops-measure-component.yml` and commit the changes.
211
+ 2. Let the `.gitlab-ci.yml` validation job pass (runs automatically on MRs, default branch, and tags).
212
+ 3. Tag the repository (e.g., `git tag v0.1.0 && git push origin v0.1.0`). Consumers reference the component with the tag via the snippet above.
213
+ 4. Update release notes/README with the new component version so teams know what changed.
214
+
191
215
  ## 📄 License
192
216
 
193
217
  MIT
package/bin/dev.js CHANGED
File without changes
@@ -2,11 +2,12 @@ import { BigQuery } from '@google-cloud/bigquery';
2
2
  import { confirm, input, password, select } from '@inquirer/prompts';
3
3
  import { Command } from '@oclif/core';
4
4
  import axios from 'axios';
5
- import { bold, gray, green } from 'kleur/colors';
5
+ import { bold, gray, green, yellow } from 'kleur/colors';
6
6
  import { execSync } from 'node:child_process';
7
7
  import fs from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
10
11
  import { configManager } from '../lib/config.js';
11
12
  const hasBinary = (command) => {
12
13
  try {
@@ -78,17 +79,23 @@ const createServiceAccountKey = (projectId, serviceAccount) => {
78
79
  fs.unlinkSync(tmpPath);
79
80
  return contents.toString('base64');
80
81
  };
81
- const ensureComputeApiEnabled = (projectId, log) => {
82
+ const ensureServiceEnabled = (projectId, service, label, log) => {
82
83
  try {
83
- log?.(gray('Ensuring Compute Engine API is enabled...'));
84
- execSync(`gcloud services enable compute.googleapis.com --project=${projectId} --quiet`, {
84
+ log?.(gray(`Ensuring ${label} API is enabled...`));
85
+ execSync(`gcloud services enable ${service} --project=${projectId} --quiet`, {
85
86
  stdio: 'ignore',
86
87
  });
87
88
  }
88
89
  catch {
89
- log?.(gray('Compute API enablement skipped (it may already be enabled).'));
90
+ log?.(gray(`${label} API enablement skipped (it may already be enabled).`));
90
91
  }
91
92
  };
93
+ const ensureComputeApiEnabled = (projectId, log) => {
94
+ ensureServiceEnabled(projectId, 'compute.googleapis.com', 'Compute Engine', log);
95
+ };
96
+ const ensureMonitoringApiEnabled = (projectId, log) => {
97
+ ensureServiceEnabled(projectId, 'monitoring.googleapis.com', 'Cloud Monitoring', log);
98
+ };
92
99
  const sleep = (ms) => new Promise((resolve) => {
93
100
  setTimeout(resolve, ms);
94
101
  });
@@ -184,6 +191,81 @@ const tryCreateRunnerToken = (projectPath, runnerDescription, runnerTag) => {
184
191
  catch {
185
192
  }
186
193
  };
194
+ const updateGitLabCiYml = async (projectPath, config, runnerInfo, log) => {
195
+ const ciPath = path.join(process.cwd(), '.gitlab-ci.yml');
196
+ if (!fs.existsSync(ciPath)) {
197
+ log(yellow('No .gitlab-ci.yml found. Skipping automatic update.'));
198
+ return;
199
+ }
200
+ const useComponent = await confirm({
201
+ default: true,
202
+ message: 'Add the DuoOps measure component to your .gitlab-ci.yml?',
203
+ });
204
+ if (!useComponent) {
205
+ return;
206
+ }
207
+ const method = await select({
208
+ choices: [
209
+ {
210
+ description: 'Recommended: Automatically updated via GitLab Catalog',
211
+ name: 'Use Published Component (v0.1)',
212
+ value: 'published',
213
+ },
214
+ {
215
+ description: 'Copy template file into your repository',
216
+ name: 'Use Local File Copy',
217
+ value: 'local',
218
+ },
219
+ ],
220
+ message: 'How would you like to include the component?',
221
+ });
222
+ let includeBlock = '';
223
+ if (method === 'published') {
224
+ // Determine latest version or default to 0.1
225
+ const componentPath = `gitlab.com/youneslaaroussi/duoops/duoops-measure-component@0.1`;
226
+ includeBlock = `
227
+ include:
228
+ - component: ${componentPath}
229
+ inputs:
230
+ gcp_project_id: "$GCP_PROJECT_ID"
231
+ gcp_instance_id: "$GCP_INSTANCE_ID"
232
+ gcp_zone: "$GCP_ZONE"
233
+ machine_type: "$MACHINE_TYPE"
234
+ tags: ["${runnerInfo.runnerTag}"]
235
+ `;
236
+ }
237
+ else {
238
+ // Local copy
239
+ const targetDir = path.join(process.cwd(), '.duoops');
240
+ if (!fs.existsSync(targetDir)) {
241
+ fs.mkdirSync(targetDir, { recursive: true });
242
+ }
243
+ const templatePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../templates/duoops-measure-component.yml');
244
+ if (fs.existsSync(templatePath)) {
245
+ const targetFile = path.join(targetDir, 'measure-component.yml');
246
+ fs.copyFileSync(templatePath, targetFile);
247
+ log(green(`Copied component template to ${targetFile}`));
248
+ }
249
+ else {
250
+ log(yellow(`Could not find template at ${templatePath}. Using remote reference instead.`));
251
+ }
252
+ includeBlock = `
253
+ include:
254
+ - local: .duoops/measure-component.yml
255
+ `;
256
+ }
257
+ const currentContent = fs.readFileSync(ciPath, 'utf8');
258
+ if (currentContent.includes('duoops-measure-component')) {
259
+ log(yellow('It looks like DuoOps component is already included in .gitlab-ci.yml.'));
260
+ const overwrite = await confirm({
261
+ message: 'Append the new configuration anyway?',
262
+ });
263
+ if (!overwrite)
264
+ return;
265
+ }
266
+ fs.appendFileSync(ciPath, includeBlock);
267
+ log(green('Updated .gitlab-ci.yml with DuoOps component configuration.'));
268
+ };
187
269
  export default class Init extends Command {
188
270
  static description = 'Initialize DuoOps, optionally wiring a GitLab project and GCP runner';
189
271
  async run() {
@@ -396,7 +478,8 @@ export default class Init extends Command {
396
478
  this.warn(` WARN Failed to set ${variable.key}: ${message}`);
397
479
  }
398
480
  }));
399
- this.log(gray('\nAdd the duoops measure component to your .gitlab-ci.yml (demo/.duoops/measure-component.yml is a good starting point).'));
481
+ this.log('\n');
482
+ await updateGitLabCiYml(projectPath, configManager.get(), runnerInfo, (msg) => this.log(msg));
400
483
  this.log(gray(`Use the '${runnerInfo.runnerTag}' tag on jobs that should target this runner.`));
401
484
  this.log(green('Project wiring complete!'));
402
485
  const currentConfig = configManager.get();
@@ -440,6 +523,7 @@ export default class Init extends Command {
440
523
  message: 'Runner tag (GitLab jobs will use this)',
441
524
  });
442
525
  ensureComputeApiEnabled(gcpProjectId, (message) => this.log(message));
526
+ ensureMonitoringApiEnabled(gcpProjectId, (message) => this.log(message));
443
527
  let runnerToken = tryCreateRunnerToken(projectPath, vmName, runnerTag);
444
528
  if (!runnerToken) {
445
529
  this.log(gray('Unable to create a runner token automatically. Generate one in your GitLab project (Settings → CI/CD → Runners) and paste it below.'));
@@ -171,96 +171,6 @@
171
171
  "logs.js"
172
172
  ]
173
173
  },
174
- "pipelines:list": {
175
- "aliases": [],
176
- "args": {
177
- "project": {
178
- "description": "Project ID or path (e.g. group/project)",
179
- "name": "project",
180
- "required": false
181
- }
182
- },
183
- "description": "List GitLab CI pipelines for a project",
184
- "examples": [
185
- "<%= config.bin %> <%= command.id %> group/my-project",
186
- "<%= config.bin %> <%= command.id %> 123 --limit 20 --ref main"
187
- ],
188
- "flags": {
189
- "limit": {
190
- "char": "n",
191
- "description": "Maximum number of pipelines to return",
192
- "name": "limit",
193
- "default": 10,
194
- "hasDynamicHelp": false,
195
- "multiple": false,
196
- "type": "option"
197
- },
198
- "ref": {
199
- "description": "Filter by branch or tag",
200
- "name": "ref",
201
- "hasDynamicHelp": false,
202
- "multiple": false,
203
- "type": "option"
204
- },
205
- "status": {
206
- "description": "Filter by status (created, pending, running, success, failed, canceled, skipped, manual, scheduled)",
207
- "name": "status",
208
- "hasDynamicHelp": false,
209
- "multiple": false,
210
- "type": "option"
211
- }
212
- },
213
- "hasDynamicHelp": false,
214
- "hiddenAliases": [],
215
- "id": "pipelines:list",
216
- "pluginAlias": "duoops",
217
- "pluginName": "duoops",
218
- "pluginType": "core",
219
- "strict": true,
220
- "enableJsonFlag": false,
221
- "isESM": true,
222
- "relativePath": [
223
- "dist",
224
- "commands",
225
- "pipelines",
226
- "list.js"
227
- ]
228
- },
229
- "pipelines:show": {
230
- "aliases": [],
231
- "args": {
232
- "project": {
233
- "description": "Project ID (e.g. 123456)",
234
- "name": "project",
235
- "required": true
236
- },
237
- "pipeline_id": {
238
- "description": "Pipeline ID",
239
- "name": "pipeline_id",
240
- "required": true
241
- }
242
- },
243
- "description": "Show pipeline details and jobs",
244
- "examples": [
245
- "<%= config.bin %> <%= command.id %> 12345 67890"
246
- ],
247
- "flags": {},
248
- "hasDynamicHelp": false,
249
- "hiddenAliases": [],
250
- "id": "pipelines:show",
251
- "pluginAlias": "duoops",
252
- "pluginName": "duoops",
253
- "pluginType": "core",
254
- "strict": true,
255
- "enableJsonFlag": false,
256
- "isESM": true,
257
- "relativePath": [
258
- "dist",
259
- "commands",
260
- "pipelines",
261
- "show.js"
262
- ]
263
- },
264
174
  "measure:calculate": {
265
175
  "aliases": [],
266
176
  "args": {},
@@ -410,6 +320,96 @@
410
320
  "seed.js"
411
321
  ]
412
322
  },
323
+ "pipelines:list": {
324
+ "aliases": [],
325
+ "args": {
326
+ "project": {
327
+ "description": "Project ID or path (e.g. group/project)",
328
+ "name": "project",
329
+ "required": false
330
+ }
331
+ },
332
+ "description": "List GitLab CI pipelines for a project",
333
+ "examples": [
334
+ "<%= config.bin %> <%= command.id %> group/my-project",
335
+ "<%= config.bin %> <%= command.id %> 123 --limit 20 --ref main"
336
+ ],
337
+ "flags": {
338
+ "limit": {
339
+ "char": "n",
340
+ "description": "Maximum number of pipelines to return",
341
+ "name": "limit",
342
+ "default": 10,
343
+ "hasDynamicHelp": false,
344
+ "multiple": false,
345
+ "type": "option"
346
+ },
347
+ "ref": {
348
+ "description": "Filter by branch or tag",
349
+ "name": "ref",
350
+ "hasDynamicHelp": false,
351
+ "multiple": false,
352
+ "type": "option"
353
+ },
354
+ "status": {
355
+ "description": "Filter by status (created, pending, running, success, failed, canceled, skipped, manual, scheduled)",
356
+ "name": "status",
357
+ "hasDynamicHelp": false,
358
+ "multiple": false,
359
+ "type": "option"
360
+ }
361
+ },
362
+ "hasDynamicHelp": false,
363
+ "hiddenAliases": [],
364
+ "id": "pipelines:list",
365
+ "pluginAlias": "duoops",
366
+ "pluginName": "duoops",
367
+ "pluginType": "core",
368
+ "strict": true,
369
+ "enableJsonFlag": false,
370
+ "isESM": true,
371
+ "relativePath": [
372
+ "dist",
373
+ "commands",
374
+ "pipelines",
375
+ "list.js"
376
+ ]
377
+ },
378
+ "pipelines:show": {
379
+ "aliases": [],
380
+ "args": {
381
+ "project": {
382
+ "description": "Project ID (e.g. 123456)",
383
+ "name": "project",
384
+ "required": true
385
+ },
386
+ "pipeline_id": {
387
+ "description": "Pipeline ID",
388
+ "name": "pipeline_id",
389
+ "required": true
390
+ }
391
+ },
392
+ "description": "Show pipeline details and jobs",
393
+ "examples": [
394
+ "<%= config.bin %> <%= command.id %> 12345 67890"
395
+ ],
396
+ "flags": {},
397
+ "hasDynamicHelp": false,
398
+ "hiddenAliases": [],
399
+ "id": "pipelines:show",
400
+ "pluginAlias": "duoops",
401
+ "pluginName": "duoops",
402
+ "pluginType": "core",
403
+ "strict": true,
404
+ "enableJsonFlag": false,
405
+ "isESM": true,
406
+ "relativePath": [
407
+ "dist",
408
+ "commands",
409
+ "pipelines",
410
+ "show.js"
411
+ ]
412
+ },
413
413
  "runner:logs": {
414
414
  "aliases": [],
415
415
  "args": {},
@@ -480,5 +480,5 @@
480
480
  ]
481
481
  }
482
482
  },
483
- "version": "0.1.0"
483
+ "version": "0.1.4"
484
484
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "duoops",
3
3
  "description": "Toolset for Explainable and Sustainable CI on Gitlab.",
4
- "version": "0.1.0",
4
+ "version": "0.1.4",
5
5
  "author": "Younes Laaroussi",
6
6
  "bin": {
7
7
  "duoops": "./bin/run.js"
@@ -61,7 +61,8 @@
61
61
  "files": [
62
62
  "./bin",
63
63
  "./dist",
64
- "./oclif.manifest.json"
64
+ "./oclif.manifest.json",
65
+ "./templates"
65
66
  ],
66
67
  "homepage": "https://gitlab.com/youneslaaroussi/duoops",
67
68
  "keywords": [
@@ -88,16 +89,21 @@
88
89
  }
89
90
  }
90
91
  },
91
- "repository": "youneslaaroussi/duoops",
92
- "publishConfig": {
93
- "access": "public"
92
+ "repository": {
93
+ "type": "git",
94
+ "url": "https://gitlab.com/youneslaaroussi/duoops.git"
94
95
  },
95
- "types": "dist/index.d.ts",
96
96
  "scripts": {
97
97
  "build": "shx rm -rf dist && tsc -b && pnpm --filter portal run build && shx cp -r portal/dist dist/portal",
98
98
  "lint": "eslint --ignore-pattern \"portal/**\"",
99
+ "postpack": "shx rm -f oclif.manifest.json",
99
100
  "posttest": "pnpm run lint",
101
+ "prepack": "pnpm run build && oclif manifest && oclif readme",
100
102
  "test": "mocha --forbid-only \"test/**/*.test.ts\"",
101
103
  "version": "oclif readme && git add README.md"
102
- }
103
- }
104
+ },
105
+ "publishConfig": {
106
+ "access": "public"
107
+ },
108
+ "types": "dist/index.d.ts"
109
+ }
@@ -0,0 +1,149 @@
1
+ spec:
2
+ inputs:
3
+ provider:
4
+ description: "Cloud provider (gcp or aws)"
5
+ type: string
6
+ default: "gcp"
7
+
8
+ # GCP inputs
9
+ gcp_project_id:
10
+ description: "GCP Project ID"
11
+ type: string
12
+ default: ""
13
+
14
+ gcp_instance_id:
15
+ description: "GCP Compute Engine instance ID (numeric)"
16
+ type: string
17
+ default: ""
18
+
19
+ gcp_zone:
20
+ description: "GCP zone (e.g., us-central1-a)"
21
+ type: string
22
+ default: ""
23
+
24
+ machine_type:
25
+ description: "Machine type (e.g., e2-standard-4)"
26
+ type: string
27
+
28
+ # Optional configuration
29
+ emission_budget_grams:
30
+ description: "Carbon/Emission budget in grams CO2e (optional)"
31
+ type: string
32
+ default: ""
33
+
34
+ fail_on_budget:
35
+ description: "Fail build if budget is exceeded"
36
+ type: boolean
37
+ default: false
38
+
39
+ duoops_version:
40
+ description: "duoops npm package version"
41
+ type: string
42
+ default: "latest"
43
+
44
+ stage:
45
+ description: "GitLab CI stage to run in"
46
+ type: string
47
+ default: ".post"
48
+
49
+ tags:
50
+ description: "Runner tags"
51
+ type: array
52
+ default: []
53
+
54
+ ---
55
+
56
+ duoops-measure-analysis:
57
+ stage: $[[ inputs.stage ]]
58
+ tags: $[[ inputs.tags ]]
59
+ image: google/cloud-sdk:slim
60
+ variables:
61
+ PROVIDER: $[[ inputs.provider ]]
62
+ GCP_PROJECT_ID: $[[ inputs.gcp_project_id ]]
63
+ GCP_INSTANCE_ID: $[[ inputs.gcp_instance_id ]]
64
+ GCP_ZONE: $[[ inputs.gcp_zone ]]
65
+ MACHINE_TYPE: $[[ inputs.machine_type ]]
66
+ EMISSION_BUDGET_GRAMS: $[[ inputs.emission_budget_grams ]]
67
+ FAIL_ON_BUDGET: $[[ inputs.fail_on_budget ]]
68
+ DUOOPS_VERSION: $[[ inputs.duoops_version ]]
69
+ before_script:
70
+ - |
71
+ if ! command -v duoops &> /dev/null; then
72
+ echo "duoops binary not found on runner. Ensure the runner image installs duoops." >&2
73
+ exit 1
74
+ fi
75
+ echo "Using $(duoops --version)"
76
+ script:
77
+ - |
78
+ PROVIDER=${PROVIDER:-$[[ inputs.provider ]]}
79
+ START_TIME=$(date -u -d "$CI_PIPELINE_CREATED_AT" +%Y-%m-%dT%H:%M:%SZ)
80
+ END_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
81
+
82
+ if [ -z "$GCP_SA_KEY_BASE64" ]; then
83
+ echo "GCP_SA_KEY_BASE64 is required for provider=gcp" >&2
84
+ exit 1
85
+ fi
86
+
87
+ echo "$GCP_SA_KEY_BASE64" | base64 -d > /tmp/gcp-key.json
88
+ export GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp-key.json
89
+ gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
90
+
91
+ TOKEN=$(gcloud auth print-access-token)
92
+
93
+ CPU_FILTER="resource.type=\"gce_instance\" AND resource.labels.instance_id=\"$GCP_INSTANCE_ID\" AND resource.labels.zone=\"$GCP_ZONE\" AND metric.type=\"compute.googleapis.com/instance/cpu/utilization\""
94
+ CPU_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$CPU_FILTER'''))")
95
+
96
+ RAM_USED_FILTER="resource.type=\"gce_instance\" AND resource.labels.instance_id=\"$GCP_INSTANCE_ID\" AND metric.type=\"compute.googleapis.com/instance/memory/balloon/ram_used\""
97
+ RAM_USED_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$RAM_USED_FILTER'''))")
98
+
99
+ RAM_SIZE_FILTER="resource.type=\"gce_instance\" AND resource.labels.instance_id=\"$GCP_INSTANCE_ID\" AND metric.type=\"compute.googleapis.com/instance/memory/balloon/ram_size\""
100
+ RAM_SIZE_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$RAM_SIZE_FILTER'''))")
101
+
102
+ START_SEC=$(date -d "$START_TIME" +%s)
103
+ NOW_SEC=$(date +%s)
104
+ DURATION_SEC=$((NOW_SEC - START_SEC))
105
+ EXPECTED_POINTS=$((DURATION_SEC / 60))
106
+ [ "$EXPECTED_POINTS" -lt 1 ] && EXPECTED_POINTS=1
107
+
108
+ echo "Pipeline duration: ${DURATION_SEC}s, expecting ~$EXPECTED_POINTS data points"
109
+ echo "Waiting for metrics data (GCP has ~3 min lag)..."
110
+
111
+ for i in $(seq 1 30); do
112
+ END_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
113
+ CPU_DATA=$(curl -s -H "Authorization: Bearer $TOKEN" \
114
+ "https://monitoring.googleapis.com/v3/projects/$GCP_PROJECT_ID/timeSeries?filter=$CPU_ENCODED&interval.startTime=$START_TIME&interval.endTime=$END_TIME")
115
+
116
+ POINTS=$(echo "$CPU_DATA" | python3 -c "import sys,json; d=json.load(sys.stdin); print(sum(len(ts.get('points',[])) for ts in d.get('timeSeries',[])))" 2>/dev/null || echo "0")
117
+
118
+ if [ "$POINTS" -ge "$EXPECTED_POINTS" ]; then
119
+ echo "Got $POINTS CPU data points (expected $EXPECTED_POINTS)"
120
+ break
121
+ fi
122
+ echo "Waiting for data... got $POINTS/$EXPECTED_POINTS (attempt $i/30)"
123
+ sleep 10
124
+ done
125
+
126
+ # Fetch RAM data
127
+ RAM_USED_DATA=$(curl -s -H "Authorization: Bearer $TOKEN" \
128
+ "https://monitoring.googleapis.com/v3/projects/$GCP_PROJECT_ID/timeSeries?filter=$RAM_USED_ENCODED&interval.startTime=$START_TIME&interval.endTime=$END_TIME")
129
+
130
+ RAM_SIZE_DATA=$(curl -s -H "Authorization: Bearer $TOKEN" \
131
+ "https://monitoring.googleapis.com/v3/projects/$GCP_PROJECT_ID/timeSeries?filter=$RAM_SIZE_ENCODED&interval.startTime=$START_TIME&interval.endTime=$END_TIME")
132
+
133
+ echo "$CPU_DATA" > /tmp/cpu_timeseries.json
134
+ echo "$RAM_USED_DATA" > /tmp/ram_used_timeseries.json
135
+ echo "$RAM_SIZE_DATA" > /tmp/ram_size_timeseries.json
136
+
137
+ CMD="duoops measure calculate --provider gcp --machine $MACHINE_TYPE --region $GCP_ZONE --cpu-timeseries /tmp/cpu_timeseries.json --ram-used-timeseries /tmp/ram_used_timeseries.json --ram-size-timeseries /tmp/ram_size_timeseries.json --out-md measure-report.md --out-json measure-report.json"
138
+ [ -n "${EMISSION_BUDGET_GRAMS:-}" ] && CMD="$CMD --budget $EMISSION_BUDGET_GRAMS"
139
+ [ "${FAIL_ON_BUDGET:-}" = "true" ] && CMD="$CMD --fail-on-budget"
140
+
141
+ eval "$CMD"
142
+ cat measure-report.md
143
+ artifacts:
144
+ paths:
145
+ - measure-report.md
146
+ - measure-report.json
147
+ expire_in: 30 days
148
+ rules:
149
+ - when: always