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.
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 {
|
|
@@ -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(
|
|
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();
|
package/oclif.manifest.json
CHANGED
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": [
|
|
@@ -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
|