duoops 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/init.js +3 -2
- package/mcp-server/.dockerignore +3 -0
- package/mcp-server/Dockerfile +27 -0
- package/mcp-server/README.md +70 -0
- package/mcp-server/data/aws_machine_power_profiles.json +54 -0
- package/mcp-server/data/cpu_physical_specs.json +105 -0
- package/mcp-server/data/cpu_power_profiles.json +275 -0
- package/mcp-server/data/gcp_machine_power_profiles.json +1802 -0
- package/mcp-server/data/runtime-pue-mappings.json +183 -0
- package/mcp-server/package.json +30 -0
- package/mcp-server/src/auth.ts +73 -0
- package/mcp-server/src/index.ts +58 -0
- package/mcp-server/src/lib/bigquery.ts +122 -0
- package/mcp-server/src/lib/intensity.ts +33 -0
- package/mcp-server/src/lib/power-profiles.ts +102 -0
- package/mcp-server/src/lib/types.ts +66 -0
- package/mcp-server/src/lib/zone-mapper.ts +63 -0
- package/mcp-server/src/tools/emissions.ts +107 -0
- package/mcp-server/src/tools/profiles.ts +72 -0
- package/mcp-server/tsconfig.json +18 -0
- package/oclif.manifest.json +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
{
|
|
2
|
+
"metadata": {
|
|
3
|
+
"generatedAt": "2025-06-14T16:09:07.455Z",
|
|
4
|
+
"methodology": "Real-time Electricity Maps API with industry standard PUE",
|
|
5
|
+
"availableZones": [
|
|
6
|
+
"US-CENT-SWPP"
|
|
7
|
+
]
|
|
8
|
+
},
|
|
9
|
+
"regions": {
|
|
10
|
+
"us-central1": {
|
|
11
|
+
"zone": "US-CENT-SWPP",
|
|
12
|
+
"industryStandardPUE": 1.1,
|
|
13
|
+
"realTimeCarbonIntensity": 492,
|
|
14
|
+
"googleOfficialIntensity": 430,
|
|
15
|
+
"cfe": 0.95
|
|
16
|
+
},
|
|
17
|
+
"us-east1": {
|
|
18
|
+
"zone": "FALLBACK",
|
|
19
|
+
"industryStandardPUE": 1.1,
|
|
20
|
+
"error": "No matching zone found in available zones"
|
|
21
|
+
},
|
|
22
|
+
"us-east4": {
|
|
23
|
+
"zone": "FALLBACK",
|
|
24
|
+
"industryStandardPUE": 1.1,
|
|
25
|
+
"error": "No matching zone found in available zones"
|
|
26
|
+
},
|
|
27
|
+
"us-west1": {
|
|
28
|
+
"zone": "FALLBACK",
|
|
29
|
+
"industryStandardPUE": 1.1,
|
|
30
|
+
"error": "No matching zone found in available zones"
|
|
31
|
+
},
|
|
32
|
+
"us-west2": {
|
|
33
|
+
"zone": "FALLBACK",
|
|
34
|
+
"industryStandardPUE": 1.1,
|
|
35
|
+
"error": "No matching zone found in available zones"
|
|
36
|
+
},
|
|
37
|
+
"us-west3": {
|
|
38
|
+
"zone": "FALLBACK",
|
|
39
|
+
"industryStandardPUE": 1.1,
|
|
40
|
+
"error": "No matching zone found in available zones"
|
|
41
|
+
},
|
|
42
|
+
"us-west4": {
|
|
43
|
+
"zone": "FALLBACK",
|
|
44
|
+
"industryStandardPUE": 1.1,
|
|
45
|
+
"error": "No matching zone found in available zones"
|
|
46
|
+
},
|
|
47
|
+
"us-south1": {
|
|
48
|
+
"zone": "FALLBACK",
|
|
49
|
+
"industryStandardPUE": 1.1,
|
|
50
|
+
"error": "No matching zone found in available zones"
|
|
51
|
+
},
|
|
52
|
+
"europe-west1": {
|
|
53
|
+
"zone": "FALLBACK",
|
|
54
|
+
"industryStandardPUE": 1.1,
|
|
55
|
+
"error": "No matching zone found in available zones"
|
|
56
|
+
},
|
|
57
|
+
"europe-west2": {
|
|
58
|
+
"zone": "FALLBACK",
|
|
59
|
+
"industryStandardPUE": 1.1,
|
|
60
|
+
"error": "No matching zone found in available zones"
|
|
61
|
+
},
|
|
62
|
+
"europe-west3": {
|
|
63
|
+
"zone": "FALLBACK",
|
|
64
|
+
"industryStandardPUE": 1.1,
|
|
65
|
+
"error": "No matching zone found in available zones"
|
|
66
|
+
},
|
|
67
|
+
"europe-west4": {
|
|
68
|
+
"zone": "FALLBACK",
|
|
69
|
+
"industryStandardPUE": 1.1,
|
|
70
|
+
"error": "No matching zone found in available zones"
|
|
71
|
+
},
|
|
72
|
+
"europe-west6": {
|
|
73
|
+
"zone": "FALLBACK",
|
|
74
|
+
"industryStandardPUE": 1.1,
|
|
75
|
+
"error": "No matching zone found in available zones"
|
|
76
|
+
},
|
|
77
|
+
"europe-west8": {
|
|
78
|
+
"zone": "FALLBACK",
|
|
79
|
+
"industryStandardPUE": 1.1,
|
|
80
|
+
"error": "No matching zone found in available zones"
|
|
81
|
+
},
|
|
82
|
+
"europe-west9": {
|
|
83
|
+
"zone": "FALLBACK",
|
|
84
|
+
"industryStandardPUE": 1.1,
|
|
85
|
+
"error": "No matching zone found in available zones"
|
|
86
|
+
},
|
|
87
|
+
"europe-west10": {
|
|
88
|
+
"zone": "FALLBACK",
|
|
89
|
+
"industryStandardPUE": 1.1,
|
|
90
|
+
"error": "No matching zone found in available zones"
|
|
91
|
+
},
|
|
92
|
+
"europe-west12": {
|
|
93
|
+
"zone": "FALLBACK",
|
|
94
|
+
"industryStandardPUE": 1.1,
|
|
95
|
+
"error": "No matching zone found in available zones"
|
|
96
|
+
},
|
|
97
|
+
"europe-north1": {
|
|
98
|
+
"zone": "FALLBACK",
|
|
99
|
+
"industryStandardPUE": 1.1,
|
|
100
|
+
"error": "No matching zone found in available zones"
|
|
101
|
+
},
|
|
102
|
+
"europe-central2": {
|
|
103
|
+
"zone": "FALLBACK",
|
|
104
|
+
"industryStandardPUE": 1.1,
|
|
105
|
+
"error": "No matching zone found in available zones"
|
|
106
|
+
},
|
|
107
|
+
"europe-southwest1": {
|
|
108
|
+
"zone": "FALLBACK",
|
|
109
|
+
"industryStandardPUE": 1.1,
|
|
110
|
+
"error": "No matching zone found in available zones"
|
|
111
|
+
},
|
|
112
|
+
"asia-east1": {
|
|
113
|
+
"zone": "FALLBACK",
|
|
114
|
+
"industryStandardPUE": 1.1,
|
|
115
|
+
"error": "No matching zone found in available zones"
|
|
116
|
+
},
|
|
117
|
+
"asia-east2": {
|
|
118
|
+
"zone": "FALLBACK",
|
|
119
|
+
"industryStandardPUE": 1.1,
|
|
120
|
+
"error": "No matching zone found in available zones"
|
|
121
|
+
},
|
|
122
|
+
"asia-northeast1": {
|
|
123
|
+
"zone": "FALLBACK",
|
|
124
|
+
"industryStandardPUE": 1.1,
|
|
125
|
+
"error": "No matching zone found in available zones"
|
|
126
|
+
},
|
|
127
|
+
"asia-northeast2": {
|
|
128
|
+
"zone": "FALLBACK",
|
|
129
|
+
"industryStandardPUE": 1.1,
|
|
130
|
+
"error": "No matching zone found in available zones"
|
|
131
|
+
},
|
|
132
|
+
"asia-northeast3": {
|
|
133
|
+
"zone": "FALLBACK",
|
|
134
|
+
"industryStandardPUE": 1.1,
|
|
135
|
+
"error": "No matching zone found in available zones"
|
|
136
|
+
},
|
|
137
|
+
"asia-south1": {
|
|
138
|
+
"zone": "FALLBACK",
|
|
139
|
+
"industryStandardPUE": 1.1,
|
|
140
|
+
"error": "No matching zone found in available zones"
|
|
141
|
+
},
|
|
142
|
+
"asia-south2": {
|
|
143
|
+
"zone": "FALLBACK",
|
|
144
|
+
"industryStandardPUE": 1.1,
|
|
145
|
+
"error": "No matching zone found in available zones"
|
|
146
|
+
},
|
|
147
|
+
"asia-southeast1": {
|
|
148
|
+
"zone": "FALLBACK",
|
|
149
|
+
"industryStandardPUE": 1.1,
|
|
150
|
+
"error": "No matching zone found in available zones"
|
|
151
|
+
},
|
|
152
|
+
"asia-southeast2": {
|
|
153
|
+
"zone": "FALLBACK",
|
|
154
|
+
"industryStandardPUE": 1.1,
|
|
155
|
+
"error": "No matching zone found in available zones"
|
|
156
|
+
},
|
|
157
|
+
"australia-southeast1": {
|
|
158
|
+
"zone": "FALLBACK",
|
|
159
|
+
"industryStandardPUE": 1.1,
|
|
160
|
+
"error": "No matching zone found in available zones"
|
|
161
|
+
},
|
|
162
|
+
"australia-southeast2": {
|
|
163
|
+
"zone": "FALLBACK",
|
|
164
|
+
"industryStandardPUE": 1.1,
|
|
165
|
+
"error": "No matching zone found in available zones"
|
|
166
|
+
},
|
|
167
|
+
"southamerica-east1": {
|
|
168
|
+
"zone": "FALLBACK",
|
|
169
|
+
"industryStandardPUE": 1.1,
|
|
170
|
+
"error": "No matching zone found in available zones"
|
|
171
|
+
},
|
|
172
|
+
"northamerica-northeast1": {
|
|
173
|
+
"zone": "FALLBACK",
|
|
174
|
+
"industryStandardPUE": 1.1,
|
|
175
|
+
"error": "No matching zone found in available zones"
|
|
176
|
+
},
|
|
177
|
+
"northamerica-northeast2": {
|
|
178
|
+
"zone": "FALLBACK",
|
|
179
|
+
"industryStandardPUE": 1.1,
|
|
180
|
+
"error": "No matching zone found in available zones"
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@duoops/mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for DuoOps carbon metrics",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"dev": "tsx watch src/index.ts",
|
|
11
|
+
"lint": "eslint ."
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@google-cloud/bigquery": "^8.1.1",
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
16
|
+
"axios": "^1.7.9",
|
|
17
|
+
"express": "^4.21.2",
|
|
18
|
+
"zod": "^3.24.2"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/express": "^5.0.0",
|
|
22
|
+
"@types/node": "^22.10.2",
|
|
23
|
+
"eslint": "^9.17.0",
|
|
24
|
+
"tsx": "^4.19.2",
|
|
25
|
+
"typescript": "^5.7.2"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* eslint-disable camelcase */
|
|
2
|
+
import axios from 'axios'
|
|
3
|
+
import {NextFunction, Request, Response} from 'express'
|
|
4
|
+
|
|
5
|
+
export const GITLAB_URL = process.env.GITLAB_URL || 'https://gitlab.com'
|
|
6
|
+
|
|
7
|
+
export async function validateGitLabToken(token: string): Promise<boolean> {
|
|
8
|
+
try {
|
|
9
|
+
const response = await axios.get(`${GITLAB_URL}/api/v4/user`, {
|
|
10
|
+
headers: {
|
|
11
|
+
Authorization: `Bearer ${token}`,
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
return response.status === 200
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error('GitLab token validation failed:', error)
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
|
22
|
+
// Skip auth for well-known discovery endpoints
|
|
23
|
+
if (req.path.startsWith('/.well-known/')) {
|
|
24
|
+
return next()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check for Authorization header
|
|
28
|
+
const authHeader = req.headers.authorization
|
|
29
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
30
|
+
return sendUnauthorized(res)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const token = authHeader.split(' ')[1]
|
|
34
|
+
|
|
35
|
+
validateGitLabToken(token)
|
|
36
|
+
.then((isValid) => {
|
|
37
|
+
if (isValid) {
|
|
38
|
+
next()
|
|
39
|
+
} else {
|
|
40
|
+
sendUnauthorized(res)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
.catch(() => sendUnauthorized(res))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sendUnauthorized(res: Response) {
|
|
47
|
+
// Return 401 with WWW-Authenticate header pointing to GitLab discovery
|
|
48
|
+
// The resource_id is the URL of THIS server (the MCP server)
|
|
49
|
+
const resourceId = process.env.MCP_SERVER_URL || `https://${process.env.RAILWAY_STATIC_URL || 'localhost'}`
|
|
50
|
+
|
|
51
|
+
// Per RFC 9728, we point to our own protected resource metadata endpoint
|
|
52
|
+
// which then points to the authorization server (GitLab)
|
|
53
|
+
res.setHeader('WWW-Authenticate', `OAuth p="ignored", resource_metadata="${resourceId}/.well-known/oauth-protected-resource"`)
|
|
54
|
+
res.status(401).send('Unauthorized')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
export function oauthEndpoints(app: any) {
|
|
59
|
+
app.get('/.well-known/oauth-protected-resource', (req: Request, res: Response) => {
|
|
60
|
+
res.json({
|
|
61
|
+
authorization_servers: [
|
|
62
|
+
GITLAB_URL
|
|
63
|
+
],
|
|
64
|
+
resource: process.env.MCP_SERVER_URL || `https://${req.get('host')}`,
|
|
65
|
+
scopes_supported: ["api", "read_user"]
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// If GitLab doesn't strictly follow RFC 8414 at /.well-known/oauth-authorization-server,
|
|
70
|
+
// we might need to proxy or hardcode its endpoints here.
|
|
71
|
+
// But GitLab does support OIDC discovery at /.well-known/openid-configuration
|
|
72
|
+
// For MCP, the client should resolve the auth server metadata from the URL we provided above.
|
|
73
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import {SSEServerTransport} from '@modelcontextprotocol/sdk/server/sse.js'
|
|
3
|
+
import express from 'express'
|
|
4
|
+
|
|
5
|
+
import {authMiddleware, oauthEndpoints} from './auth.js'
|
|
6
|
+
import {registerEmissionsTools} from './tools/emissions.js'
|
|
7
|
+
import {registerProfileTools} from './tools/profiles.js'
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
const server = new McpServer({
|
|
11
|
+
name: 'duoops-carbon',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// Register tools
|
|
16
|
+
registerEmissionsTools(server)
|
|
17
|
+
registerProfileTools(server)
|
|
18
|
+
|
|
19
|
+
const app = express()
|
|
20
|
+
|
|
21
|
+
app.get('/health', (_req, res) => {
|
|
22
|
+
res.json({service: 'duoops-mcp', status: 'ok', version: '1.0.0'})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
oauthEndpoints(app)
|
|
26
|
+
|
|
27
|
+
// MCP SSE Transport setup
|
|
28
|
+
let transport: SSEServerTransport
|
|
29
|
+
|
|
30
|
+
app.get('/sse', authMiddleware, async (req, res) => {
|
|
31
|
+
transport = new SSEServerTransport('/message', res)
|
|
32
|
+
await server.connect(transport)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
app.post('/message', authMiddleware, async (req, res) => {
|
|
36
|
+
if (!transport) {
|
|
37
|
+
res.sendStatus(400)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await transport.handlePostMessage(req, res)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Legacy/Simple HTTP endpoint (optional, if SSE not supported by some clients)
|
|
45
|
+
// Currently we focus on SSE as per SDK standard for HTTP transport
|
|
46
|
+
|
|
47
|
+
const port = process.env.PORT || 8080
|
|
48
|
+
app.listen(port, () => {
|
|
49
|
+
console.log(`DuoOps MCP Server running on port ${port}`)
|
|
50
|
+
console.log(`OAuth discovery at /.well-known/oauth-protected-resource`)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// eslint-disable-next-line unicorn/prefer-top-level-await
|
|
55
|
+
main().catch((error) => {
|
|
56
|
+
console.error('Fatal error:', error)
|
|
57
|
+
process.exit(1) // eslint-disable-line n/no-process-exit, unicorn/no-process-exit
|
|
58
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/* eslint-disable camelcase */
|
|
2
|
+
import {BigQuery} from '@google-cloud/bigquery'
|
|
3
|
+
|
|
4
|
+
import {EmissionsRecord} from './types.js'
|
|
5
|
+
|
|
6
|
+
export class BigQueryService {
|
|
7
|
+
private bigquery: BigQuery
|
|
8
|
+
private datasetId: string
|
|
9
|
+
private projectId: string
|
|
10
|
+
private tableId: string
|
|
11
|
+
|
|
12
|
+
constructor() {
|
|
13
|
+
this.projectId = process.env.GCP_PROJECT_ID || ''
|
|
14
|
+
this.datasetId = process.env.BQ_DATASET || ''
|
|
15
|
+
this.tableId = process.env.BQ_TABLE || ''
|
|
16
|
+
|
|
17
|
+
if (!this.projectId || !this.datasetId || !this.tableId) {
|
|
18
|
+
console.warn('BigQuery configuration missing: GCP_PROJECT_ID, BQ_DATASET, or BQ_TABLE env vars not set.')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
this.bigquery = new BigQuery({projectId: this.projectId})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async getJobEmissions(jobId: number, projectId?: number): Promise<EmissionsRecord | null> {
|
|
25
|
+
const query = `
|
|
26
|
+
SELECT
|
|
27
|
+
job_id,
|
|
28
|
+
pipeline_id,
|
|
29
|
+
gitlab_project_id as project_id,
|
|
30
|
+
machine_type,
|
|
31
|
+
region,
|
|
32
|
+
total_emissions,
|
|
33
|
+
(cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
|
|
34
|
+
created_at,
|
|
35
|
+
runtime_seconds
|
|
36
|
+
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
37
|
+
WHERE job_id = @jobId
|
|
38
|
+
${projectId ? 'AND gitlab_project_id = @projectId' : ''}
|
|
39
|
+
LIMIT 1
|
|
40
|
+
`
|
|
41
|
+
|
|
42
|
+
const options = {
|
|
43
|
+
params: {jobId, projectId},
|
|
44
|
+
query,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [rows] = await this.bigquery.query(options)
|
|
48
|
+
return rows.length > 0 ? (rows[0] as EmissionsRecord) : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getPipelineEmissions(pipelineId: number, projectId?: number): Promise<{
|
|
52
|
+
jobs: Partial<EmissionsRecord>[];
|
|
53
|
+
pipeline_id: number;
|
|
54
|
+
total_emissions_g: number;
|
|
55
|
+
total_energy_kwh: number;
|
|
56
|
+
}> {
|
|
57
|
+
const query = `
|
|
58
|
+
SELECT
|
|
59
|
+
job_id,
|
|
60
|
+
machine_type,
|
|
61
|
+
total_emissions,
|
|
62
|
+
(cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
|
|
63
|
+
runtime_seconds
|
|
64
|
+
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
65
|
+
WHERE pipeline_id = @pipelineId
|
|
66
|
+
${projectId ? 'AND gitlab_project_id = @projectId' : ''}
|
|
67
|
+
`
|
|
68
|
+
|
|
69
|
+
const options = {
|
|
70
|
+
params: {pipelineId, projectId},
|
|
71
|
+
query,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const [rows] = await this.bigquery.query(options)
|
|
75
|
+
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
77
|
+
const totalEmissions = rows.reduce((sum: number, row: any) => sum + (row.total_emissions || 0), 0)
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
const totalEnergy = rows.reduce((sum: number, row: any) => sum + (row.total_energy_kwh || 0), 0)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
jobs: rows.map((r: any) => ({
|
|
84
|
+
job_id: r.job_id,
|
|
85
|
+
machine_type: r.machine_type,
|
|
86
|
+
runtime_seconds: r.runtime_seconds,
|
|
87
|
+
total_emissions: r.total_emissions,
|
|
88
|
+
total_energy_kwh: r.total_energy_kwh
|
|
89
|
+
})),
|
|
90
|
+
pipeline_id: pipelineId,
|
|
91
|
+
total_emissions_g: totalEmissions,
|
|
92
|
+
total_energy_kwh: totalEnergy
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getProjectEmissionsSummary(projectId: number, limit: number = 10): Promise<EmissionsRecord[]> {
|
|
97
|
+
const query = `
|
|
98
|
+
SELECT
|
|
99
|
+
job_id,
|
|
100
|
+
pipeline_id,
|
|
101
|
+
gitlab_project_id as project_id,
|
|
102
|
+
machine_type,
|
|
103
|
+
region,
|
|
104
|
+
total_emissions,
|
|
105
|
+
(cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
|
|
106
|
+
created_at,
|
|
107
|
+
runtime_seconds
|
|
108
|
+
FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
|
|
109
|
+
WHERE gitlab_project_id = @projectId
|
|
110
|
+
ORDER BY created_at DESC
|
|
111
|
+
LIMIT @limit
|
|
112
|
+
`
|
|
113
|
+
|
|
114
|
+
const options = {
|
|
115
|
+
params: {limit, projectId},
|
|
116
|
+
query,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const [rows] = await this.bigquery.query(options)
|
|
120
|
+
return rows as EmissionsRecord[]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class IntensityProvider {
|
|
2
|
+
async getCarbonIntensity(zone: string): Promise<number> {
|
|
3
|
+
// Electricity Maps API or similar would go here.
|
|
4
|
+
// For this implementation, we'll return a static average if we can't fetch real data,
|
|
5
|
+
// or simulate a fetch to public carbon intensity APIs if available without auth.
|
|
6
|
+
// This mirrors the CLI's simple implementation which currently uses static fallbacks or public APIs.
|
|
7
|
+
|
|
8
|
+
// Simplification for the hackathon: use a mapping of common zones to average intensities
|
|
9
|
+
const averages: Record<string, number> = {
|
|
10
|
+
'AU': 300,
|
|
11
|
+
'BR': 600,
|
|
12
|
+
'DE': 350,
|
|
13
|
+
'FR': 50,
|
|
14
|
+
'GB': 60,
|
|
15
|
+
'IE': 230,
|
|
16
|
+
'IN': 650,
|
|
17
|
+
'JP': 600,
|
|
18
|
+
'NL': 350,
|
|
19
|
+
'US-CAL-CISO': 370,
|
|
20
|
+
'US-MIDA-PJM': 390,
|
|
21
|
+
'US-NW-PACW': 150,
|
|
22
|
+
'US-NY-NYIS': 300,
|
|
23
|
+
'US-TEX-NYIS': 400,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Try to find a match or partial match
|
|
27
|
+
for (const [key, value] of Object.entries(averages)) {
|
|
28
|
+
if (zone.includes(key)) return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return 475 // Global average fallback
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import {CloudProvider, MachineProfile, PowerPoint} from './types.js'
|
|
5
|
+
|
|
6
|
+
type RawMachineProfile = {
|
|
7
|
+
cpu_power_profile?: PowerPoint[];
|
|
8
|
+
matched_cpu_profile?: string;
|
|
9
|
+
memory_gb: number | string;
|
|
10
|
+
platform_cpu?: string;
|
|
11
|
+
ram_power_profile?: unknown;
|
|
12
|
+
scope3_emissions_hourly?: number;
|
|
13
|
+
vcpus: number | string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type RawCpuProfile = {
|
|
17
|
+
power_profile: PowerPoint[];
|
|
18
|
+
tdp_watts: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type CpuPhysicalSpec = {
|
|
22
|
+
architecture: string;
|
|
23
|
+
cores: number;
|
|
24
|
+
note?: string;
|
|
25
|
+
source: string;
|
|
26
|
+
source_name: string;
|
|
27
|
+
tdp_watts: number;
|
|
28
|
+
threads: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type CpuPhysicalSpecs = Record<string, CpuPhysicalSpec> & {
|
|
32
|
+
_metadata: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class PowerProfileRepository {
|
|
36
|
+
private awsMachines: null | Record<string, RawMachineProfile> = null
|
|
37
|
+
private cpuPhysicalSpecs: CpuPhysicalSpecs | null = null
|
|
38
|
+
private cpuProfiles: null | Record<string, RawCpuProfile> = null
|
|
39
|
+
private gcpMachines: null | Record<string, RawMachineProfile> = null
|
|
40
|
+
|
|
41
|
+
// Default to ./data relative to CWD, which works in the container
|
|
42
|
+
constructor(private readonly dataDir: string = path.resolve(process.cwd(), 'data')) {}
|
|
43
|
+
|
|
44
|
+
async getMachineProfile(provider: CloudProvider, machineType: string): Promise<MachineProfile | null> {
|
|
45
|
+
const machines = this.loadMachineData(provider)
|
|
46
|
+
const raw = machines[machineType]
|
|
47
|
+
if (!raw) return null
|
|
48
|
+
|
|
49
|
+
const powerProfile = raw.cpu_power_profile ? this.normalizePowerPoints(raw.cpu_power_profile) : []
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
cpuPowerProfile: powerProfile,
|
|
53
|
+
machineType,
|
|
54
|
+
matchedCpuProfile: raw.matched_cpu_profile,
|
|
55
|
+
memoryGb: this.normalizeNumber(raw.memory_gb),
|
|
56
|
+
platformCpu: raw.platform_cpu,
|
|
57
|
+
scope3EmissionsHourly: raw.scope3_emissions_hourly,
|
|
58
|
+
vcpus: this.normalizeNumber(raw.vcpus),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private loadJsonFile<T>(filename: string): T {
|
|
63
|
+
const filePath = path.join(this.dataDir, filename)
|
|
64
|
+
try {
|
|
65
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
66
|
+
return JSON.parse(raw) as T
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(`Failed to load data file ${filename}:`, error)
|
|
69
|
+
return {} as T
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private loadMachineData(provider: CloudProvider): Record<string, RawMachineProfile> {
|
|
74
|
+
if (provider === 'gcp') {
|
|
75
|
+
if (!this.gcpMachines) {
|
|
76
|
+
this.gcpMachines = this.loadJsonFile<Record<string, RawMachineProfile>>('gcp_machine_power_profiles.json')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return this.gcpMachines
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!this.awsMachines) {
|
|
83
|
+
this.awsMachines = this.loadJsonFile<Record<string, RawMachineProfile>>('aws_machine_power_profiles.json')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return this.awsMachines
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private normalizeNumber(value: number | string | undefined): number {
|
|
90
|
+
if (typeof value === 'number') return value
|
|
91
|
+
if (!value) return 0
|
|
92
|
+
const parsed = Number.parseFloat(value)
|
|
93
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private normalizePowerPoints(points: PowerPoint[]): PowerPoint[] {
|
|
97
|
+
return points.map((p) => ({
|
|
98
|
+
percentage: Number(p.percentage),
|
|
99
|
+
watts: Number(p.watts),
|
|
100
|
+
})).sort((a, b) => a.percentage - b.percentage)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Copied and adapted from src/lib/measure/types.ts
|
|
2
|
+
|
|
3
|
+
export type CloudProvider = 'aws' | 'gcp';
|
|
4
|
+
|
|
5
|
+
export interface TimeseriesPoint {
|
|
6
|
+
timestamp: string;
|
|
7
|
+
value: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface JobInput {
|
|
11
|
+
cpuTimeseries: TimeseriesPoint[];
|
|
12
|
+
machineType: string;
|
|
13
|
+
provider: CloudProvider;
|
|
14
|
+
ramSizeTimeseries?: TimeseriesPoint[];
|
|
15
|
+
ramUsedTimeseries: TimeseriesPoint[];
|
|
16
|
+
region: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PowerPoint {
|
|
20
|
+
percentage: number;
|
|
21
|
+
watts: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MachineProfile {
|
|
25
|
+
cpuPowerProfile?: PowerPoint[];
|
|
26
|
+
machineType: string;
|
|
27
|
+
matchedCpuProfile?: string;
|
|
28
|
+
memoryGb: number;
|
|
29
|
+
platformCpu?: string;
|
|
30
|
+
scope3EmissionsHourly?: number;
|
|
31
|
+
vcpus: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ZoneMapping {
|
|
35
|
+
carbonIntensityUrl: string;
|
|
36
|
+
pue: number;
|
|
37
|
+
zone: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CarbonResult {
|
|
41
|
+
carbonIntensity: number;
|
|
42
|
+
cpuEmissions: number;
|
|
43
|
+
cpuEnergyKwh: number;
|
|
44
|
+
machineType: string;
|
|
45
|
+
provider: CloudProvider;
|
|
46
|
+
pue: number;
|
|
47
|
+
ramEmissions: number;
|
|
48
|
+
ramEnergyKwh: number;
|
|
49
|
+
region: string;
|
|
50
|
+
runtimeHours: number;
|
|
51
|
+
scope3Emissions: number;
|
|
52
|
+
totalEmissions: number;
|
|
53
|
+
zone: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface EmissionsRecord {
|
|
57
|
+
created_at: string;
|
|
58
|
+
job_id: number;
|
|
59
|
+
machine_type: string;
|
|
60
|
+
pipeline_id: number;
|
|
61
|
+
project_id: number;
|
|
62
|
+
region: string;
|
|
63
|
+
runtime_seconds: number;
|
|
64
|
+
total_emissions: number;
|
|
65
|
+
total_energy_kwh: number;
|
|
66
|
+
}
|