gitgreen 1.0.0 → 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 +142 -3
- 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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 GitGreen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
# GitGreen CLI
|
|
2
2
|
|
|
3
|
-
Self-contained carbon calculation CLI for GitLab jobs (no API server). It reuses the same power profiles and budget reporting as the existing implementation, pulls Electricity Maps intensity, and can post Merge Request notes via `CI_JOB_TOKEN`.
|
|
3
|
+
Self-contained carbon calculation CLI for GitLab jobs (no API server). It reuses the same power profiles and budget reporting as the existing implementation, pulls Electricity Maps intensity, and can post Merge Request notes via `CI_JOB_TOKEN`. Works with both GCP and AWS runners.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
Before installing GitGreen CLI, ensure you have the following installed and configured:
|
|
8
|
+
|
|
9
|
+
### Required
|
|
10
|
+
|
|
11
|
+
- **Node.js** (v20 or higher) - [Download](https://nodejs.org/)
|
|
12
|
+
- Required to run the CLI tool
|
|
13
|
+
- Check version: `node --version`
|
|
14
|
+
|
|
15
|
+
- **npm** or **pnpm** - Package manager (comes with Node.js)
|
|
16
|
+
- This project uses `pnpm@8.15.4` as the package manager
|
|
17
|
+
- Install pnpm: `npm install -g pnpm`
|
|
18
|
+
|
|
19
|
+
- **Git** - [Download](https://git-scm.com/)
|
|
20
|
+
- Required for detecting GitLab project information during initialization
|
|
21
|
+
- Check version: `git --version`
|
|
22
|
+
|
|
23
|
+
### Cloud Provider CLIs (Required based on provider)
|
|
24
|
+
|
|
25
|
+
- **Google Cloud SDK (gcloud CLI)** - [Installation Guide](https://cloud.google.com/sdk/docs/install-sdk)
|
|
26
|
+
- Required for GCP provider setup and authentication
|
|
27
|
+
- After installation, authenticate: `gcloud auth login`
|
|
28
|
+
- Set default project: `gcloud config set project YOUR_PROJECT_ID`
|
|
29
|
+
- Check version: `gcloud --version`
|
|
30
|
+
|
|
31
|
+
- **AWS Credentials** - [AWS CLI Installation](https://aws.amazon.com/cli/) (optional, but recommended)
|
|
32
|
+
- Required for AWS provider (can use environment variables or AWS CLI config)
|
|
33
|
+
- Configure credentials: `aws configure`
|
|
34
|
+
- Or set environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`
|
|
35
|
+
|
|
36
|
+
### Optional (but recommended)
|
|
37
|
+
|
|
38
|
+
- **GitLab CLI (glab)** - [Installation Guide](https://docs.gitlab.com/cli/)
|
|
39
|
+
- Recommended for easier GitLab authentication and CI/CD variable management
|
|
40
|
+
- After installation, authenticate: `glab auth login`
|
|
41
|
+
- Check version: `glab --version`
|
|
42
|
+
- If not installed, you'll need to provide a GitLab Personal Access Token manually
|
|
43
|
+
|
|
44
|
+
### API Keys
|
|
45
|
+
|
|
46
|
+
- **Electricity Maps API Key** - [Get free key](https://api-portal.electricitymaps.com)
|
|
47
|
+
- Required for carbon intensity data
|
|
48
|
+
- You'll be prompted for this during `gitgreen init`
|
|
4
49
|
|
|
5
50
|
## Install
|
|
6
51
|
- From npm (global CLI):
|
|
@@ -48,11 +93,17 @@ Once initialized, all subsequent pipelines will run on the configured runner, an
|
|
|
48
93
|
Key environment variables:
|
|
49
94
|
- `ELECTRICITY_MAPS_API_KEY` (required) - Get a free key from https://api-portal.electricitymaps.com
|
|
50
95
|
- `GCP_PROJECT_ID` / `GOOGLE_CLOUD_PROJECT` (required for GCP) - Your GCP project ID
|
|
96
|
+
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (required for AWS CloudWatch) - Credentials with CloudWatch read access on the runner
|
|
97
|
+
- `AWS_REGION` (for AWS) - Region of the runner EC2 instance
|
|
51
98
|
|
|
52
99
|
The `gitgreen init` wizard will automatically set all necessary GitLab CI/CD variables for you.
|
|
53
100
|
|
|
54
101
|
## Providers
|
|
102
|
+
|
|
103
|
+
GitGreen supports multiple cloud providers:
|
|
104
|
+
|
|
55
105
|
- **GCP**: Fully wired — the CLI parser expects GCP Monitoring JSON, the GitLab component polls GCE metrics, and `gitgreen init` provisions or reuses GCP runners.
|
|
106
|
+
- **AWS**: CloudWatch-backed — the CLI can pull metrics directly via `--from-cloudwatch`, the GitLab component fetches CloudWatch CPU/RAM data, and `gitgreen init` can configure or provision EC2 runners.
|
|
56
107
|
|
|
57
108
|
## Architecture
|
|
58
109
|
Runtime flow:
|
|
@@ -83,8 +134,8 @@ CPU/RAM timeseries (GCP Monitoring JSON or custom collector)
|
|
|
83
134
|
|
|
84
135
|
GitLab CI path:
|
|
85
136
|
```
|
|
86
|
-
Pipeline starts → component script fetches CPU/RAM timeseries from GCP Monitoring
|
|
87
|
-
→ writes JSON files → runs `gitgreen --provider gcp ...`
|
|
137
|
+
Pipeline starts → component script fetches CPU/RAM timeseries from GCP Monitoring or AWS CloudWatch
|
|
138
|
+
→ writes JSON files → runs `gitgreen --provider gcp|aws ...`
|
|
88
139
|
→ emits `carbon-report.md` / `carbon-report.json`
|
|
89
140
|
→ optional MR note when CI_JOB_TOKEN is present
|
|
90
141
|
```
|
|
@@ -100,3 +151,91 @@ Pipeline starts → component script fetches CPU/RAM timeseries from GCP Monitor
|
|
|
100
151
|
- Ensure version bump in `package.json`
|
|
101
152
|
- Run `pnpm -C node-module build && pnpm -C node-module test`
|
|
102
153
|
- Publish: `pnpm -C node-module publish --access public`
|
|
154
|
+
|
|
155
|
+
## FAQ
|
|
156
|
+
|
|
157
|
+
### Are my API keys and credentials secure?
|
|
158
|
+
|
|
159
|
+
Yes. GitGreen does not save your keys locally. During `gitgreen init`, all sensitive credentials (API keys, service account keys, AWS credentials) are stored only in GitLab CI/CD variables, which are encrypted and managed by GitLab. The CLI never writes credentials to disk on your local machine.
|
|
160
|
+
|
|
161
|
+
### Does GitGreen call any third-party APIs?
|
|
162
|
+
|
|
163
|
+
GitGreen only calls APIs that you explicitly configure and authorize:
|
|
164
|
+
|
|
165
|
+
- **Electricity Maps API** - For carbon intensity data (requires your API key)
|
|
166
|
+
- **GitLab API** - Only when using `glab` CLI or providing a PAT, to set CI/CD variables
|
|
167
|
+
- **GCP Monitoring API** - To fetch CPU/RAM metrics from your GCP project (requires your service account)
|
|
168
|
+
- **AWS CloudWatch API** - To fetch CPU/RAM metrics from your AWS account (requires your AWS credentials)
|
|
169
|
+
|
|
170
|
+
GitGreen does not operate any backend services or call any APIs owned by the GitGreen project. All API calls are made directly from your environment or CI/CD pipeline using your credentials.
|
|
171
|
+
|
|
172
|
+
### How do I know GitGreen won't destroy my `.gitlab-ci.yml` file?
|
|
173
|
+
|
|
174
|
+
GitGreen takes safety precautions when modifying your `.gitlab-ci.yml`:
|
|
175
|
+
|
|
176
|
+
1. **Automatic backups** - Before making any changes, GitGreen creates a backup of your existing `.gitlab-ci.yml` file
|
|
177
|
+
2. **Backup location** - The backup path is printed to the console (e.g., `/tmp/gitlab-ci-backup-{timestamp}.yml`)
|
|
178
|
+
3. **Append-only changes** - GitGreen only appends new content to your CI file; it never removes or modifies existing jobs
|
|
179
|
+
4. **Confirmation prompt** - You're asked to confirm before any changes are made
|
|
180
|
+
|
|
181
|
+
If something goes wrong, you can restore your file from the backup location that was printed.
|
|
182
|
+
|
|
183
|
+
### Can I use GitGreen without the `init` wizard?
|
|
184
|
+
|
|
185
|
+
Yes. You can configure GitGreen manually by:
|
|
186
|
+
|
|
187
|
+
1. Setting the required CI/CD variables in your GitLab project settings
|
|
188
|
+
2. Adding the GitLab CI/CD component to your `.gitlab-ci.yml` manually
|
|
189
|
+
3. Running `gitgreen` directly with command-line options instead of using the component
|
|
190
|
+
|
|
191
|
+
The `init` wizard is a convenience tool, but all functionality is available through manual configuration.
|
|
192
|
+
|
|
193
|
+
### What data does GitGreen collect?
|
|
194
|
+
|
|
195
|
+
GitGreen only collects:
|
|
196
|
+
|
|
197
|
+
- **CPU and RAM usage metrics** from your cloud provider (GCP Monitoring or AWS CloudWatch)
|
|
198
|
+
- **Pipeline metadata** (start/end times) from GitLab CI/CD environment variables
|
|
199
|
+
|
|
200
|
+
GitGreen does not collect any personal information, code, or data from your repositories. All calculations happen locally in your CI/CD pipeline.
|
|
201
|
+
|
|
202
|
+
### Can I run GitGreen locally for testing?
|
|
203
|
+
|
|
204
|
+
Yes. You can run `gitgreen` directly with your own metrics files:
|
|
205
|
+
|
|
206
|
+
**GCP example:**
|
|
207
|
+
```bash
|
|
208
|
+
gitgreen --provider gcp \
|
|
209
|
+
--machine e2-standard-4 \
|
|
210
|
+
--region us-central1-a \
|
|
211
|
+
--cpu-timeseries cpu.json \
|
|
212
|
+
--ram-used-timeseries ram-used.json \
|
|
213
|
+
--ram-size-timeseries ram-size.json \
|
|
214
|
+
--out-md report.md
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**AWS example:**
|
|
218
|
+
```bash
|
|
219
|
+
gitgreen --provider aws \
|
|
220
|
+
--machine t3.medium \
|
|
221
|
+
--region us-east-1 \
|
|
222
|
+
--cpu-timeseries cpu.json \
|
|
223
|
+
--ram-used-timeseries ram-used.json \
|
|
224
|
+
--ram-size-timeseries ram-size.json \
|
|
225
|
+
--out-md report.md
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Or use `--from-cloudwatch` to fetch metrics directly from AWS CloudWatch:
|
|
229
|
+
```bash
|
|
230
|
+
gitgreen --provider aws \
|
|
231
|
+
--machine t3.medium \
|
|
232
|
+
--region us-east-1 \
|
|
233
|
+
--from-cloudwatch \
|
|
234
|
+
--out-md report.md
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
This is useful for testing before integrating into your CI/CD pipeline.
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
package/dist/cli.js
CHANGED
|
@@ -8,47 +8,217 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
8
8
|
const commander_1 = require("commander");
|
|
9
9
|
const kleur_1 = require("kleur");
|
|
10
10
|
const asciichart_1 = __importDefault(require("asciichart"));
|
|
11
|
+
const config_1 = require("./config");
|
|
11
12
|
const index_1 = require("./index");
|
|
12
13
|
const init_1 = require("./init");
|
|
14
|
+
const cloudwatch_1 = require("./lib/aws/cloudwatch");
|
|
15
|
+
const power_profile_repository_1 = require("./lib/carbon/power-profile-repository");
|
|
13
16
|
const program = new commander_1.Command();
|
|
14
|
-
|
|
17
|
+
const toIsoTimestamp = (input) => {
|
|
18
|
+
if (input instanceof Date) {
|
|
19
|
+
return Number.isNaN(input.getTime()) ? undefined : input.toISOString();
|
|
20
|
+
}
|
|
21
|
+
if (typeof input === 'string' || typeof input === 'number') {
|
|
22
|
+
const date = new Date(input);
|
|
23
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
};
|
|
27
|
+
const toNumericValue = (input) => {
|
|
28
|
+
if (typeof input === 'number') {
|
|
29
|
+
return Number.isFinite(input) ? input : undefined;
|
|
30
|
+
}
|
|
31
|
+
if (typeof input === 'string') {
|
|
32
|
+
const parsed = Number(input);
|
|
33
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
};
|
|
37
|
+
const formatErrorMessage = (value) => {
|
|
38
|
+
if (value instanceof Error)
|
|
39
|
+
return value.message;
|
|
40
|
+
if (value === null || value === undefined)
|
|
41
|
+
return 'Unknown error';
|
|
42
|
+
return String(value);
|
|
43
|
+
};
|
|
44
|
+
const parseGcpTimeseries = (raw) => {
|
|
45
|
+
const points = [];
|
|
46
|
+
const response = raw;
|
|
47
|
+
const timeSeries = response.timeSeries;
|
|
48
|
+
if (!Array.isArray(timeSeries))
|
|
49
|
+
return points;
|
|
50
|
+
for (const ts of timeSeries) {
|
|
51
|
+
const series = ts;
|
|
52
|
+
if (!Array.isArray(series.points))
|
|
53
|
+
continue;
|
|
54
|
+
for (const point of series.points) {
|
|
55
|
+
const timestamp = toIsoTimestamp(point.interval?.endTime || point.interval?.startTime);
|
|
56
|
+
const value = point.value?.doubleValue !== undefined
|
|
57
|
+
? toNumericValue(point.value.doubleValue)
|
|
58
|
+
: toNumericValue(point.value?.int64Value);
|
|
59
|
+
if (timestamp && value !== undefined)
|
|
60
|
+
points.push({ timestamp, value });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return points;
|
|
64
|
+
};
|
|
65
|
+
const parseCloudWatchMetricData = (raw) => {
|
|
66
|
+
const data = raw.MetricDataResults || raw.metricDataResults;
|
|
67
|
+
const results = Array.isArray(data) ? data : [];
|
|
68
|
+
if (!results.length)
|
|
69
|
+
return [];
|
|
70
|
+
const first = results[0];
|
|
71
|
+
const timestamps = Array.isArray(first?.Timestamps) ? first.Timestamps : [];
|
|
72
|
+
const values = Array.isArray(first?.Values) ? first.Values : [];
|
|
73
|
+
const len = Math.min(timestamps.length, values.length);
|
|
74
|
+
const points = [];
|
|
75
|
+
for (let i = 0; i < len; i++) {
|
|
76
|
+
const timestamp = toIsoTimestamp(timestamps[i]);
|
|
77
|
+
const value = toNumericValue(values[i]);
|
|
78
|
+
if (timestamp && value !== undefined) {
|
|
79
|
+
points.push({ timestamp, value });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return points;
|
|
83
|
+
};
|
|
84
|
+
const parseTimeseriesFile = (filePath) => {
|
|
15
85
|
const raw = JSON.parse(fs_1.default.readFileSync(filePath, 'utf8'));
|
|
86
|
+
const gcp = parseGcpTimeseries(raw);
|
|
87
|
+
if (gcp.length)
|
|
88
|
+
return gcp;
|
|
89
|
+
const cw = parseCloudWatchMetricData(raw);
|
|
90
|
+
if (cw.length)
|
|
91
|
+
return cw;
|
|
92
|
+
if (Array.isArray(raw)) {
|
|
93
|
+
const cwFromArray = parseCloudWatchMetricData({ MetricDataResults: raw });
|
|
94
|
+
if (cwFromArray.length)
|
|
95
|
+
return cwFromArray;
|
|
96
|
+
const legacy = parseLegacyTimeseries(raw);
|
|
97
|
+
if (legacy.length)
|
|
98
|
+
return legacy;
|
|
99
|
+
}
|
|
100
|
+
const datapointResponse = raw;
|
|
101
|
+
if (Array.isArray(datapointResponse.Datapoints)) {
|
|
102
|
+
const datapointSeries = convertCloudWatchDatapoints(datapointResponse.Datapoints);
|
|
103
|
+
if (datapointSeries.length)
|
|
104
|
+
return datapointSeries;
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
};
|
|
108
|
+
const parseLegacyTimeseries = (entries) => {
|
|
16
109
|
const points = [];
|
|
17
|
-
for (const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (p.value?.doubleValue !== undefined) {
|
|
22
|
-
value = p.value.doubleValue;
|
|
23
|
-
}
|
|
24
|
-
else if (p.value?.int64Value !== undefined) {
|
|
25
|
-
value = Number(p.value.int64Value);
|
|
26
|
-
}
|
|
27
|
-
else {
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
const timestamp = toIsoTimestamp((entry.timestamp ?? entry.Timestamp));
|
|
112
|
+
const value = toNumericValue((entry.value ?? entry.Value));
|
|
113
|
+
if (timestamp && value !== undefined) {
|
|
30
114
|
points.push({ timestamp, value });
|
|
31
115
|
}
|
|
32
116
|
}
|
|
33
117
|
return points;
|
|
34
|
-
}
|
|
118
|
+
};
|
|
119
|
+
const convertCloudWatchDatapoints = (entries) => {
|
|
120
|
+
const points = [];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const timestamp = toIsoTimestamp(entry.Timestamp);
|
|
123
|
+
const value = toNumericValue((entry.Average ?? entry.Maximum ?? entry.Minimum ?? entry.Sum));
|
|
124
|
+
if (timestamp && value !== undefined) {
|
|
125
|
+
points.push({ timestamp, value });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return points;
|
|
129
|
+
};
|
|
35
130
|
const runCalculate = async (opts) => {
|
|
36
131
|
const gitlabDisabled = opts.gitlab === false;
|
|
37
|
-
|
|
38
|
-
|
|
132
|
+
const provider = (opts.provider || config_1.config.defaultProvider);
|
|
133
|
+
const regionInput = opts.region || opts.awsRegion;
|
|
134
|
+
const awsEnvRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION;
|
|
135
|
+
const calculationRegion = provider === 'aws' ? (regionInput || awsEnvRegion) : regionInput;
|
|
136
|
+
if (!opts.machine) {
|
|
137
|
+
console.error((0, kleur_1.red)('Error: --machine is required'));
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!calculationRegion) {
|
|
142
|
+
console.error((0, kleur_1.red)('Error: --region (or --aws-region) is required'));
|
|
39
143
|
process.exitCode = 1;
|
|
40
144
|
return;
|
|
41
145
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
146
|
+
let cpuTimeseries = [];
|
|
147
|
+
let ramUsedTimeseries = [];
|
|
148
|
+
let ramSizeTimeseries = [];
|
|
149
|
+
const timeseriesProvided = Boolean(opts.cpuTimeseries && opts.ramUsedTimeseries && opts.ramSizeTimeseries);
|
|
150
|
+
const shouldFetchCloudWatch = provider === 'aws' && (opts.fromCloudwatch || (!timeseriesProvided && opts.awsInstanceId));
|
|
151
|
+
if (shouldFetchCloudWatch) {
|
|
152
|
+
const instanceId = opts.awsInstanceId;
|
|
153
|
+
const resolvedRegion = calculationRegion || awsEnvRegion;
|
|
154
|
+
if (!resolvedRegion || !instanceId) {
|
|
155
|
+
console.error((0, kleur_1.red)('Error: --aws-instance-id and --region/--aws-region are required for AWS provider'));
|
|
156
|
+
process.exitCode = 1;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const awsInstanceId = instanceId;
|
|
160
|
+
const awsRegionValue = resolvedRegion;
|
|
161
|
+
const startTime = opts.awsStartTime
|
|
162
|
+
? new Date(opts.awsStartTime)
|
|
163
|
+
: process.env.CI_PIPELINE_CREATED_AT
|
|
164
|
+
? new Date(process.env.CI_PIPELINE_CREATED_AT)
|
|
165
|
+
: new Date(Date.now() - 60 * 60 * 1000);
|
|
166
|
+
const endTime = opts.awsEndTime ? new Date(opts.awsEndTime) : new Date();
|
|
167
|
+
const periodSeconds = opts.awsPeriod ? Number(opts.awsPeriod) : 60;
|
|
168
|
+
let memoryBytesFallback;
|
|
169
|
+
try {
|
|
170
|
+
const repo = new power_profile_repository_1.PowerProfileRepository(config_1.config.dataDir);
|
|
171
|
+
const profile = await repo.getMachineProfile('aws', opts.machine);
|
|
172
|
+
if (profile?.memoryGb) {
|
|
173
|
+
memoryBytesFallback = profile.memoryGb * 1024 * 1024 * 1024;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch { }
|
|
177
|
+
try {
|
|
178
|
+
const cw = await (0, cloudwatch_1.fetchCloudWatchTimeseries)({
|
|
179
|
+
region: awsRegionValue,
|
|
180
|
+
instanceId: awsInstanceId,
|
|
181
|
+
startTime,
|
|
182
|
+
endTime,
|
|
183
|
+
periodSeconds,
|
|
184
|
+
memoryBytesFallback
|
|
185
|
+
});
|
|
186
|
+
cpuTimeseries = cw.cpuUtilization;
|
|
187
|
+
ramUsedTimeseries = cw.ramUsed;
|
|
188
|
+
ramSizeTimeseries = cw.ramTotal;
|
|
189
|
+
if (!ramSizeTimeseries.length && memoryBytesFallback) {
|
|
190
|
+
const ts = ramUsedTimeseries[0]?.timestamp || new Date().toISOString();
|
|
191
|
+
ramSizeTimeseries = [{ timestamp: ts, value: memoryBytesFallback }];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (errorValue) {
|
|
195
|
+
console.error((0, kleur_1.red)('Error fetching CloudWatch metrics:'), formatErrorMessage(errorValue));
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
if (!timeseriesProvided) {
|
|
202
|
+
console.error((0, kleur_1.red)('Error: --cpu-timeseries, --ram-used-timeseries, and --ram-size-timeseries are required'));
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
cpuTimeseries = parseTimeseriesFile(opts.cpuTimeseries);
|
|
207
|
+
ramUsedTimeseries = parseTimeseriesFile(opts.ramUsedTimeseries);
|
|
208
|
+
ramSizeTimeseries = parseTimeseriesFile(opts.ramSizeTimeseries);
|
|
209
|
+
}
|
|
45
210
|
console.log((0, kleur_1.gray)(`Loaded ${cpuTimeseries.length} CPU points, ${ramUsedTimeseries.length} RAM points`));
|
|
46
211
|
// Sort by timestamp for charts
|
|
47
212
|
const cpuSorted = [...cpuTimeseries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
48
213
|
const ramSorted = [...ramUsedTimeseries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
49
214
|
const ramSizeSorted = [...ramSizeTimeseries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
50
215
|
// CPU chart (only if more than 1 point)
|
|
51
|
-
const cpuValues = cpuSorted.map(p =>
|
|
216
|
+
const cpuValues = cpuSorted.map(p => {
|
|
217
|
+
if (provider === 'aws') {
|
|
218
|
+
return p.value <= 1 ? p.value * 100 : p.value;
|
|
219
|
+
}
|
|
220
|
+
return p.value * 100;
|
|
221
|
+
});
|
|
52
222
|
if (cpuValues.length > 1) {
|
|
53
223
|
console.log((0, kleur_1.bold)('\nCPU Utilization (%)'));
|
|
54
224
|
console.log(asciichart_1.default.plot(cpuValues, { height: 8, format: (x) => x.toFixed(1).padStart(6) }));
|
|
@@ -62,9 +232,9 @@ const runCalculate = async (opts) => {
|
|
|
62
232
|
}
|
|
63
233
|
try {
|
|
64
234
|
const { result, budget, markdown } = await (0, index_1.calculate)({
|
|
65
|
-
provider
|
|
235
|
+
provider,
|
|
66
236
|
machineType: opts.machine,
|
|
67
|
-
region:
|
|
237
|
+
region: calculationRegion,
|
|
68
238
|
cpuTimeseries,
|
|
69
239
|
ramUsedTimeseries,
|
|
70
240
|
ramSizeTimeseries,
|
|
@@ -102,20 +272,26 @@ const runCalculate = async (opts) => {
|
|
|
102
272
|
process.exitCode = 1;
|
|
103
273
|
}
|
|
104
274
|
}
|
|
105
|
-
catch (
|
|
106
|
-
console.error((0, kleur_1.red)('Error:'),
|
|
275
|
+
catch (errorValue) {
|
|
276
|
+
console.error((0, kleur_1.red)('Error:'), formatErrorMessage(errorValue));
|
|
107
277
|
process.exitCode = 1;
|
|
108
278
|
}
|
|
109
279
|
};
|
|
110
280
|
program
|
|
111
281
|
.name('gitgreen')
|
|
112
282
|
.description('GitGreen carbon calculator using real timeseries metrics')
|
|
113
|
-
.option('-p, --provider <provider>', 'Cloud provider (gcp)')
|
|
283
|
+
.option('-p, --provider <provider>', 'Cloud provider (gcp|aws)', config_1.config.defaultProvider)
|
|
114
284
|
.option('-m, --machine <type>', 'Machine type (e.g., e2-standard-4)')
|
|
115
|
-
.option('-r, --region <region>', 'Region/zone (e.g., us-central1-a)')
|
|
116
|
-
.option('--cpu-timeseries <path>', 'Path to CPU timeseries JSON (GCP Monitoring
|
|
285
|
+
.option('-r, --region <region>', 'Region/zone (e.g., us-central1-a or us-east-1)')
|
|
286
|
+
.option('--cpu-timeseries <path>', 'Path to CPU timeseries JSON (GCP Monitoring or CloudWatch)')
|
|
117
287
|
.option('--ram-used-timeseries <path>', 'Path to RAM used timeseries JSON')
|
|
118
288
|
.option('--ram-size-timeseries <path>', 'Path to RAM size timeseries JSON')
|
|
289
|
+
.option('--aws-instance-id <id>', 'AWS EC2 instance ID (for CloudWatch fetch)')
|
|
290
|
+
.option('--aws-region <region>', 'AWS region (overrides --region)')
|
|
291
|
+
.option('--aws-start-time <iso>', 'Custom start time (ISO) for CloudWatch metrics')
|
|
292
|
+
.option('--aws-end-time <iso>', 'Custom end time (ISO) for CloudWatch metrics')
|
|
293
|
+
.option('--aws-period <seconds>', 'CloudWatch metric period in seconds', v => Number(v))
|
|
294
|
+
.option('--from-cloudwatch', 'Fetch CPU/RAM metrics from CloudWatch (AWS only)')
|
|
119
295
|
.option('--budget <grams>', 'Carbon budget in grams CO2e', v => Number(v))
|
|
120
296
|
.option('--fail-on-budget', 'Exit non-zero when budget exceeded')
|
|
121
297
|
.option('--out-md <path>', 'Write markdown report')
|
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
|
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchCloudWatchTimeseries = void 0;
|
|
4
|
+
const client_cloudwatch_1 = require("@aws-sdk/client-cloudwatch");
|
|
5
|
+
const toTimeseries = (result) => {
|
|
6
|
+
if (!result)
|
|
7
|
+
return [];
|
|
8
|
+
const timestamps = result.Timestamps || [];
|
|
9
|
+
const values = result.Values || [];
|
|
10
|
+
const points = [];
|
|
11
|
+
const len = Math.min(timestamps.length, values.length);
|
|
12
|
+
for (let i = 0; i < len; i++) {
|
|
13
|
+
const value = values[i];
|
|
14
|
+
if (value === undefined || value === null)
|
|
15
|
+
continue;
|
|
16
|
+
const ts = timestamps[i];
|
|
17
|
+
points.push({
|
|
18
|
+
timestamp: ts instanceof Date ? ts.toISOString() : new Date(ts).toISOString(),
|
|
19
|
+
value: Number(value)
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return points.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
23
|
+
};
|
|
24
|
+
const fetchCloudWatchTimeseries = async (opts) => {
|
|
25
|
+
const cw = new client_cloudwatch_1.CloudWatchClient({ region: opts.region });
|
|
26
|
+
const periodSeconds = opts.periodSeconds ?? 60;
|
|
27
|
+
const command = new client_cloudwatch_1.GetMetricDataCommand({
|
|
28
|
+
StartTime: opts.startTime,
|
|
29
|
+
EndTime: opts.endTime,
|
|
30
|
+
ScanBy: 'TimestampAscending',
|
|
31
|
+
MetricDataQueries: [
|
|
32
|
+
{
|
|
33
|
+
Id: 'cpu',
|
|
34
|
+
MetricStat: {
|
|
35
|
+
Metric: {
|
|
36
|
+
Namespace: 'AWS/EC2',
|
|
37
|
+
MetricName: 'CPUUtilization',
|
|
38
|
+
Dimensions: [{ Name: 'InstanceId', Value: opts.instanceId }]
|
|
39
|
+
},
|
|
40
|
+
Period: periodSeconds,
|
|
41
|
+
Stat: 'Average'
|
|
42
|
+
},
|
|
43
|
+
ReturnData: true
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
Id: 'ramUsed',
|
|
47
|
+
MetricStat: {
|
|
48
|
+
Metric: {
|
|
49
|
+
Namespace: 'CWAgent',
|
|
50
|
+
MetricName: 'mem_used',
|
|
51
|
+
Dimensions: [{ Name: 'InstanceId', Value: opts.instanceId }]
|
|
52
|
+
},
|
|
53
|
+
Period: periodSeconds,
|
|
54
|
+
Stat: 'Average'
|
|
55
|
+
},
|
|
56
|
+
ReturnData: true
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
Id: 'ramTotal',
|
|
60
|
+
MetricStat: {
|
|
61
|
+
Metric: {
|
|
62
|
+
Namespace: 'CWAgent',
|
|
63
|
+
MetricName: 'mem_total',
|
|
64
|
+
Dimensions: [{ Name: 'InstanceId', Value: opts.instanceId }]
|
|
65
|
+
},
|
|
66
|
+
Period: periodSeconds,
|
|
67
|
+
Stat: 'Average'
|
|
68
|
+
},
|
|
69
|
+
ReturnData: true
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
Id: 'ramUsedPercent',
|
|
73
|
+
MetricStat: {
|
|
74
|
+
Metric: {
|
|
75
|
+
Namespace: 'CWAgent',
|
|
76
|
+
MetricName: 'mem_used_percent',
|
|
77
|
+
Dimensions: [{ Name: 'InstanceId', Value: opts.instanceId }]
|
|
78
|
+
},
|
|
79
|
+
Period: periodSeconds,
|
|
80
|
+
Stat: 'Average'
|
|
81
|
+
},
|
|
82
|
+
ReturnData: true
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
});
|
|
86
|
+
const resp = await cw.send(command);
|
|
87
|
+
const byId = new Map();
|
|
88
|
+
(resp.MetricDataResults || []).forEach(result => {
|
|
89
|
+
if (result.Id)
|
|
90
|
+
byId.set(result.Id, result);
|
|
91
|
+
});
|
|
92
|
+
const cpuUtilization = toTimeseries(byId.get('cpu'));
|
|
93
|
+
const ramUsed = toTimeseries(byId.get('ramUsed'));
|
|
94
|
+
let ramTotal = toTimeseries(byId.get('ramTotal'));
|
|
95
|
+
const ramUsedPercent = toTimeseries(byId.get('ramUsedPercent'));
|
|
96
|
+
let ramUsedSeries = ramUsed;
|
|
97
|
+
const fallbackTotal = ramTotal[0]?.value || opts.memoryBytesFallback;
|
|
98
|
+
if (!ramUsedSeries.length && ramUsedPercent.length && fallbackTotal) {
|
|
99
|
+
ramUsedSeries = ramUsedPercent.map(point => ({
|
|
100
|
+
timestamp: point.timestamp,
|
|
101
|
+
value: (point.value / 100) * fallbackTotal
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
if (!ramTotal.length && fallbackTotal) {
|
|
105
|
+
const ts = ramUsedSeries[0]?.timestamp || new Date().toISOString();
|
|
106
|
+
ramTotal = [{ timestamp: ts, value: fallbackTotal }];
|
|
107
|
+
}
|
|
108
|
+
return { cpuUtilization, ramUsed: ramUsedSeries, ramTotal };
|
|
109
|
+
};
|
|
110
|
+
exports.fetchCloudWatchTimeseries = fetchCloudWatchTimeseries;
|
|
@@ -36,17 +36,19 @@ class CarbonCalculator {
|
|
|
36
36
|
const cpuSorted = [...job.cpuTimeseries].sort((a, b) => this.parseTimestamp(a.timestamp) - this.parseTimestamp(b.timestamp));
|
|
37
37
|
const ramUsedSorted = [...job.ramUsedTimeseries].sort((a, b) => this.parseTimestamp(a.timestamp) - this.parseTimestamp(b.timestamp));
|
|
38
38
|
const ramSizeSorted = [...job.ramSizeTimeseries].sort((a, b) => this.parseTimestamp(a.timestamp) - this.parseTimestamp(b.timestamp));
|
|
39
|
-
|
|
40
|
-
const GCP_INTERVAL_SECONDS = 60;
|
|
39
|
+
const DEFAULT_INTERVAL_SECONDS = 60;
|
|
41
40
|
// Integrate CPU energy over timeseries
|
|
42
41
|
let cpuEnergyKwh = 0;
|
|
43
42
|
for (let i = 0; i < cpuSorted.length; i++) {
|
|
44
|
-
const
|
|
43
|
+
const rawCpuValue = cpuSorted[i].value;
|
|
44
|
+
const cpuPercent = job.provider === 'aws'
|
|
45
|
+
? (rawCpuValue <= 1 ? rawCpuValue * 100 : rawCpuValue)
|
|
46
|
+
: rawCpuValue * 100;
|
|
45
47
|
const powerWatts = this.interpolatePower(cpuProfile, cpuPercent);
|
|
46
48
|
// Calculate interval from timestamps, or use GCP interval for single point
|
|
47
49
|
let intervalSeconds;
|
|
48
50
|
if (cpuSorted.length === 1) {
|
|
49
|
-
intervalSeconds =
|
|
51
|
+
intervalSeconds = DEFAULT_INTERVAL_SECONDS;
|
|
50
52
|
}
|
|
51
53
|
else if (i < cpuSorted.length - 1) {
|
|
52
54
|
intervalSeconds = this.parseTimestamp(cpuSorted[i + 1].timestamp) - this.parseTimestamp(cpuSorted[i].timestamp);
|
|
@@ -65,7 +67,7 @@ class CarbonCalculator {
|
|
|
65
67
|
const powerWatts = ramUsedGb * RAM_WATTS_PER_GB;
|
|
66
68
|
let intervalSeconds;
|
|
67
69
|
if (ramUsedSorted.length === 1) {
|
|
68
|
-
intervalSeconds =
|
|
70
|
+
intervalSeconds = DEFAULT_INTERVAL_SECONDS;
|
|
69
71
|
}
|
|
70
72
|
else if (i < ramUsedSorted.length - 1) {
|
|
71
73
|
intervalSeconds = this.parseTimestamp(ramUsedSorted[i + 1].timestamp) - this.parseTimestamp(ramUsedSorted[i].timestamp);
|
|
@@ -78,8 +80,8 @@ class CarbonCalculator {
|
|
|
78
80
|
// Calculate total runtime from timeseries
|
|
79
81
|
const firstTs = Math.min(cpuSorted.length > 0 ? this.parseTimestamp(cpuSorted[0].timestamp) : Infinity, ramUsedSorted.length > 0 ? this.parseTimestamp(ramUsedSorted[0].timestamp) : Infinity);
|
|
80
82
|
const lastTs = Math.max(cpuSorted.length > 0 ? this.parseTimestamp(cpuSorted[cpuSorted.length - 1].timestamp) : 0, ramUsedSorted.length > 0 ? this.parseTimestamp(ramUsedSorted[ramUsedSorted.length - 1].timestamp) : 0);
|
|
81
|
-
// If single point, use
|
|
82
|
-
const runtimeSeconds = lastTs === firstTs ?
|
|
83
|
+
// If single point, use default interval; otherwise use actual span
|
|
84
|
+
const runtimeSeconds = lastTs === firstTs ? DEFAULT_INTERVAL_SECONDS : (lastTs - firstTs);
|
|
83
85
|
const runtimeHours = runtimeSeconds / 3600;
|
|
84
86
|
// Calculate emissions
|
|
85
87
|
const cpuEmissions = cpuEnergyKwh * pue * carbonIntensity;
|
|
@@ -64,7 +64,12 @@ const buildMarkdownReport = (result, job, budget) => {
|
|
|
64
64
|
}
|
|
65
65
|
// Add CPU chart (only if more than 1 point)
|
|
66
66
|
if (cpuSorted.length > 1) {
|
|
67
|
-
const cpuValues = cpuSorted.map(p =>
|
|
67
|
+
const cpuValues = cpuSorted.map(p => {
|
|
68
|
+
if (job.provider === 'aws') {
|
|
69
|
+
return p.value <= 1 ? p.value * 100 : p.value;
|
|
70
|
+
}
|
|
71
|
+
return p.value * 100;
|
|
72
|
+
});
|
|
68
73
|
lines.push('', '## CPU Utilization (%)', '```', asciichart_1.default.plot(cpuValues, { height: 6 }), '```');
|
|
69
74
|
}
|
|
70
75
|
// Add RAM chart (only if more than 1 point)
|