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 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
- function parseGcpTimeseries(filePath) {
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 ts of raw.timeSeries || []) {
18
- for (const p of ts.points || []) {
19
- const timestamp = p.interval?.endTime || p.interval?.startTime;
20
- let value;
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
- if (!opts.cpuTimeseries || !opts.ramUsedTimeseries || !opts.ramSizeTimeseries) {
38
- console.error((0, kleur_1.red)('Error: --cpu-timeseries, --ram-used-timeseries, and --ram-size-timeseries are required'));
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
- const cpuTimeseries = parseGcpTimeseries(opts.cpuTimeseries);
43
- const ramUsedTimeseries = parseGcpTimeseries(opts.ramUsedTimeseries);
44
- const ramSizeTimeseries = parseGcpTimeseries(opts.ramSizeTimeseries);
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 => p.value * 100);
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: opts.provider,
235
+ provider,
66
236
  machineType: opts.machine,
67
- region: opts.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 (err) {
106
- console.error((0, kleur_1.red)('Error:'), err?.message || err);
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 format)')
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 = ` gcp_project_id: $GCP_PROJECT_ID
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@0.1.0
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
- console.log((0, kleur_1.red)('\nAWS support coming soon.'));
171
- process.exit(1);
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
- if (addCiJob) {
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
- // GCP reports metrics every 60 seconds
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 cpuPercent = cpuSorted[i].value * 100; // GCP returns 0-1, convert to 0-100
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 = GCP_INTERVAL_SECONDS;
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 = GCP_INTERVAL_SECONDS;
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 GCP interval; otherwise use actual span
82
- const runtimeSeconds = lastTs === firstTs ? GCP_INTERVAL_SECONDS : (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 => p.value * 100);
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitgreen",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "GitGreen CLI for carbon reporting in GitLab pipelines (GCP/AWS)",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",