gitgreen 0.1.0
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/README.md +77 -0
- package/data/aws_machine_power_profiles.json +54 -0
- package/data/cpu_power_profiles.json +275 -0
- package/data/gcp_machine_power_profiles.json +1802 -0
- package/data/runtime-pue-mappings.json +183 -0
- package/dist/cli.js +137 -0
- package/dist/config.js +43 -0
- package/dist/index.js +94 -0
- package/dist/init.js +773 -0
- package/dist/lib/carbon/carbon-calculator.js +105 -0
- package/dist/lib/carbon/intensity-provider.js +29 -0
- package/dist/lib/carbon/power-profile-repository.js +90 -0
- package/dist/lib/carbon/zone-mapper.js +107 -0
- package/dist/lib/gitlab/gitlab-client.js +28 -0
- package/dist/lib/gitlab/report-formatter.js +93 -0
- package/dist/lib/telemetry/gitlab-job-loader.js +23 -0
- package/dist/lib/telemetry/runner-parser.js +63 -0
- package/dist/types.js +2 -0
- package/package.json +56 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CarbonCalculator = void 0;
|
|
4
|
+
const Spline = require('cubic-spline');
|
|
5
|
+
class CarbonCalculator {
|
|
6
|
+
constructor(powerProfileRepository, zoneMapper, intensityProvider) {
|
|
7
|
+
this.powerProfileRepository = powerProfileRepository;
|
|
8
|
+
this.zoneMapper = zoneMapper;
|
|
9
|
+
this.intensityProvider = intensityProvider;
|
|
10
|
+
}
|
|
11
|
+
interpolatePower(profile, utilization) {
|
|
12
|
+
const sorted = profile.slice().sort((a, b) => a.percentage - b.percentage);
|
|
13
|
+
const exact = sorted.find(point => point.percentage === utilization);
|
|
14
|
+
if (exact)
|
|
15
|
+
return exact.watts;
|
|
16
|
+
const percentages = sorted.map(p => p.percentage);
|
|
17
|
+
const watts = sorted.map(p => p.watts);
|
|
18
|
+
const spline = new Spline(percentages, watts);
|
|
19
|
+
return spline.at(utilization);
|
|
20
|
+
}
|
|
21
|
+
parseTimestamp(ts) {
|
|
22
|
+
return new Date(ts).getTime() / 1000;
|
|
23
|
+
}
|
|
24
|
+
async calculate(job) {
|
|
25
|
+
const machineProfile = await this.powerProfileRepository.getMachineProfile(job.provider, job.machineType);
|
|
26
|
+
if (!machineProfile) {
|
|
27
|
+
throw new Error(`No machine profile found for ${job.machineType} on ${job.provider}`);
|
|
28
|
+
}
|
|
29
|
+
const cpuProfile = await this.powerProfileRepository.getCpuPowerProfile(job.provider, job.machineType);
|
|
30
|
+
if (!cpuProfile || cpuProfile.length === 0) {
|
|
31
|
+
throw new Error(`No CPU power profile for ${job.machineType}`);
|
|
32
|
+
}
|
|
33
|
+
const { zone, pue } = this.zoneMapper.resolve(job.provider, job.region);
|
|
34
|
+
const carbonIntensity = await this.intensityProvider.getCarbonIntensity(zone);
|
|
35
|
+
// Sort timeseries by timestamp
|
|
36
|
+
const cpuSorted = [...job.cpuTimeseries].sort((a, b) => this.parseTimestamp(a.timestamp) - this.parseTimestamp(b.timestamp));
|
|
37
|
+
const ramUsedSorted = [...job.ramUsedTimeseries].sort((a, b) => this.parseTimestamp(a.timestamp) - this.parseTimestamp(b.timestamp));
|
|
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;
|
|
41
|
+
// Integrate CPU energy over timeseries
|
|
42
|
+
let cpuEnergyKwh = 0;
|
|
43
|
+
for (let i = 0; i < cpuSorted.length; i++) {
|
|
44
|
+
const cpuPercent = cpuSorted[i].value * 100; // GCP returns 0-1, convert to 0-100
|
|
45
|
+
const powerWatts = this.interpolatePower(cpuProfile, cpuPercent);
|
|
46
|
+
// Calculate interval from timestamps, or use GCP interval for single point
|
|
47
|
+
let intervalSeconds;
|
|
48
|
+
if (cpuSorted.length === 1) {
|
|
49
|
+
intervalSeconds = GCP_INTERVAL_SECONDS;
|
|
50
|
+
}
|
|
51
|
+
else if (i < cpuSorted.length - 1) {
|
|
52
|
+
intervalSeconds = this.parseTimestamp(cpuSorted[i + 1].timestamp) - this.parseTimestamp(cpuSorted[i].timestamp);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
intervalSeconds = this.parseTimestamp(cpuSorted[i].timestamp) - this.parseTimestamp(cpuSorted[i - 1].timestamp);
|
|
56
|
+
}
|
|
57
|
+
cpuEnergyKwh += (powerWatts / 1000) * (intervalSeconds / 3600);
|
|
58
|
+
}
|
|
59
|
+
// Integrate RAM energy over timeseries
|
|
60
|
+
let ramEnergyKwh = 0;
|
|
61
|
+
const RAM_WATTS_PER_GB = 0.5;
|
|
62
|
+
for (let i = 0; i < ramUsedSorted.length; i++) {
|
|
63
|
+
const ramUsedBytes = ramUsedSorted[i].value;
|
|
64
|
+
const ramUsedGb = ramUsedBytes / (1024 * 1024 * 1024);
|
|
65
|
+
const powerWatts = ramUsedGb * RAM_WATTS_PER_GB;
|
|
66
|
+
let intervalSeconds;
|
|
67
|
+
if (ramUsedSorted.length === 1) {
|
|
68
|
+
intervalSeconds = GCP_INTERVAL_SECONDS;
|
|
69
|
+
}
|
|
70
|
+
else if (i < ramUsedSorted.length - 1) {
|
|
71
|
+
intervalSeconds = this.parseTimestamp(ramUsedSorted[i + 1].timestamp) - this.parseTimestamp(ramUsedSorted[i].timestamp);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
intervalSeconds = this.parseTimestamp(ramUsedSorted[i].timestamp) - this.parseTimestamp(ramUsedSorted[i - 1].timestamp);
|
|
75
|
+
}
|
|
76
|
+
ramEnergyKwh += (powerWatts / 1000) * (intervalSeconds / 3600);
|
|
77
|
+
}
|
|
78
|
+
// Calculate total runtime from timeseries
|
|
79
|
+
const firstTs = Math.min(cpuSorted.length > 0 ? this.parseTimestamp(cpuSorted[0].timestamp) : Infinity, ramUsedSorted.length > 0 ? this.parseTimestamp(ramUsedSorted[0].timestamp) : Infinity);
|
|
80
|
+
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
|
+
const runtimeHours = runtimeSeconds / 3600;
|
|
84
|
+
// Calculate emissions
|
|
85
|
+
const cpuEmissions = cpuEnergyKwh * pue * carbonIntensity;
|
|
86
|
+
const ramEmissions = ramEnergyKwh * pue * carbonIntensity;
|
|
87
|
+
const scope3Emissions = (machineProfile.scope3EmissionsHourly || 0) * runtimeHours;
|
|
88
|
+
const totalEmissions = cpuEmissions + ramEmissions + scope3Emissions;
|
|
89
|
+
return {
|
|
90
|
+
totalEmissions,
|
|
91
|
+
cpuEmissions,
|
|
92
|
+
ramEmissions,
|
|
93
|
+
scope3Emissions,
|
|
94
|
+
cpuEnergyKwh,
|
|
95
|
+
ramEnergyKwh,
|
|
96
|
+
runtimeHours,
|
|
97
|
+
pue,
|
|
98
|
+
carbonIntensity,
|
|
99
|
+
provider: job.provider,
|
|
100
|
+
machineType: job.machineType,
|
|
101
|
+
region: job.region
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
exports.CarbonCalculator = CarbonCalculator;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.IntensityProvider = void 0;
|
|
7
|
+
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
class IntensityProvider {
|
|
9
|
+
constructor(baseUrl, apiKey) {
|
|
10
|
+
this.baseUrl = baseUrl;
|
|
11
|
+
this.apiKey = apiKey;
|
|
12
|
+
}
|
|
13
|
+
async getCarbonIntensity(zone) {
|
|
14
|
+
if (!this.apiKey) {
|
|
15
|
+
throw new Error('ELECTRICITY_MAPS_API_KEY is required. Get one at https://api-portal.electricitymaps.com/');
|
|
16
|
+
}
|
|
17
|
+
const url = `${this.baseUrl.replace(/\/$/, '')}/carbon-intensity/latest`;
|
|
18
|
+
const response = await axios_1.default.get(url, {
|
|
19
|
+
params: { zone },
|
|
20
|
+
headers: { 'auth-token': this.apiKey }
|
|
21
|
+
});
|
|
22
|
+
const intensity = response.data.carbonIntensity;
|
|
23
|
+
if (typeof intensity !== 'number' || intensity < 0) {
|
|
24
|
+
throw new Error(`Invalid carbon intensity response for ${zone}`);
|
|
25
|
+
}
|
|
26
|
+
return intensity;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.IntensityProvider = IntensityProvider;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.PowerProfileRepository = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
class PowerProfileRepository {
|
|
10
|
+
constructor(dataDir) {
|
|
11
|
+
this.dataDir = dataDir;
|
|
12
|
+
this.gcpMachines = null;
|
|
13
|
+
this.awsMachines = null;
|
|
14
|
+
this.cpuProfiles = null;
|
|
15
|
+
}
|
|
16
|
+
loadJsonFile(filename) {
|
|
17
|
+
const filePath = path_1.default.join(this.dataDir, filename);
|
|
18
|
+
const raw = fs_1.default.readFileSync(filePath, 'utf8');
|
|
19
|
+
return JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
loadMachineData(provider) {
|
|
22
|
+
if (provider === 'gcp') {
|
|
23
|
+
if (!this.gcpMachines) {
|
|
24
|
+
this.gcpMachines = this.loadJsonFile('gcp_machine_power_profiles.json');
|
|
25
|
+
}
|
|
26
|
+
return this.gcpMachines;
|
|
27
|
+
}
|
|
28
|
+
if (!this.awsMachines) {
|
|
29
|
+
this.awsMachines = this.loadJsonFile('aws_machine_power_profiles.json');
|
|
30
|
+
}
|
|
31
|
+
return this.awsMachines;
|
|
32
|
+
}
|
|
33
|
+
loadCpuProfiles() {
|
|
34
|
+
if (!this.cpuProfiles) {
|
|
35
|
+
this.cpuProfiles = this.loadJsonFile('cpu_power_profiles.json');
|
|
36
|
+
}
|
|
37
|
+
return this.cpuProfiles;
|
|
38
|
+
}
|
|
39
|
+
normalizeNumber(value) {
|
|
40
|
+
if (typeof value === 'number')
|
|
41
|
+
return value;
|
|
42
|
+
if (!value)
|
|
43
|
+
return 0;
|
|
44
|
+
const parsed = parseFloat(value);
|
|
45
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
46
|
+
}
|
|
47
|
+
normalizePowerPoints(points) {
|
|
48
|
+
return points.map(p => ({
|
|
49
|
+
percentage: Number(p.percentage),
|
|
50
|
+
watts: Number(p.watts)
|
|
51
|
+
})).sort((a, b) => a.percentage - b.percentage);
|
|
52
|
+
}
|
|
53
|
+
async getMachineProfile(provider, machineType) {
|
|
54
|
+
const machines = this.loadMachineData(provider);
|
|
55
|
+
const raw = machines[machineType];
|
|
56
|
+
if (!raw)
|
|
57
|
+
return null;
|
|
58
|
+
const powerProfile = raw.cpu_power_profile ? this.normalizePowerPoints(raw.cpu_power_profile) : [];
|
|
59
|
+
return {
|
|
60
|
+
machineType,
|
|
61
|
+
vcpus: this.normalizeNumber(raw.vcpus),
|
|
62
|
+
memoryGb: this.normalizeNumber(raw.memory_gb),
|
|
63
|
+
platformCpu: raw.platform_cpu,
|
|
64
|
+
matchedCpuProfile: raw.matched_cpu_profile,
|
|
65
|
+
cpuPowerProfile: powerProfile,
|
|
66
|
+
scope3EmissionsHourly: raw.scope3_emissions_hourly
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async getCpuPowerProfile(provider, machineType) {
|
|
70
|
+
const profile = await this.getMachineProfile(provider, machineType);
|
|
71
|
+
if (!profile)
|
|
72
|
+
return null;
|
|
73
|
+
if (profile.cpuPowerProfile && profile.cpuPowerProfile.length > 0) {
|
|
74
|
+
return profile.cpuPowerProfile;
|
|
75
|
+
}
|
|
76
|
+
if (profile.matchedCpuProfile) {
|
|
77
|
+
const cpuProfiles = this.loadCpuProfiles();
|
|
78
|
+
const rawCpu = cpuProfiles[profile.matchedCpuProfile];
|
|
79
|
+
if (rawCpu) {
|
|
80
|
+
return this.normalizePowerPoints(rawCpu.power_profile);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
listMachines(provider) {
|
|
86
|
+
const machines = this.loadMachineData(provider);
|
|
87
|
+
return Object.keys(machines).sort();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
exports.PowerProfileRepository = PowerProfileRepository;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ZoneMapper = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
class ZoneMapper {
|
|
10
|
+
constructor(dataDir, fallbackPue) {
|
|
11
|
+
this.dataDir = dataDir;
|
|
12
|
+
this.fallbackPue = fallbackPue;
|
|
13
|
+
this.runtimePueData = null;
|
|
14
|
+
this.gcpZoneMapping = {
|
|
15
|
+
'us-central1': 'US-CENT-SWPP',
|
|
16
|
+
'us-east1': 'US-NE-ISNE',
|
|
17
|
+
'us-east4': 'US-MIDA-PJM',
|
|
18
|
+
'us-west1': 'US-CAL-CISO',
|
|
19
|
+
'us-west2': 'US-NW-PACW',
|
|
20
|
+
'us-west3': 'US-SW-AZPS',
|
|
21
|
+
'us-west4': 'US-NW-NEVP',
|
|
22
|
+
'europe-west1': 'BE',
|
|
23
|
+
'europe-west2': 'GB',
|
|
24
|
+
'europe-west3': 'DE',
|
|
25
|
+
'europe-west4': 'NL',
|
|
26
|
+
'europe-west6': 'CH',
|
|
27
|
+
'europe-north1': 'FI',
|
|
28
|
+
'asia-east1': 'TW',
|
|
29
|
+
'asia-east2': 'HK',
|
|
30
|
+
'asia-northeast1': 'JP-TK',
|
|
31
|
+
'asia-northeast2': 'JP-KN',
|
|
32
|
+
'asia-northeast3': 'KR',
|
|
33
|
+
'asia-south1': 'IN-WE',
|
|
34
|
+
'asia-southeast1': 'SG',
|
|
35
|
+
'asia-southeast2': 'ID-JW',
|
|
36
|
+
'australia-southeast1': 'AU-NSW'
|
|
37
|
+
};
|
|
38
|
+
this.awsZoneMapping = {
|
|
39
|
+
'us-east-1': { zone: 'US-NE-ISNE', pue: 1.18 },
|
|
40
|
+
'us-east-2': { zone: 'US-MIDA-PJM', pue: 1.18 },
|
|
41
|
+
'us-west-1': { zone: 'US-CAL-CISO', pue: 1.15 },
|
|
42
|
+
'us-west-2': { zone: 'US-NW-PACW', pue: 1.14 },
|
|
43
|
+
'eu-west-1': { zone: 'IE', pue: 1.18 },
|
|
44
|
+
'eu-west-2': { zone: 'GB', pue: 1.18 },
|
|
45
|
+
'eu-central-1': { zone: 'DE', pue: 1.16 },
|
|
46
|
+
'ap-northeast-1': { zone: 'JP-TK', pue: 1.2 },
|
|
47
|
+
'ap-southeast-1': { zone: 'SG', pue: 1.2 },
|
|
48
|
+
'ap-south-1': { zone: 'IN-NO', pue: 1.22 }
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
loadRuntimePueData() {
|
|
52
|
+
if (this.runtimePueData)
|
|
53
|
+
return this.runtimePueData;
|
|
54
|
+
const filePath = path_1.default.join(this.dataDir, 'runtime-pue-mappings.json');
|
|
55
|
+
if (!fs_1.default.existsSync(filePath)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const raw = fs_1.default.readFileSync(filePath, 'utf8');
|
|
60
|
+
this.runtimePueData = JSON.parse(raw);
|
|
61
|
+
return this.runtimePueData;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
normalizeRegion(region, provider) {
|
|
68
|
+
if (provider === 'gcp') {
|
|
69
|
+
if (/^[a-z]+-[a-z0-9]+[1-9]-[a-z]$/.test(region)) {
|
|
70
|
+
return region.split('-').slice(0, 2).join('-');
|
|
71
|
+
}
|
|
72
|
+
return region;
|
|
73
|
+
}
|
|
74
|
+
if (/^[a-z]+-[a-z]+-[0-9][a-z]$/.test(region)) {
|
|
75
|
+
return region.slice(0, -1);
|
|
76
|
+
}
|
|
77
|
+
return region;
|
|
78
|
+
}
|
|
79
|
+
resolve(provider, region) {
|
|
80
|
+
const normalizedRegion = this.normalizeRegion(region, provider);
|
|
81
|
+
if (provider === 'gcp') {
|
|
82
|
+
const runtime = this.loadRuntimePueData();
|
|
83
|
+
const runtimeRegion = runtime?.regions?.[normalizedRegion];
|
|
84
|
+
const runtimePue = runtimeRegion?.industryStandardPUE;
|
|
85
|
+
const runtimeZone = runtimeRegion?.zone;
|
|
86
|
+
const zone = runtimeZone && runtimeZone !== 'FALLBACK'
|
|
87
|
+
? runtimeZone
|
|
88
|
+
: this.gcpZoneMapping[normalizedRegion];
|
|
89
|
+
if (!zone) {
|
|
90
|
+
throw new Error(`No zone mapping for GCP region ${normalizedRegion}`);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
zone,
|
|
94
|
+
pue: runtimePue || this.fallbackPue
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const awsMapping = this.awsZoneMapping[normalizedRegion];
|
|
98
|
+
if (!awsMapping) {
|
|
99
|
+
throw new Error(`No zone mapping for AWS region ${normalizedRegion}`);
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
zone: awsMapping.zone,
|
|
103
|
+
pue: awsMapping.pue || this.fallbackPue
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
exports.ZoneMapper = ZoneMapper;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GitLabClient = void 0;
|
|
7
|
+
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
class GitLabClient {
|
|
9
|
+
constructor(baseUrl) {
|
|
10
|
+
this.baseUrl = baseUrl;
|
|
11
|
+
}
|
|
12
|
+
apiBase(serverUrl) {
|
|
13
|
+
const base = serverUrl || this.baseUrl;
|
|
14
|
+
return `${base.replace(/\/$/, '')}/api/v4`;
|
|
15
|
+
}
|
|
16
|
+
async postMergeRequestNote(context, body) {
|
|
17
|
+
if (!context.mergeRequestIid) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const url = `${this.apiBase(context.serverUrl)}/projects/${context.projectId}/merge_requests/${context.mergeRequestIid}/notes`;
|
|
21
|
+
await axios_1.default.post(url, { body }, {
|
|
22
|
+
headers: {
|
|
23
|
+
'JOB-TOKEN': context.jobToken
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.GitLabClient = GitLabClient;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildJsonReport = exports.buildMarkdownReport = exports.evaluateBudget = exports.gramsToString = void 0;
|
|
7
|
+
const asciichart_1 = __importDefault(require("asciichart"));
|
|
8
|
+
const formatNumber = (value, decimals = 2) => Number(value).toFixed(decimals);
|
|
9
|
+
const gramsToString = (grams) => {
|
|
10
|
+
if (grams >= 1000) {
|
|
11
|
+
return `${formatNumber(grams / 1000, 3)} kgCO₂e`;
|
|
12
|
+
}
|
|
13
|
+
return `${formatNumber(grams, 3)} gCO₂e`;
|
|
14
|
+
};
|
|
15
|
+
exports.gramsToString = gramsToString;
|
|
16
|
+
const formatDuration = (hours) => {
|
|
17
|
+
const seconds = hours * 3600;
|
|
18
|
+
if (seconds < 60)
|
|
19
|
+
return `${Math.round(seconds)}s`;
|
|
20
|
+
const minutes = seconds / 60;
|
|
21
|
+
if (minutes < 60)
|
|
22
|
+
return `${formatNumber(minutes, 1)}m`;
|
|
23
|
+
return `${formatNumber(hours, 2)}h`;
|
|
24
|
+
};
|
|
25
|
+
const evaluateBudget = (result, budget) => {
|
|
26
|
+
if (!budget || !budget.carbonBudgetGrams) {
|
|
27
|
+
return { budgetConfigured: false, overBudget: false };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
budgetConfigured: true,
|
|
31
|
+
limitGrams: budget.carbonBudgetGrams,
|
|
32
|
+
overBudget: result.totalEmissions > budget.carbonBudgetGrams
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
exports.evaluateBudget = evaluateBudget;
|
|
36
|
+
const buildMarkdownReport = (result, job, budget) => {
|
|
37
|
+
const budgetResult = (0, exports.evaluateBudget)(result, budget);
|
|
38
|
+
const totalEnergyKwh = result.cpuEnergyKwh + result.ramEnergyKwh;
|
|
39
|
+
// Sort timeseries for charts
|
|
40
|
+
const cpuSorted = [...job.cpuTimeseries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
41
|
+
const ramSorted = [...job.ramUsedTimeseries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
42
|
+
const ramSizeSorted = [...job.ramSizeTimeseries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
43
|
+
const lines = [
|
|
44
|
+
'# Carbon report',
|
|
45
|
+
'',
|
|
46
|
+
`- Cloud provider: ${result.provider.toUpperCase()}`,
|
|
47
|
+
`- Region/zone: ${result.region}`,
|
|
48
|
+
`- Machine type: ${result.machineType}`,
|
|
49
|
+
`- Runtime: ${formatDuration(result.runtimeHours)}`,
|
|
50
|
+
`- Data points: ${job.cpuTimeseries.length} CPU, ${job.ramUsedTimeseries.length} RAM`,
|
|
51
|
+
`- Total energy: ${formatNumber(totalEnergyKwh * 1000, 3)} Wh`,
|
|
52
|
+
` - CPU: ${formatNumber(result.cpuEnergyKwh * 1000, 3)} Wh`,
|
|
53
|
+
` - RAM: ${formatNumber(result.ramEnergyKwh * 1000, 3)} Wh`,
|
|
54
|
+
`- Total emissions: ${(0, exports.gramsToString)(result.totalEmissions)}`,
|
|
55
|
+
` - CPU: ${(0, exports.gramsToString)(result.cpuEmissions)}`,
|
|
56
|
+
` - RAM: ${(0, exports.gramsToString)(result.ramEmissions)}`,
|
|
57
|
+
` - Scope 3: ${(0, exports.gramsToString)(result.scope3Emissions)}`,
|
|
58
|
+
`- Power usage effectiveness (PUE): ${formatNumber(result.pue, 2)}`,
|
|
59
|
+
`- Grid carbon intensity: ${formatNumber(result.carbonIntensity, 2)} gCO₂e/kWh`
|
|
60
|
+
];
|
|
61
|
+
if (budgetResult.budgetConfigured && budgetResult.limitGrams) {
|
|
62
|
+
const status = budgetResult.overBudget ? 'over budget' : 'within budget';
|
|
63
|
+
lines.push(`- Budget: ${(0, exports.gramsToString)(budgetResult.limitGrams)} (${status})`);
|
|
64
|
+
}
|
|
65
|
+
// Add CPU chart (only if more than 1 point)
|
|
66
|
+
if (cpuSorted.length > 1) {
|
|
67
|
+
const cpuValues = cpuSorted.map(p => p.value * 100);
|
|
68
|
+
lines.push('', '## CPU Utilization (%)', '```', asciichart_1.default.plot(cpuValues, { height: 6 }), '```');
|
|
69
|
+
}
|
|
70
|
+
// Add RAM chart (only if more than 1 point)
|
|
71
|
+
if (ramSorted.length > 1 && ramSizeSorted.length > 0) {
|
|
72
|
+
const totalRamGb = ramSizeSorted[0].value / (1024 * 1024 * 1024);
|
|
73
|
+
const ramPercent = ramSorted.map(p => (p.value / (1024 * 1024 * 1024) / totalRamGb) * 100);
|
|
74
|
+
lines.push('', '## RAM Utilization (%)', '```', asciichart_1.default.plot(ramPercent, { height: 6 }), '```');
|
|
75
|
+
}
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
};
|
|
78
|
+
exports.buildMarkdownReport = buildMarkdownReport;
|
|
79
|
+
const buildJsonReport = (result, job, budget) => {
|
|
80
|
+
const budgetResult = (0, exports.evaluateBudget)(result, budget);
|
|
81
|
+
return {
|
|
82
|
+
job: {
|
|
83
|
+
provider: job.provider,
|
|
84
|
+
region: job.region,
|
|
85
|
+
machineType: job.machineType,
|
|
86
|
+
cpuDataPoints: job.cpuTimeseries.length,
|
|
87
|
+
ramDataPoints: job.ramUsedTimeseries.length
|
|
88
|
+
},
|
|
89
|
+
emissions: result,
|
|
90
|
+
budget: budgetResult
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
exports.buildJsonReport = buildJsonReport;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GitLabJobLoader = void 0;
|
|
7
|
+
const axios_1 = __importDefault(require("axios"));
|
|
8
|
+
class GitLabJobLoader {
|
|
9
|
+
constructor(baseUrl) {
|
|
10
|
+
this.baseUrl = baseUrl;
|
|
11
|
+
}
|
|
12
|
+
apiBase(serverUrl) {
|
|
13
|
+
return `${(serverUrl || this.baseUrl).replace(/\/$/, '')}/api/v4`;
|
|
14
|
+
}
|
|
15
|
+
async fetchJob(projectId, jobId, jobToken, serverUrl) {
|
|
16
|
+
const url = `${this.apiBase(serverUrl)}/projects/${projectId}/jobs/${jobId}`;
|
|
17
|
+
const { data } = await axios_1.default.get(url, {
|
|
18
|
+
headers: { 'JOB-TOKEN': jobToken }
|
|
19
|
+
});
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
exports.GitLabJobLoader = GitLabJobLoader;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractZoneOrRegion = exports.extractMachineType = exports.extractGcpProject = exports.extractInstanceId = void 0;
|
|
4
|
+
const zoneRegexGcp = /^[a-z]+-[a-z0-9]+-[a-z]$/;
|
|
5
|
+
const regionRegexAws = /^[a-z]+-[a-z]+-[0-9]$/;
|
|
6
|
+
const regionZoneRegexAws = /^[a-z]+-[a-z]+-[0-9][a-z]$/; // e.g., us-east-1a
|
|
7
|
+
const extractInstanceId = (tags) => {
|
|
8
|
+
for (const tag of tags) {
|
|
9
|
+
const gcpMatch = tag.match(/^instance-(\d+)$/);
|
|
10
|
+
if (gcpMatch)
|
|
11
|
+
return gcpMatch[1];
|
|
12
|
+
const awsMatch = tag.match(/^i-[a-f0-9]+$/i);
|
|
13
|
+
if (awsMatch)
|
|
14
|
+
return awsMatch[0];
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
};
|
|
18
|
+
exports.extractInstanceId = extractInstanceId;
|
|
19
|
+
const extractGcpProject = (tags) => {
|
|
20
|
+
for (const tag of tags) {
|
|
21
|
+
const match = tag.match(/^project-(.+)$/);
|
|
22
|
+
if (match)
|
|
23
|
+
return match[1];
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
exports.extractGcpProject = extractGcpProject;
|
|
28
|
+
const extractMachineType = (tags, description) => {
|
|
29
|
+
const candidates = [...tags, description || ''];
|
|
30
|
+
for (const value of candidates) {
|
|
31
|
+
if (!value)
|
|
32
|
+
continue;
|
|
33
|
+
const awsMatch = value.match(/\b([cmrtpgidfhsvx][0-9]+[a-z]?(?:\.\d*xlarge|\.\d*large|\.metal|\.nano|\.micro|\.small|\.medium|\.xlarge))\b/i);
|
|
34
|
+
if (awsMatch)
|
|
35
|
+
return awsMatch[1];
|
|
36
|
+
const gcpMatch = value.match(/\b([a-z0-9]+-[a-z]+-[a-z0-9]+)\b/);
|
|
37
|
+
if (gcpMatch)
|
|
38
|
+
return gcpMatch[1];
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
};
|
|
42
|
+
exports.extractMachineType = extractMachineType;
|
|
43
|
+
const extractZoneOrRegion = (tags, description) => {
|
|
44
|
+
const candidates = [...tags, description || ''];
|
|
45
|
+
for (const value of candidates) {
|
|
46
|
+
if (!value)
|
|
47
|
+
continue;
|
|
48
|
+
const parts = value.split(/[,\s]+/);
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
if (zoneRegexGcp.test(part)) {
|
|
51
|
+
return { zoneOrRegion: part, providerGuess: 'gcp' };
|
|
52
|
+
}
|
|
53
|
+
if (regionZoneRegexAws.test(part)) {
|
|
54
|
+
return { zoneOrRegion: part.slice(0, -1), providerGuess: 'aws' }; // strip zone letter
|
|
55
|
+
}
|
|
56
|
+
if (regionRegexAws.test(part)) {
|
|
57
|
+
return { zoneOrRegion: part, providerGuess: 'aws' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { zoneOrRegion: null };
|
|
62
|
+
};
|
|
63
|
+
exports.extractZoneOrRegion = extractZoneOrRegion;
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gitgreen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GitGreen CLI for carbon reporting in GitLab pipelines (GCP/AWS)",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"gitgreen": "dist/cli.js",
|
|
9
|
+
"gitgreen-cli": "dist/cli.js",
|
|
10
|
+
"carbon-cli": "dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"packageManager": "pnpm@8.15.4",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"carbon",
|
|
16
|
+
"sustainability",
|
|
17
|
+
"gitlab",
|
|
18
|
+
"cli",
|
|
19
|
+
"gcp",
|
|
20
|
+
"aws"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p .",
|
|
24
|
+
"dev": "ts-node src/cli.ts",
|
|
25
|
+
"prepare": "npm run build",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"stress": "bash ./scripts/stress.sh",
|
|
28
|
+
"prepublishOnly": "pnpm build && pnpm test"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"data",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@aws-sdk/client-cloudwatch": "^3.525.0",
|
|
37
|
+
"@google-cloud/monitoring": "^4.1.0",
|
|
38
|
+
"asciichart": "^1.5.25",
|
|
39
|
+
"axios": "^1.9.0",
|
|
40
|
+
"commander": "^12.1.0",
|
|
41
|
+
"cubic-spline": "^3.0.3",
|
|
42
|
+
"dotenv": "^16.5.0",
|
|
43
|
+
"kleur": "^4.1.5",
|
|
44
|
+
"prompts": "^2.4.2"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^20.11.30",
|
|
48
|
+
"@types/prompts": "^2.4.9",
|
|
49
|
+
"ts-node": "^10.9.2",
|
|
50
|
+
"typescript": "^5.4.0",
|
|
51
|
+
"vitest": "^1.5.0"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|