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 +24 -0
- package/bin/dev.js +0 -0
- package/dist/commands/init.js +90 -6
- package/oclif.manifest.json +91 -91
- package/package.json +14 -8
- package/templates/duoops-measure-component.yml +149 -0
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
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
82
|
+
const ensureServiceEnabled = (projectId, service, label, log) => {
|
|
82
83
|
try {
|
|
83
|
-
log?.(gray(
|
|
84
|
-
execSync(`gcloud services enable
|
|
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(
|
|
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(
|
|
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.'));
|
package/oclif.manifest.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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":
|
|
92
|
-
|
|
93
|
-
"
|
|
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
|