duoops 0.1.3 → 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.
@@ -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 {
@@ -190,6 +191,81 @@ const tryCreateRunnerToken = (projectPath, runnerDescription, runnerTag) => {
190
191
  catch {
191
192
  }
192
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
+ };
193
269
  export default class Init extends Command {
194
270
  static description = 'Initialize DuoOps, optionally wiring a GitLab project and GCP runner';
195
271
  async run() {
@@ -402,7 +478,8 @@ export default class Init extends Command {
402
478
  this.warn(` WARN Failed to set ${variable.key}: ${message}`);
403
479
  }
404
480
  }));
405
- 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));
406
483
  this.log(gray(`Use the '${runnerInfo.runnerTag}' tag on jobs that should target this runner.`));
407
484
  this.log(green('Project wiring complete!'));
408
485
  const currentConfig = configManager.get();
@@ -480,5 +480,5 @@
480
480
  ]
481
481
  }
482
482
  },
483
- "version": "0.1.3"
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.3",
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": [
@@ -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