gitgreen 0.1.1 → 1.0.1
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/LICENSE +21 -0
- package/README.md +213 -49
- package/dist/cli.js +204 -28
- package/dist/init.js +582 -40
- package/dist/lib/aws/cloudwatch.js +110 -0
- package/dist/lib/carbon/carbon-calculator.js +9 -7
- package/dist/lib/gitlab/report-formatter.js +6 -1
- package/package.json +1 -1
package/dist/init.js
CHANGED
|
@@ -10,6 +10,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
10
10
|
const axios_1 = __importDefault(require("axios"));
|
|
11
11
|
const prompts_1 = __importDefault(require("prompts"));
|
|
12
12
|
const kleur_1 = require("kleur");
|
|
13
|
+
const power_profile_repository_1 = require("./lib/carbon/power-profile-repository");
|
|
13
14
|
const hasGlab = () => {
|
|
14
15
|
try {
|
|
15
16
|
(0, child_process_1.execSync)('glab --version', { stdio: 'ignore' });
|
|
@@ -80,12 +81,19 @@ const setVariable = async (auth, project, key, value, masked = false) => {
|
|
|
80
81
|
return setVariableApi(auth.baseUrl, auth.pat, project, key, value, masked);
|
|
81
82
|
}
|
|
82
83
|
};
|
|
83
|
-
const generateCiJob = (opts
|
|
84
|
-
const { runnerTag, carbonBudget, failOnBudget } = opts;
|
|
85
|
-
let inputs = `
|
|
86
|
-
gcp_instance_id: $GCP_INSTANCE_ID
|
|
87
|
-
gcp_zone: $GCP_ZONE
|
|
84
|
+
const generateCiJob = (opts) => {
|
|
85
|
+
const { provider, runnerTag, carbonBudget, failOnBudget } = opts;
|
|
86
|
+
let inputs = ` provider: ${provider}
|
|
88
87
|
machine_type: $MACHINE_TYPE`;
|
|
88
|
+
if (provider === 'gcp') {
|
|
89
|
+
inputs += `\n gcp_project_id: $GCP_PROJECT_ID\n gcp_instance_id: $GCP_INSTANCE_ID\n gcp_zone: $GCP_ZONE`;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
inputs += `\n aws_region: $AWS_REGION\n aws_instance_id: $AWS_INSTANCE_ID`;
|
|
93
|
+
if (opts.aws?.periodSeconds) {
|
|
94
|
+
inputs += `\n aws_period_seconds: "${opts.aws.periodSeconds}"`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
89
97
|
if (carbonBudget) {
|
|
90
98
|
inputs += `\n carbon_budget_grams: "${carbonBudget}"`;
|
|
91
99
|
}
|
|
@@ -95,7 +103,7 @@ const generateCiJob = (opts = {}) => {
|
|
|
95
103
|
const tagsSection = runnerTag ? `\n tags:\n - ${runnerTag}` : '';
|
|
96
104
|
return `# GitGreen carbon analysis (generated by gitgreen init)
|
|
97
105
|
include:
|
|
98
|
-
- component: gitlab.com/youneslaaroussi/gitgreen/gitgreen-cli-component@
|
|
106
|
+
- component: gitlab.com/youneslaaroussi/gitgreen/gitgreen-cli-component@main
|
|
99
107
|
inputs:
|
|
100
108
|
${inputs}
|
|
101
109
|
|
|
@@ -103,6 +111,565 @@ carbon_analysis:
|
|
|
103
111
|
extends: .gitgreen-carbon-analysis${tagsSection}
|
|
104
112
|
`;
|
|
105
113
|
};
|
|
114
|
+
const applyCiJobSnippet = async (ciJob) => {
|
|
115
|
+
const { addCiJob } = await (0, prompts_1.default)({
|
|
116
|
+
type: 'confirm',
|
|
117
|
+
name: 'addCiJob',
|
|
118
|
+
message: 'Add job to .gitlab-ci.yml?',
|
|
119
|
+
initial: true
|
|
120
|
+
});
|
|
121
|
+
const ciPath = path_1.default.join(process.cwd(), '.gitlab-ci.yml');
|
|
122
|
+
if (addCiJob) {
|
|
123
|
+
if (fs_1.default.existsSync(ciPath)) {
|
|
124
|
+
const backupPath = `/tmp/gitlab-ci-backup-${Date.now()}.yml`;
|
|
125
|
+
const existing = fs_1.default.readFileSync(ciPath, 'utf8');
|
|
126
|
+
fs_1.default.writeFileSync(backupPath, existing);
|
|
127
|
+
console.log((0, kleur_1.gray)(`Backup saved to: ${backupPath}`));
|
|
128
|
+
fs_1.default.writeFileSync(ciPath, existing + '\n' + ciJob);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
fs_1.default.writeFileSync(ciPath, 'stages:\n - test\n\n' + ciJob);
|
|
132
|
+
}
|
|
133
|
+
console.log((0, kleur_1.green)('Updated .gitlab-ci.yml'));
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
console.log('\nCI job snippet:\n');
|
|
137
|
+
console.log(ciJob);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const getAwsMachineTypes = (() => {
|
|
141
|
+
let cached = null;
|
|
142
|
+
return () => {
|
|
143
|
+
if (cached)
|
|
144
|
+
return cached;
|
|
145
|
+
try {
|
|
146
|
+
const repo = new power_profile_repository_1.PowerProfileRepository(path_1.default.join(__dirname, '..', 'data'));
|
|
147
|
+
cached = repo.listMachines('aws');
|
|
148
|
+
return cached;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
cached = [];
|
|
152
|
+
return cached;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
})();
|
|
156
|
+
const promptMachineType = async (message, initial) => {
|
|
157
|
+
const machines = getAwsMachineTypes();
|
|
158
|
+
if (!machines.length) {
|
|
159
|
+
const { manualMachine } = await (0, prompts_1.default)({
|
|
160
|
+
type: 'text',
|
|
161
|
+
name: 'manualMachine',
|
|
162
|
+
message,
|
|
163
|
+
initial
|
|
164
|
+
});
|
|
165
|
+
return manualMachine || initial;
|
|
166
|
+
}
|
|
167
|
+
const choices = [
|
|
168
|
+
...machines.map(m => ({ title: m, value: m })),
|
|
169
|
+
{ title: 'Enter manually', value: '_manual_' }
|
|
170
|
+
];
|
|
171
|
+
const { selectedType } = await (0, prompts_1.default)({
|
|
172
|
+
type: 'select',
|
|
173
|
+
name: 'selectedType',
|
|
174
|
+
message,
|
|
175
|
+
choices,
|
|
176
|
+
initial: Math.max(0, machines.indexOf(initial))
|
|
177
|
+
});
|
|
178
|
+
if (selectedType === '_manual_') {
|
|
179
|
+
const { manualMachine } = await (0, prompts_1.default)({
|
|
180
|
+
type: 'text',
|
|
181
|
+
name: 'manualMachine',
|
|
182
|
+
message,
|
|
183
|
+
initial
|
|
184
|
+
});
|
|
185
|
+
return manualMachine || initial;
|
|
186
|
+
}
|
|
187
|
+
return selectedType;
|
|
188
|
+
};
|
|
189
|
+
const listAwsInstances = (region, env) => {
|
|
190
|
+
try {
|
|
191
|
+
const query = "Reservations[].Instances[].{InstanceId:InstanceId,Type:InstanceType,State:State.Name,Name:Tags[?Key==\\`Name\\`]|[0].Value}";
|
|
192
|
+
const raw = (0, child_process_1.execSync)(`aws ec2 describe-instances --region ${region} --query "${query}" --output json`, { encoding: 'utf8', env });
|
|
193
|
+
const parsed = JSON.parse(raw);
|
|
194
|
+
return parsed
|
|
195
|
+
.filter(item => Boolean(item.InstanceId))
|
|
196
|
+
.map(item => ({
|
|
197
|
+
instanceId: String(item.InstanceId),
|
|
198
|
+
type: item.Type,
|
|
199
|
+
state: item.State,
|
|
200
|
+
name: item.Name
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const ensureAwsCli = () => {
|
|
208
|
+
try {
|
|
209
|
+
(0, child_process_1.execSync)('aws --version', { stdio: 'ignore' });
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
console.log((0, kleur_1.red)('aws CLI not found. Install from https://aws.amazon.com/cli/'));
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const resolveAmazonLinuxAmi = (region, env) => {
|
|
217
|
+
try {
|
|
218
|
+
const ami = (0, child_process_1.execSync)(`aws ssm get-parameter --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64 --query 'Parameter.Value' --output text --region ${region}`, { encoding: 'utf8', env }).trim();
|
|
219
|
+
if (ami)
|
|
220
|
+
return ami;
|
|
221
|
+
}
|
|
222
|
+
catch { }
|
|
223
|
+
return '';
|
|
224
|
+
};
|
|
225
|
+
const createRunnerToken = (auth, projectPath, description, runnerTag) => {
|
|
226
|
+
if (auth.type !== 'glab')
|
|
227
|
+
return '';
|
|
228
|
+
try {
|
|
229
|
+
const projectEncoded = encodeURIComponent(projectPath);
|
|
230
|
+
const projectInfoJson = (0, child_process_1.execSync)(`glab api "projects/${projectEncoded}"`, { encoding: 'utf8' });
|
|
231
|
+
const projectInfo = JSON.parse(projectInfoJson);
|
|
232
|
+
const projectId = projectInfo.id;
|
|
233
|
+
const createResult = (0, child_process_1.execSync)(`glab api --method POST "user/runners" -f runner_type=project_type -f project_id=${projectId} -f description="${description}" -f tag_list="${runnerTag}" -f run_untagged=true`, { encoding: 'utf8' });
|
|
234
|
+
const runnerData = JSON.parse(createResult);
|
|
235
|
+
console.log((0, kleur_1.green)(`Runner token created (ID: ${runnerData.id})`));
|
|
236
|
+
return runnerData.token;
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
console.log((0, kleur_1.gray)('Could not create token via API, falling back to manual...'));
|
|
240
|
+
return '';
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const buildAwsUserData = (params) => {
|
|
244
|
+
const { region, runnerToken, runnerName, runnerTag, machineType, periodSeconds } = params;
|
|
245
|
+
const lines = [
|
|
246
|
+
'#!/usr/bin/env bash',
|
|
247
|
+
'set -euo pipefail',
|
|
248
|
+
`REGION=${region}`,
|
|
249
|
+
`RUNNER_TOKEN="${runnerToken}"`,
|
|
250
|
+
`RUNNER_NAME="${runnerName}"`,
|
|
251
|
+
`RUNNER_TAGS="${runnerTag}"`,
|
|
252
|
+
`MACHINE_TYPE="${machineType}"`,
|
|
253
|
+
`PERIOD=${periodSeconds}`,
|
|
254
|
+
'',
|
|
255
|
+
'dnf update -y',
|
|
256
|
+
'dnf install -y amazon-cloudwatch-agent jq tar',
|
|
257
|
+
'dnf install -y curl --allowerasing',
|
|
258
|
+
'curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh | bash',
|
|
259
|
+
'dnf install -y gitlab-runner',
|
|
260
|
+
'curl -fsSL https://rpm.nodesource.com/setup_20.x | bash -',
|
|
261
|
+
'dnf install -y nodejs',
|
|
262
|
+
'',
|
|
263
|
+
'INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)',
|
|
264
|
+
'',
|
|
265
|
+
"cat > /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json <<'EOF'",
|
|
266
|
+
'{',
|
|
267
|
+
' "metrics": {',
|
|
268
|
+
' "append_dimensions": {',
|
|
269
|
+
' "InstanceId": "${aws:InstanceId}"',
|
|
270
|
+
' },',
|
|
271
|
+
' "metrics_collected": {',
|
|
272
|
+
' "mem": {',
|
|
273
|
+
' "measurement": ["mem_used", "mem_total", "mem_used_percent"],',
|
|
274
|
+
` "metrics_collection_interval": ${periodSeconds}`,
|
|
275
|
+
' },',
|
|
276
|
+
' "cpu": {',
|
|
277
|
+
' "measurement": ["cpu_usage_active"],',
|
|
278
|
+
` "metrics_collection_interval": ${periodSeconds}`,
|
|
279
|
+
' }',
|
|
280
|
+
' }',
|
|
281
|
+
' }',
|
|
282
|
+
'}',
|
|
283
|
+
'EOF',
|
|
284
|
+
'',
|
|
285
|
+
'/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json -s',
|
|
286
|
+
'',
|
|
287
|
+
'gitlab-runner register --non-interactive --url https://gitlab.com/ --token "$RUNNER_TOKEN" --executor shell --description "$RUNNER_NAME"',
|
|
288
|
+
'RUNNER_ID=$(gitlab-runner list | grep -Eo \'[^ ]+ \\(ID: [0-9]+\\)\' | grep "$RUNNER_NAME" | sed -E \'s/.*ID: ([0-9]+).*/\\1/\')',
|
|
289
|
+
'if [ -n "$RUNNER_ID" ]; then',
|
|
290
|
+
' gitlab-runner verify --run-untagged --add --name "$RUNNER_NAME" || true',
|
|
291
|
+
' gitlab-runner verify --tag-list "$RUNNER_TAGS" --add --name "$RUNNER_NAME" || true',
|
|
292
|
+
'fi',
|
|
293
|
+
'echo "$INSTANCE_ID" > /opt/gitlab-runner-instance-id.txt',
|
|
294
|
+
'systemctl enable amazon-cloudwatch-agent',
|
|
295
|
+
'systemctl enable gitlab-runner',
|
|
296
|
+
'systemctl start amazon-cloudwatch-agent',
|
|
297
|
+
'systemctl start gitlab-runner'
|
|
298
|
+
];
|
|
299
|
+
return lines.join('\n');
|
|
300
|
+
};
|
|
301
|
+
const provisionAwsRunnerInstance = (awsEnv, params) => {
|
|
302
|
+
const userData = buildAwsUserData({
|
|
303
|
+
region: params.region,
|
|
304
|
+
runnerToken: params.runnerToken,
|
|
305
|
+
runnerName: params.runnerName,
|
|
306
|
+
runnerTag: params.runnerTag,
|
|
307
|
+
machineType: params.machineType,
|
|
308
|
+
periodSeconds: params.periodSeconds
|
|
309
|
+
});
|
|
310
|
+
const tmpFile = `/tmp/gitgreen-aws-userdata-${Date.now()}.sh`;
|
|
311
|
+
fs_1.default.writeFileSync(tmpFile, userData);
|
|
312
|
+
const tagSpec = `ResourceType=instance,Tags=[{Key=Name,Value=${params.runnerName}},{Key=gitlab-runner,Value=gitgreen}]`;
|
|
313
|
+
const subnetFlag = params.subnetId ? `--subnet-id ${params.subnetId}` : '';
|
|
314
|
+
const sgFlag = params.securityGroupId ? `--security-group-ids ${params.securityGroupId}` : '';
|
|
315
|
+
const keyFlag = params.keyName ? `--key-name ${params.keyName}` : '';
|
|
316
|
+
const iamFlag = params.iamInstanceProfile ? `--iam-instance-profile Name=${params.iamInstanceProfile}` : '';
|
|
317
|
+
try {
|
|
318
|
+
const resultJson = (0, child_process_1.execSync)(`aws ec2 run-instances --image-id ${params.amiId} --count 1 --instance-type ${params.machineType} ${subnetFlag} ${sgFlag} ${keyFlag} ${iamFlag} --user-data file://${tmpFile} --tag-specifications '${tagSpec}' --region ${params.region} --output json`, { encoding: 'utf8', env: awsEnv, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
319
|
+
const parsed = JSON.parse(resultJson);
|
|
320
|
+
return parsed?.Instances?.[0]?.InstanceId || '';
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
fs_1.default.unlinkSync(tmpFile);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
const runAwsInit = async (auth, projectPath) => {
|
|
327
|
+
console.log((0, kleur_1.gray)('\nStep 4: AWS Runner'));
|
|
328
|
+
console.log((0, kleur_1.gray)('─'.repeat(40)));
|
|
329
|
+
console.log((0, kleur_1.gray)('Provide the details for your existing GitLab runner on AWS.'));
|
|
330
|
+
console.log((0, kleur_1.gray)('Ensure CloudWatch Agent publishes mem_used and mem_total for RAM data.'));
|
|
331
|
+
const { awsRegion } = await (0, prompts_1.default)({
|
|
332
|
+
type: 'text',
|
|
333
|
+
name: 'awsRegion',
|
|
334
|
+
message: 'AWS region (e.g., us-east-1)',
|
|
335
|
+
initial: 'us-east-1'
|
|
336
|
+
});
|
|
337
|
+
if (!awsRegion) {
|
|
338
|
+
console.log((0, kleur_1.red)('AWS region required'));
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
const { runnerSetup } = await (0, prompts_1.default)({
|
|
342
|
+
type: 'select',
|
|
343
|
+
name: 'runnerSetup',
|
|
344
|
+
message: 'Runner setup',
|
|
345
|
+
choices: [
|
|
346
|
+
{ title: 'Use existing runner', value: 'existing' },
|
|
347
|
+
{ title: 'Provision new AWS runner', value: 'provision' }
|
|
348
|
+
]
|
|
349
|
+
});
|
|
350
|
+
let instanceId = '';
|
|
351
|
+
let machineType = '';
|
|
352
|
+
let periodSeconds = 60;
|
|
353
|
+
let runnerTag = 'aws';
|
|
354
|
+
let pendingInstanceSelection = false;
|
|
355
|
+
if (runnerSetup === 'existing') {
|
|
356
|
+
const existingAnswers = await (0, prompts_1.default)([
|
|
357
|
+
{
|
|
358
|
+
type: 'text',
|
|
359
|
+
name: 'runnerTag',
|
|
360
|
+
message: 'Runner tag (for routing CI jobs)',
|
|
361
|
+
initial: 'aws'
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
type: 'number',
|
|
365
|
+
name: 'periodSeconds',
|
|
366
|
+
message: 'CloudWatch metrics period (seconds)',
|
|
367
|
+
initial: 60
|
|
368
|
+
}
|
|
369
|
+
]);
|
|
370
|
+
runnerTag = existingAnswers.runnerTag || 'aws';
|
|
371
|
+
periodSeconds = existingAnswers.periodSeconds || 60;
|
|
372
|
+
machineType = await promptMachineType('Instance type', 'm5.large');
|
|
373
|
+
pendingInstanceSelection = true;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
ensureAwsCli();
|
|
377
|
+
const selectedMachineType = await promptMachineType('Instance type', 'm5.large');
|
|
378
|
+
const provisionAnswers = await (0, prompts_1.default)([
|
|
379
|
+
{
|
|
380
|
+
type: 'text',
|
|
381
|
+
name: 'runnerName',
|
|
382
|
+
message: 'Runner name/description',
|
|
383
|
+
initial: 'aws-carbon-runner'
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
type: 'text',
|
|
387
|
+
name: 'runnerTag',
|
|
388
|
+
message: 'Runner tag (for routing CI jobs)',
|
|
389
|
+
initial: 'aws'
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
type: 'number',
|
|
393
|
+
name: 'periodSeconds',
|
|
394
|
+
message: 'CloudWatch metrics period (seconds)',
|
|
395
|
+
initial: 60
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
type: 'text',
|
|
399
|
+
name: 'subnetId',
|
|
400
|
+
message: 'Subnet ID (optional, leave empty for default VPC)',
|
|
401
|
+
initial: ''
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
type: 'text',
|
|
405
|
+
name: 'securityGroupId',
|
|
406
|
+
message: 'Security Group ID (optional)',
|
|
407
|
+
initial: ''
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
type: 'text',
|
|
411
|
+
name: 'keyName',
|
|
412
|
+
message: 'EC2 key pair name (optional)',
|
|
413
|
+
initial: ''
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
type: 'text',
|
|
417
|
+
name: 'iamInstanceProfile',
|
|
418
|
+
message: 'IAM instance profile name (recommended for CloudWatch Agent, optional)',
|
|
419
|
+
initial: ''
|
|
420
|
+
}
|
|
421
|
+
]);
|
|
422
|
+
machineType = selectedMachineType;
|
|
423
|
+
periodSeconds = provisionAnswers.periodSeconds || 60;
|
|
424
|
+
runnerTag = provisionAnswers.runnerTag || 'aws';
|
|
425
|
+
const runnerName = provisionAnswers.runnerName || 'aws-carbon-runner';
|
|
426
|
+
const subnetId = provisionAnswers.subnetId || '';
|
|
427
|
+
const securityGroupId = provisionAnswers.securityGroupId || '';
|
|
428
|
+
const keyName = provisionAnswers.keyName || '';
|
|
429
|
+
const iamInstanceProfile = provisionAnswers.iamInstanceProfile || '';
|
|
430
|
+
let runnerToken = createRunnerToken(auth, projectPath, runnerName, runnerTag);
|
|
431
|
+
if (!runnerToken) {
|
|
432
|
+
const { manualToken } = await (0, prompts_1.default)({
|
|
433
|
+
type: 'password',
|
|
434
|
+
name: 'manualToken',
|
|
435
|
+
message: 'GitLab runner token (glrt-...)'
|
|
436
|
+
});
|
|
437
|
+
runnerToken = manualToken;
|
|
438
|
+
}
|
|
439
|
+
if (!runnerToken) {
|
|
440
|
+
console.log((0, kleur_1.red)('Runner token required to provision.'));
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
// Credentials will be collected below (Step 5) before provisioning
|
|
444
|
+
const provisionConfig = {
|
|
445
|
+
runnerName,
|
|
446
|
+
runnerToken,
|
|
447
|
+
subnetId,
|
|
448
|
+
securityGroupId,
|
|
449
|
+
keyName,
|
|
450
|
+
iamInstanceProfile
|
|
451
|
+
};
|
|
452
|
+
runAwsInit._provisionConfig = provisionConfig;
|
|
453
|
+
}
|
|
454
|
+
console.log((0, kleur_1.gray)('\nStep 5: AWS Credentials'));
|
|
455
|
+
console.log((0, kleur_1.gray)('─'.repeat(40)));
|
|
456
|
+
console.log((0, kleur_1.gray)('Use an access key with CloudWatch read permissions for the runner instance.'));
|
|
457
|
+
const { accessKeyId } = await (0, prompts_1.default)({
|
|
458
|
+
type: 'password',
|
|
459
|
+
name: 'accessKeyId',
|
|
460
|
+
message: 'AWS Access Key ID',
|
|
461
|
+
initial: process.env.AWS_ACCESS_KEY_ID || ''
|
|
462
|
+
});
|
|
463
|
+
if (!accessKeyId) {
|
|
464
|
+
console.log((0, kleur_1.red)('AWS Access Key ID required'));
|
|
465
|
+
process.exit(1);
|
|
466
|
+
}
|
|
467
|
+
const { secretAccessKey } = await (0, prompts_1.default)({
|
|
468
|
+
type: 'password',
|
|
469
|
+
name: 'secretAccessKey',
|
|
470
|
+
message: 'AWS Secret Access Key',
|
|
471
|
+
initial: process.env.AWS_SECRET_ACCESS_KEY || ''
|
|
472
|
+
});
|
|
473
|
+
if (!secretAccessKey) {
|
|
474
|
+
console.log((0, kleur_1.red)('AWS Secret Access Key required'));
|
|
475
|
+
process.exit(1);
|
|
476
|
+
}
|
|
477
|
+
const { sessionToken } = await (0, prompts_1.default)({
|
|
478
|
+
type: 'password',
|
|
479
|
+
name: 'sessionToken',
|
|
480
|
+
message: 'AWS Session Token (optional)',
|
|
481
|
+
initial: process.env.AWS_SESSION_TOKEN || ''
|
|
482
|
+
});
|
|
483
|
+
const awsEnv = {
|
|
484
|
+
...process.env,
|
|
485
|
+
AWS_REGION: awsRegion,
|
|
486
|
+
AWS_DEFAULT_REGION: awsRegion,
|
|
487
|
+
AWS_ACCESS_KEY_ID: accessKeyId,
|
|
488
|
+
AWS_SECRET_ACCESS_KEY: secretAccessKey,
|
|
489
|
+
...(sessionToken ? { AWS_SESSION_TOKEN: sessionToken } : {})
|
|
490
|
+
};
|
|
491
|
+
if (runAwsInit._provisionConfig) {
|
|
492
|
+
ensureAwsCli();
|
|
493
|
+
const cfg = runAwsInit._provisionConfig;
|
|
494
|
+
console.log((0, kleur_1.gray)('\nProvisioning AWS runner EC2 instance...'));
|
|
495
|
+
console.log((0, kleur_1.gray)('─'.repeat(40)));
|
|
496
|
+
let amiId = resolveAmazonLinuxAmi(awsRegion, awsEnv);
|
|
497
|
+
if (!amiId) {
|
|
498
|
+
const { manualAmi } = await (0, prompts_1.default)({
|
|
499
|
+
type: 'text',
|
|
500
|
+
name: 'manualAmi',
|
|
501
|
+
message: 'AMI ID (Amazon Linux 2023)',
|
|
502
|
+
initial: ''
|
|
503
|
+
});
|
|
504
|
+
amiId = manualAmi;
|
|
505
|
+
}
|
|
506
|
+
if (!amiId) {
|
|
507
|
+
console.log((0, kleur_1.red)('AMI ID required'));
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
let newInstanceId = '';
|
|
511
|
+
try {
|
|
512
|
+
newInstanceId = provisionAwsRunnerInstance(awsEnv, {
|
|
513
|
+
region: awsRegion,
|
|
514
|
+
amiId,
|
|
515
|
+
machineType,
|
|
516
|
+
runnerName: cfg.runnerName,
|
|
517
|
+
runnerTag,
|
|
518
|
+
runnerToken: cfg.runnerToken,
|
|
519
|
+
periodSeconds,
|
|
520
|
+
subnetId: cfg.subnetId || undefined,
|
|
521
|
+
securityGroupId: cfg.securityGroupId || undefined,
|
|
522
|
+
keyName: cfg.keyName || undefined,
|
|
523
|
+
iamInstanceProfile: cfg.iamInstanceProfile || undefined
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
console.log((0, kleur_1.red)('Failed to create EC2 instance: ' + (err?.message || err)));
|
|
528
|
+
process.exit(1);
|
|
529
|
+
}
|
|
530
|
+
if (!newInstanceId) {
|
|
531
|
+
console.log((0, kleur_1.red)('Could not determine instance ID from run-instances output.'));
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
instanceId = newInstanceId;
|
|
535
|
+
console.log((0, kleur_1.green)(`Provisioned instance ${instanceId}. Runner will register shortly.`));
|
|
536
|
+
}
|
|
537
|
+
if (pendingInstanceSelection && !instanceId) {
|
|
538
|
+
ensureAwsCli();
|
|
539
|
+
const instances = listAwsInstances(awsRegion, awsEnv);
|
|
540
|
+
let chosenInstance = '_manual_';
|
|
541
|
+
if (instances.length > 0) {
|
|
542
|
+
const choices = [
|
|
543
|
+
...instances.map(inst => ({
|
|
544
|
+
title: `${inst.instanceId}${inst.type ? ` (${inst.type})` : ''}${inst.name ? ` - ${inst.name}` : ''}${inst.state ? ` [${inst.state}]` : ''}`,
|
|
545
|
+
value: inst
|
|
546
|
+
})),
|
|
547
|
+
{ title: 'Enter manually', value: '_manual_' }
|
|
548
|
+
];
|
|
549
|
+
const { selectedInstance } = await (0, prompts_1.default)({
|
|
550
|
+
type: 'select',
|
|
551
|
+
name: 'selectedInstance',
|
|
552
|
+
message: 'Select EC2 runner instance',
|
|
553
|
+
choices
|
|
554
|
+
});
|
|
555
|
+
chosenInstance = selectedInstance;
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
console.log((0, kleur_1.gray)('No EC2 instances found via AWS CLI. Please enter details manually.'));
|
|
559
|
+
}
|
|
560
|
+
if (chosenInstance === '_manual_') {
|
|
561
|
+
const { manualInstanceId } = await (0, prompts_1.default)({
|
|
562
|
+
type: 'text',
|
|
563
|
+
name: 'manualInstanceId',
|
|
564
|
+
message: 'EC2 instance ID (i-xxxxxxxx)',
|
|
565
|
+
initial: process.env.AWS_INSTANCE_ID || ''
|
|
566
|
+
});
|
|
567
|
+
instanceId = manualInstanceId;
|
|
568
|
+
machineType = await promptMachineType('Instance type', machineType || 'm5.large');
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
instanceId = chosenInstance.instanceId;
|
|
572
|
+
machineType = chosenInstance.type || '';
|
|
573
|
+
if (!machineType) {
|
|
574
|
+
machineType = await promptMachineType('Instance type', 'm5.large');
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
console.log((0, kleur_1.gray)(`Detected instance type: ${machineType}`));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (!instanceId) {
|
|
581
|
+
console.log((0, kleur_1.red)('Instance ID required'));
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
console.log((0, kleur_1.gray)('\nStep 6: Electricity Maps API'));
|
|
586
|
+
console.log((0, kleur_1.gray)('─'.repeat(40)));
|
|
587
|
+
console.log((0, kleur_1.gray)('Get free key: https://api-portal.electricitymaps.com'));
|
|
588
|
+
const { electricityMapsKey } = await (0, prompts_1.default)({
|
|
589
|
+
type: 'password',
|
|
590
|
+
name: 'electricityMapsKey',
|
|
591
|
+
message: 'Electricity Maps API Key'
|
|
592
|
+
});
|
|
593
|
+
if (!electricityMapsKey) {
|
|
594
|
+
console.log((0, kleur_1.red)('API key required'));
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
console.log((0, kleur_1.gray)('\nStep 7: Optional Settings'));
|
|
598
|
+
console.log((0, kleur_1.gray)('─'.repeat(40)));
|
|
599
|
+
console.log((0, kleur_1.gray)('Set a carbon budget to track emissions against a limit.'));
|
|
600
|
+
console.log((0, kleur_1.gray)('Example: 10 grams CO2e per job. Leave empty to skip.\n'));
|
|
601
|
+
const { carbonBudget } = await (0, prompts_1.default)({
|
|
602
|
+
type: 'number',
|
|
603
|
+
name: 'carbonBudget',
|
|
604
|
+
message: 'Carbon budget (grams CO2e)',
|
|
605
|
+
initial: undefined
|
|
606
|
+
});
|
|
607
|
+
let failOnBudget = false;
|
|
608
|
+
if (carbonBudget) {
|
|
609
|
+
const { shouldFail } = await (0, prompts_1.default)({
|
|
610
|
+
type: 'confirm',
|
|
611
|
+
name: 'shouldFail',
|
|
612
|
+
message: 'Fail CI job if over budget?',
|
|
613
|
+
initial: false
|
|
614
|
+
});
|
|
615
|
+
failOnBudget = shouldFail;
|
|
616
|
+
}
|
|
617
|
+
console.log((0, kleur_1.gray)('\nStep 8: Setting CI/CD Variables'));
|
|
618
|
+
console.log((0, kleur_1.gray)('─'.repeat(40)));
|
|
619
|
+
const variables = [
|
|
620
|
+
{ key: 'AWS_ACCESS_KEY_ID', value: accessKeyId, masked: true },
|
|
621
|
+
{ key: 'AWS_SECRET_ACCESS_KEY', value: secretAccessKey, masked: true },
|
|
622
|
+
{ key: 'AWS_REGION', value: awsRegion, masked: false },
|
|
623
|
+
{ key: 'AWS_INSTANCE_ID', value: instanceId, masked: false },
|
|
624
|
+
{ key: 'MACHINE_TYPE', value: machineType, masked: false },
|
|
625
|
+
{ key: 'AWS_PERIOD_SECONDS', value: String(periodSeconds || 60), masked: false },
|
|
626
|
+
{ key: 'ELECTRICITY_MAPS_API_KEY', value: electricityMapsKey, masked: true }
|
|
627
|
+
];
|
|
628
|
+
if (sessionToken) {
|
|
629
|
+
variables.push({ key: 'AWS_SESSION_TOKEN', value: sessionToken, masked: true });
|
|
630
|
+
}
|
|
631
|
+
if (carbonBudget) {
|
|
632
|
+
variables.push({ key: 'CARBON_BUDGET_GRAMS', value: String(carbonBudget), masked: false });
|
|
633
|
+
}
|
|
634
|
+
if (failOnBudget) {
|
|
635
|
+
variables.push({ key: 'FAIL_ON_BUDGET', value: 'true', masked: false });
|
|
636
|
+
}
|
|
637
|
+
for (const v of variables) {
|
|
638
|
+
const ok = await setVariable(auth, projectPath, v.key, v.value, v.masked);
|
|
639
|
+
if (ok) {
|
|
640
|
+
console.log((0, kleur_1.green)(' Set ' + v.key));
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
console.log((0, kleur_1.red)(' Failed: ' + v.key));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
console.log((0, kleur_1.gray)('\nStep 9: CI Configuration'));
|
|
647
|
+
console.log((0, kleur_1.gray)('─'.repeat(40)));
|
|
648
|
+
let runnerTagForCi = runnerTag;
|
|
649
|
+
if (!runnerTagForCi) {
|
|
650
|
+
const { runnerTag: promptRunnerTag } = await (0, prompts_1.default)({
|
|
651
|
+
type: 'text',
|
|
652
|
+
name: 'runnerTag',
|
|
653
|
+
message: 'Runner tag (leave empty for any runner)',
|
|
654
|
+
initial: 'aws'
|
|
655
|
+
});
|
|
656
|
+
runnerTagForCi = promptRunnerTag || '';
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
console.log((0, kleur_1.gray)(`Using runner tag: ${runnerTagForCi}`));
|
|
660
|
+
}
|
|
661
|
+
const ciJob = generateCiJob({
|
|
662
|
+
provider: 'aws',
|
|
663
|
+
machineType,
|
|
664
|
+
runnerTag: runnerTagForCi || undefined,
|
|
665
|
+
carbonBudget: carbonBudget || undefined,
|
|
666
|
+
failOnBudget,
|
|
667
|
+
aws: { region: awsRegion, instanceId, periodSeconds: periodSeconds || 60 }
|
|
668
|
+
});
|
|
669
|
+
await applyCiJobSnippet(ciJob);
|
|
670
|
+
console.log((0, kleur_1.bold)('\nDone'));
|
|
671
|
+
console.log('Commit and push to trigger the pipeline.\n');
|
|
672
|
+
};
|
|
106
673
|
const runInit = async (opts = {}) => {
|
|
107
674
|
console.log((0, kleur_1.bold)('\nGitGreen Setup\n'));
|
|
108
675
|
// Step 1: GitLab Authentication
|
|
@@ -167,11 +734,12 @@ const runInit = async (opts = {}) => {
|
|
|
167
734
|
]
|
|
168
735
|
});
|
|
169
736
|
if (provider === 'aws') {
|
|
170
|
-
|
|
171
|
-
|
|
737
|
+
await runAwsInit(auth, projectPath);
|
|
738
|
+
return;
|
|
172
739
|
}
|
|
173
740
|
if (provider === 'manual') {
|
|
174
741
|
console.log((0, kleur_1.red)('\nManual configuration coming soon.'));
|
|
742
|
+
console.log((0, kleur_1.gray)('Docs: https://gitlab.com/youneslaaroussi/gitgreen (README covers manual metrics/power profile ingestion)'));
|
|
175
743
|
process.exit(1);
|
|
176
744
|
}
|
|
177
745
|
// Check if gcloud is available
|
|
@@ -236,14 +804,9 @@ const runInit = async (opts = {}) => {
|
|
|
236
804
|
message: 'GitLab Runner',
|
|
237
805
|
choices: [
|
|
238
806
|
{ title: 'Use existing runner', value: 'existing' },
|
|
239
|
-
{ title: 'Provision new GCP runner VM', value: 'provision-gcp' }
|
|
240
|
-
{ title: 'Provision new AWS runner (coming soon)', value: 'provision-aws' }
|
|
807
|
+
{ title: 'Provision new GCP runner VM', value: 'provision-gcp' }
|
|
241
808
|
]
|
|
242
809
|
});
|
|
243
|
-
if (runnerSetup === 'provision-aws') {
|
|
244
|
-
console.log((0, kleur_1.red)('\nAWS runner provisioning coming soon.'));
|
|
245
|
-
process.exit(1);
|
|
246
|
-
}
|
|
247
810
|
let gcpZone = '';
|
|
248
811
|
let gcpInstanceId = '';
|
|
249
812
|
let machineType = '';
|
|
@@ -688,36 +1251,15 @@ systemctl start gitlab-runner
|
|
|
688
1251
|
else {
|
|
689
1252
|
console.log((0, kleur_1.gray)(`Using runner tag: ${runnerTag}`));
|
|
690
1253
|
}
|
|
691
|
-
const { addCiJob } = await (0, prompts_1.default)({
|
|
692
|
-
type: 'confirm',
|
|
693
|
-
name: 'addCiJob',
|
|
694
|
-
message: 'Add job to .gitlab-ci.yml?',
|
|
695
|
-
initial: true
|
|
696
|
-
});
|
|
697
1254
|
const ciJob = generateCiJob({
|
|
1255
|
+
provider: 'gcp',
|
|
1256
|
+
machineType,
|
|
698
1257
|
runnerTag: runnerTag || undefined,
|
|
699
1258
|
carbonBudget: carbonBudget || undefined,
|
|
700
|
-
failOnBudget
|
|
1259
|
+
failOnBudget,
|
|
1260
|
+
gcp: { projectId: gcpProjectId, zone: gcpZone, instanceId: gcpInstanceId }
|
|
701
1261
|
});
|
|
702
|
-
|
|
703
|
-
const ciPath = path_1.default.join(process.cwd(), '.gitlab-ci.yml');
|
|
704
|
-
if (fs_1.default.existsSync(ciPath)) {
|
|
705
|
-
// Backup existing file before modifying
|
|
706
|
-
const backupPath = `/tmp/gitlab-ci-backup-${Date.now()}.yml`;
|
|
707
|
-
const existing = fs_1.default.readFileSync(ciPath, 'utf8');
|
|
708
|
-
fs_1.default.writeFileSync(backupPath, existing);
|
|
709
|
-
console.log((0, kleur_1.gray)(`Backup saved to: ${backupPath}`));
|
|
710
|
-
fs_1.default.writeFileSync(ciPath, existing + '\n' + ciJob);
|
|
711
|
-
}
|
|
712
|
-
else {
|
|
713
|
-
fs_1.default.writeFileSync(ciPath, 'stages:\n - test\n\n' + ciJob);
|
|
714
|
-
}
|
|
715
|
-
console.log((0, kleur_1.green)('Updated .gitlab-ci.yml'));
|
|
716
|
-
}
|
|
717
|
-
else {
|
|
718
|
-
console.log('\nCI job snippet:\n');
|
|
719
|
-
console.log(ciJob);
|
|
720
|
-
}
|
|
1262
|
+
await applyCiJobSnippet(ciJob);
|
|
721
1263
|
console.log((0, kleur_1.bold)('\nDone'));
|
|
722
1264
|
console.log('Commit and push to trigger the pipeline.\n');
|
|
723
1265
|
};
|