duoops 0.1.9 → 0.2.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 +151 -63
- package/data/aws_machine_power_profiles.json +54 -0
- package/data/cpu_physical_specs.json +105 -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/commands/autofix-ci.d.ts +13 -0
- package/dist/commands/autofix-ci.js +114 -0
- package/dist/commands/autofix.d.ts +5 -0
- package/dist/commands/autofix.js +11 -0
- package/dist/commands/init.js +50 -27
- package/dist/commands/mcp/deploy.d.ts +13 -0
- package/dist/commands/mcp/deploy.js +139 -0
- package/dist/commands/measure/calculate.js +2 -2
- package/dist/commands/portal.js +421 -6
- package/dist/lib/ai/agent.js +1 -0
- package/dist/lib/ai/tools/editing.js +28 -13
- package/dist/lib/ai/tools/gitlab.js +8 -4
- package/dist/lib/config.d.ts +10 -0
- package/dist/lib/gcloud.d.ts +7 -0
- package/dist/lib/gcloud.js +105 -0
- package/dist/lib/gitlab/pipelines-service.d.ts +23 -0
- package/dist/lib/gitlab/pipelines-service.js +146 -0
- package/dist/lib/gitlab/runner-service.d.ts +11 -0
- package/dist/lib/gitlab/runner-service.js +15 -0
- package/dist/lib/portal/settings.d.ts +3 -0
- package/dist/lib/portal/settings.js +48 -0
- package/dist/lib/scaffold.d.ts +5 -0
- package/dist/lib/scaffold.js +32 -0
- package/dist/portal/assets/HomeDashboard-DlkwSyKx.js +1 -0
- package/dist/portal/assets/JobDetailsDrawer-7kXXMSH8.js +1 -0
- package/dist/portal/assets/JobsDashboard-D4pNc9TM.js +1 -0
- package/dist/portal/assets/MetricsDashboard-BcgzvzBz.js +1 -0
- package/dist/portal/assets/PipelinesDashboard-BNrSM9GB.js +1 -0
- package/dist/portal/assets/allPaths-CXDKahbk.js +1 -0
- package/dist/portal/assets/allPathsLoader-BF5PAx2c.js +2 -0
- package/dist/portal/assets/cache-YerT0Slh.js +6 -0
- package/dist/portal/assets/core-Cz8f3oSB.js +19 -0
- package/dist/portal/assets/{index-C54ZhVUo.js → index-B9sNUqEC.js} +1 -1
- package/dist/portal/assets/index-BWa_E8Y7.css +1 -0
- package/dist/portal/assets/index-Bp4RqK05.js +1 -0
- package/dist/portal/assets/index-DW6Qp0d6.js +64 -0
- package/dist/portal/assets/index-Uc4Xhv31.js +1 -0
- package/dist/portal/assets/progressBar-C4SmnGeZ.js +1 -0
- package/dist/portal/assets/splitPathsBySizeLoader-C-T9_API.js +1 -0
- package/dist/portal/index.html +2 -2
- package/oclif.manifest.json +237 -92
- package/package.json +2 -1
- package/templates/.gitlab/duo/flows/duoops.yaml +114 -0
- package/templates/agents/agent.yml +45 -0
- package/templates/duoops-autofix-component.yml +52 -0
- package/templates/flows/flow.yml +283 -0
- package/dist/portal/assets/MetricsDashboard-Bnj-jtu6.js +0 -27
- package/dist/portal/assets/index-B1SGDQNX.css +0 -1
- package/dist/portal/assets/index-Bk8OVV7a.js +0 -106
|
@@ -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,13 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class AutofixCi extends Command {
|
|
3
|
+
static args: {
|
|
4
|
+
'project-id': import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
mr: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
pipeline: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
+
import { runAgent } from '../lib/ai/agent.js';
|
|
3
|
+
import { configManager } from '../lib/config.js';
|
|
4
|
+
import { createGitlabClient } from '../lib/gitlab/client.js';
|
|
5
|
+
import { getPipelineProvider } from '../lib/gitlab/index.js';
|
|
6
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
7
|
+
export default class AutofixCi extends Command {
|
|
8
|
+
static args = {
|
|
9
|
+
'project-id': Args.string({
|
|
10
|
+
description: 'GitLab project ID or path (falls back to DUOOPS_PROJECT_ID, CI_PROJECT_ID, or duoops init default)',
|
|
11
|
+
required: false,
|
|
12
|
+
}),
|
|
13
|
+
};
|
|
14
|
+
static description = 'Headless autofix: analyze the latest failing pipeline and post results to an MR or stdout';
|
|
15
|
+
static examples = [
|
|
16
|
+
'<%= config.bin %> <%= command.id %>',
|
|
17
|
+
'<%= config.bin %> <%= command.id %> 12345 --mr 42',
|
|
18
|
+
'<%= config.bin %> <%= command.id %> my-group/my-project --pipeline 99999',
|
|
19
|
+
];
|
|
20
|
+
static flags = {
|
|
21
|
+
mr: Flags.integer({
|
|
22
|
+
description: 'Merge request IID to comment the analysis on',
|
|
23
|
+
}),
|
|
24
|
+
pipeline: Flags.integer({
|
|
25
|
+
description: 'Pipeline ID to analyze (defaults to latest failed)',
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
async run() {
|
|
29
|
+
const { args, flags } = await this.parse(AutofixCi);
|
|
30
|
+
const config = configManager.get();
|
|
31
|
+
const projectId = args['project-id'] ??
|
|
32
|
+
process.env.DUOOPS_PROJECT_ID ??
|
|
33
|
+
process.env.CI_PROJECT_ID ??
|
|
34
|
+
config.defaultProjectId;
|
|
35
|
+
if (!projectId) {
|
|
36
|
+
this.error('Project ID is required. Pass it as an argument, set DUOOPS_PROJECT_ID / CI_PROJECT_ID, or run "duoops init".');
|
|
37
|
+
}
|
|
38
|
+
const provider = getPipelineProvider();
|
|
39
|
+
let pipeline = null;
|
|
40
|
+
if (flags.pipeline) {
|
|
41
|
+
pipeline = await provider.getPipeline(projectId, flags.pipeline);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
this.log('Looking for the latest failing pipeline...');
|
|
45
|
+
const pipelines = await provider.listPipelines(projectId, { perPage: 10, status: 'failed' });
|
|
46
|
+
pipeline = pipelines.find((p) => p.status === 'failed') ?? null;
|
|
47
|
+
}
|
|
48
|
+
if (!pipeline) {
|
|
49
|
+
this.log('No failing pipelines found. Nothing to fix.');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
this.log(`Analyzing pipeline ${pipeline.id} (${pipeline.status}) on ref ${pipeline.ref}...`);
|
|
53
|
+
const jobs = await provider.listJobs(projectId, pipeline.id);
|
|
54
|
+
const prompt = buildAutofixPrompt(pipeline, jobs);
|
|
55
|
+
this.log('Running agent analysis...');
|
|
56
|
+
const analysis = await runAgent(prompt, { projectId });
|
|
57
|
+
if (flags.mr) {
|
|
58
|
+
this.log(`Posting analysis to MR !${flags.mr}...`);
|
|
59
|
+
await postMrNote(projectId, flags.mr, analysis, pipeline);
|
|
60
|
+
this.log('Done. Analysis posted to the merge request.');
|
|
61
|
+
}
|
|
62
|
+
else if (process.env.CI_MERGE_REQUEST_IID) {
|
|
63
|
+
const mrIid = Number(process.env.CI_MERGE_REQUEST_IID);
|
|
64
|
+
this.log(`Posting analysis to MR !${mrIid} (from CI_MERGE_REQUEST_IID)...`);
|
|
65
|
+
await postMrNote(projectId, mrIid, analysis, pipeline);
|
|
66
|
+
this.log('Done. Analysis posted to the merge request.');
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
this.log('\n' + analysis);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function postMrNote(projectId, mrIid, analysis, pipeline) {
|
|
74
|
+
const client = createGitlabClient();
|
|
75
|
+
const body = `## DuoOps Autofix Analysis
|
|
76
|
+
|
|
77
|
+
**Pipeline:** [#${pipeline.id}](${pipeline.webUrl ?? ''}) | **Ref:** \`${pipeline.ref}\` | **Status:** ${pipeline.status}
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
${analysis}
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
*Generated by [DuoOps](https://gitlab.com/youneslaaroussi/duoops) autofix agent*`;
|
|
85
|
+
await client.MergeRequestNotes.create(projectId, mrIid, body);
|
|
86
|
+
}
|
|
87
|
+
function buildAutofixPrompt(pipeline, jobs) {
|
|
88
|
+
const jobSummary = jobs.length === 0
|
|
89
|
+
? 'No jobs were returned for this pipeline.'
|
|
90
|
+
: jobs
|
|
91
|
+
.map((job) => `• Job ${job.id} (${job.name}) in stage ${job.stage} - status: ${job.status}${job.duration ? `, duration: ${job.duration}s` : ''}`)
|
|
92
|
+
.join('\n');
|
|
93
|
+
return `You are DuoOps, an AI pair engineer focused on CI/CD reliability and sustainability.
|
|
94
|
+
Inspect the following failing pipeline and propose actionable fixes.
|
|
95
|
+
|
|
96
|
+
Pipeline:
|
|
97
|
+
- ID: ${pipeline.id}
|
|
98
|
+
- Ref: ${pipeline.ref}
|
|
99
|
+
- Status: ${pipeline.status}
|
|
100
|
+
- SHA: ${pipeline.sha}
|
|
101
|
+
- URL: ${pipeline.webUrl ?? 'n/a'}
|
|
102
|
+
|
|
103
|
+
Jobs:
|
|
104
|
+
${jobSummary}
|
|
105
|
+
|
|
106
|
+
Instructions:
|
|
107
|
+
1. Use the get_job_logs tool to fetch logs for ALL failed jobs. This is critical.
|
|
108
|
+
2. Identify the root cause of the failure based on the logs and job statuses.
|
|
109
|
+
3. Propose concrete remediation steps, referencing jobs and stages explicitly.
|
|
110
|
+
4. Suggest any GitLab CI configuration changes or code adjustments to prevent this regression.
|
|
111
|
+
5. Outline validation steps once the fix is applied.
|
|
112
|
+
|
|
113
|
+
Respond in Markdown with clear sections: Root Cause, Fix Plan, Validation.`;
|
|
114
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Portal from './portal.js';
|
|
2
|
+
const AUTO_MESSAGE = 'Please inspect the most recent failing pipeline, diagnose the issue, and propose a fix.';
|
|
3
|
+
export default class Autofix extends Portal {
|
|
4
|
+
static description = 'Launch the DuoOps portal and pre-fill an autofix request to the agent';
|
|
5
|
+
async run() {
|
|
6
|
+
process.env.DUOOPS_PORTAL_DEFAULT_TAB = 'console';
|
|
7
|
+
process.env.DUOOPS_PORTAL_PRESET_MESSAGE = AUTO_MESSAGE;
|
|
8
|
+
this.log('Launching DuoOps portal with an autofix prompt...');
|
|
9
|
+
await super.run();
|
|
10
|
+
}
|
|
11
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -9,6 +9,8 @@ import os from 'node:os';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { configManager } from '../lib/config.js';
|
|
12
|
+
import { detectGcpProject, enableApis, validateProjectAccess } from '../lib/gcloud.js';
|
|
13
|
+
import McpDeploy from './mcp/deploy.js';
|
|
12
14
|
const hasBinary = (command) => {
|
|
13
15
|
try {
|
|
14
16
|
execSync(`${command} --version`, { stdio: 'ignore' });
|
|
@@ -32,13 +34,6 @@ const detectGitRemotePath = () => {
|
|
|
32
34
|
}
|
|
33
35
|
catch { /* ignore */ }
|
|
34
36
|
};
|
|
35
|
-
const detectGcpProject = () => {
|
|
36
|
-
try {
|
|
37
|
-
const value = execSync('gcloud config get-value project', { encoding: 'utf8' }).trim();
|
|
38
|
-
return value && value !== '(unset)' ? value : undefined;
|
|
39
|
-
}
|
|
40
|
-
catch { }
|
|
41
|
-
};
|
|
42
37
|
const normalizeProjectPath = (project) => project.replace(/^\/+/, '');
|
|
43
38
|
const normalizeGitLabUrl = (url) => url.replace(/\/+$/, '');
|
|
44
39
|
const setGitlabVariable = async (auth, projectPath, variable) => {
|
|
@@ -79,23 +74,6 @@ const createServiceAccountKey = (projectId, serviceAccount) => {
|
|
|
79
74
|
fs.unlinkSync(tmpPath);
|
|
80
75
|
return contents.toString('base64');
|
|
81
76
|
};
|
|
82
|
-
const ensureServiceEnabled = (projectId, service, label, log) => {
|
|
83
|
-
try {
|
|
84
|
-
log?.(gray(`Ensuring ${label} API is enabled...`));
|
|
85
|
-
execSync(`gcloud services enable ${service} --project=${projectId} --quiet`, {
|
|
86
|
-
stdio: 'ignore',
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
log?.(gray(`${label} API enablement skipped (it may already be enabled).`));
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
const ensureComputeApiEnabled = (projectId, log) => {
|
|
94
|
-
ensureServiceEnabled(projectId, 'compute.googleapis.com', 'Compute Engine', log);
|
|
95
|
-
};
|
|
96
|
-
const ensureMonitoringApiEnabled = (projectId, log) => {
|
|
97
|
-
ensureServiceEnabled(projectId, 'monitoring.googleapis.com', 'Cloud Monitoring', log);
|
|
98
|
-
};
|
|
99
77
|
const sleep = (ms) => new Promise((resolve) => {
|
|
100
78
|
setTimeout(resolve, ms);
|
|
101
79
|
});
|
|
@@ -354,7 +332,40 @@ export default class Init extends Command {
|
|
|
354
332
|
};
|
|
355
333
|
configManager.set(config);
|
|
356
334
|
this.log(green('Configuration saved.'));
|
|
357
|
-
|
|
335
|
+
const setupAgents = await confirm({
|
|
336
|
+
default: true,
|
|
337
|
+
message: 'Set up DuoOps agents and flows in this project?',
|
|
338
|
+
});
|
|
339
|
+
if (setupAgents) {
|
|
340
|
+
const { scaffoldProject } = await import('../lib/scaffold.js');
|
|
341
|
+
const result = scaffoldProject(process.cwd());
|
|
342
|
+
for (const f of result.created) {
|
|
343
|
+
this.log(green(` ✓ Created ${f}`));
|
|
344
|
+
}
|
|
345
|
+
for (const f of result.skipped) {
|
|
346
|
+
this.log(gray(` · Skipped ${f} (already exists)`));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
this.log(`\nTry '${this.config.bin} pipelines:list <project>' to verify GitLab access.\n`);
|
|
350
|
+
if (enableMeasure) {
|
|
351
|
+
const deployMcp = await confirm({
|
|
352
|
+
default: true,
|
|
353
|
+
message: 'Deploy the DuoOps MCP Server to Cloud Run now?',
|
|
354
|
+
});
|
|
355
|
+
if (deployMcp && googleProjectId && bigqueryDataset && bigqueryTable) {
|
|
356
|
+
try {
|
|
357
|
+
await McpDeploy.run([
|
|
358
|
+
'--gcp-project', googleProjectId,
|
|
359
|
+
'--bq-dataset', bigqueryDataset,
|
|
360
|
+
'--bq-table', bigqueryTable,
|
|
361
|
+
'--gitlab-url', gitlabUrl,
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
this.warn(`MCP Deployment failed: ${error}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
358
369
|
const configureProject = await confirm({
|
|
359
370
|
default: true,
|
|
360
371
|
message: 'Configure a GitLab project (CI variables + optional GCP runner) now?',
|
|
@@ -418,6 +429,14 @@ export default class Init extends Command {
|
|
|
418
429
|
this.warn('GCP Project ID is required to collect metrics.');
|
|
419
430
|
return setAsDefault ? projectPath : undefined;
|
|
420
431
|
}
|
|
432
|
+
try {
|
|
433
|
+
validateProjectAccess(gcpProjectId);
|
|
434
|
+
this.log(green(` ✓ Verified access to ${gcpProjectId}`));
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
this.warn(error.message);
|
|
438
|
+
return setAsDefault ? projectPath : undefined;
|
|
439
|
+
}
|
|
421
440
|
let serviceAccountEmail = await input({
|
|
422
441
|
default: `duoops-runner@${gcpProjectId}.iam.gserviceaccount.com`,
|
|
423
442
|
message: 'Service account email for DuoOps measurements',
|
|
@@ -522,8 +541,12 @@ export default class Init extends Command {
|
|
|
522
541
|
default: 'gcp',
|
|
523
542
|
message: 'Runner tag (GitLab jobs will use this)',
|
|
524
543
|
});
|
|
525
|
-
|
|
526
|
-
|
|
544
|
+
try {
|
|
545
|
+
enableApis(gcpProjectId, ['compute.googleapis.com', 'monitoring.googleapis.com'], (msg) => this.log(gray(msg)));
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
this.warn(error.message);
|
|
549
|
+
}
|
|
527
550
|
let runnerToken = tryCreateRunnerToken(projectPath, vmName, runnerTag);
|
|
528
551
|
if (!runnerToken) {
|
|
529
552
|
this.log(gray('Unable to create a runner token automatically. Generate one in your GitLab project (Settings → CI/CD → Runners) and paste it below.'));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class McpDeploy extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static flags: {
|
|
5
|
+
'bq-dataset': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
6
|
+
'bq-table': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
|
+
'gcp-project': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
'gitlab-url': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
|
+
region: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
source: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
};
|
|
12
|
+
run(): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { confirm, input } from '@inquirer/prompts';
|
|
2
|
+
import { Command, Flags } from '@oclif/core';
|
|
3
|
+
import { bold, cyan, gray, green } from 'kleur/colors';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { configManager } from '../../lib/config.js';
|
|
9
|
+
import { detectGcpProject, enableApis, ensureCloudBuildServiceAccount, getActiveAccount, requireGcloud, validateProjectAccess } from '../../lib/gcloud.js';
|
|
10
|
+
export default class McpDeploy extends Command {
|
|
11
|
+
static description = 'Deploy the DuoOps MCP server to Google Cloud Run';
|
|
12
|
+
static flags = {
|
|
13
|
+
'bq-dataset': Flags.string({ description: 'BigQuery Dataset ID' }),
|
|
14
|
+
'bq-table': Flags.string({ description: 'BigQuery Table ID' }),
|
|
15
|
+
'gcp-project': Flags.string({ char: 'p', description: 'Google Cloud Project ID' }),
|
|
16
|
+
'gitlab-url': Flags.string({ default: 'https://gitlab.com', description: 'GitLab instance URL' }),
|
|
17
|
+
region: Flags.string({ char: 'r', default: 'us-central1', description: 'Cloud Run region' }),
|
|
18
|
+
'source': Flags.string({ default: '', description: 'Path to MCP server source directory' }),
|
|
19
|
+
};
|
|
20
|
+
async run() {
|
|
21
|
+
const { flags } = await this.parse(McpDeploy);
|
|
22
|
+
const config = configManager.get();
|
|
23
|
+
const gcpProject = flags['gcp-project'] || config.measure?.googleProjectId || await input({
|
|
24
|
+
default: detectGcpProject(),
|
|
25
|
+
message: 'Google Cloud Project ID',
|
|
26
|
+
});
|
|
27
|
+
const bqDataset = flags['bq-dataset'] || config.measure?.bigqueryDataset || await input({
|
|
28
|
+
default: 'measure_data',
|
|
29
|
+
message: 'BigQuery Dataset',
|
|
30
|
+
});
|
|
31
|
+
const bqTable = flags['bq-table'] || config.measure?.bigqueryTable || await input({
|
|
32
|
+
default: 'emissions',
|
|
33
|
+
message: 'BigQuery Table',
|
|
34
|
+
});
|
|
35
|
+
const region = flags.region || await input({
|
|
36
|
+
default: 'us-central1',
|
|
37
|
+
message: 'Cloud Run region',
|
|
38
|
+
});
|
|
39
|
+
const gitlabUrl = flags['gitlab-url'] || config.gitlabUrl || await input({
|
|
40
|
+
default: 'https://gitlab.com',
|
|
41
|
+
message: 'GitLab instance URL',
|
|
42
|
+
});
|
|
43
|
+
if (!gcpProject) {
|
|
44
|
+
this.error('Google Cloud Project ID is required.');
|
|
45
|
+
}
|
|
46
|
+
this.log(bold('Deploying DuoOps MCP Server...'));
|
|
47
|
+
this.log(gray(`Project: ${gcpProject}`));
|
|
48
|
+
this.log(gray(`Region: ${region}`));
|
|
49
|
+
this.log(gray(`Dataset: ${bqDataset}.${bqTable}`));
|
|
50
|
+
try {
|
|
51
|
+
requireGcloud();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
this.error(error.message);
|
|
55
|
+
}
|
|
56
|
+
const account = getActiveAccount();
|
|
57
|
+
this.log(gray(`Account: ${account}`));
|
|
58
|
+
try {
|
|
59
|
+
validateProjectAccess(gcpProject);
|
|
60
|
+
this.log(green(` ✓ Verified access to ${gcpProject}`));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
this.error(error.message);
|
|
64
|
+
}
|
|
65
|
+
this.log(gray('\nEnabling required GCP APIs...'));
|
|
66
|
+
try {
|
|
67
|
+
enableApis(gcpProject, [
|
|
68
|
+
'run.googleapis.com',
|
|
69
|
+
'artifactregistry.googleapis.com',
|
|
70
|
+
'cloudbuild.googleapis.com',
|
|
71
|
+
], (msg) => this.log(gray(msg)));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.error(error.message);
|
|
75
|
+
}
|
|
76
|
+
this.log(gray('\nConfiguring Cloud Build service account...'));
|
|
77
|
+
ensureCloudBuildServiceAccount(gcpProject, (msg) => this.log(gray(msg)));
|
|
78
|
+
const mcpServerDir = flags.source || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../mcp-server');
|
|
79
|
+
if (!fs.existsSync(path.join(mcpServerDir, 'Dockerfile'))) {
|
|
80
|
+
this.error(`MCP server source not found at ${mcpServerDir}. Provide --source flag or run from the DuoOps repo.`);
|
|
81
|
+
}
|
|
82
|
+
const serviceName = 'duoops-mcp';
|
|
83
|
+
const cmd = `gcloud run deploy ${serviceName} \
|
|
84
|
+
--source ${mcpServerDir} \
|
|
85
|
+
--project ${gcpProject} \
|
|
86
|
+
--region ${region} \
|
|
87
|
+
--platform managed \
|
|
88
|
+
--allow-unauthenticated \
|
|
89
|
+
--set-env-vars "GCP_PROJECT_ID=${gcpProject},BQ_DATASET=${bqDataset},BQ_TABLE=${bqTable},GITLAB_URL=${gitlabUrl}" \
|
|
90
|
+
--format="value(status.url)"`;
|
|
91
|
+
this.log(gray(`\nBuilding and deploying from ${mcpServerDir}...`));
|
|
92
|
+
this.log(gray('(This uses Cloud Build and may take 2-3 minutes)\n'));
|
|
93
|
+
let serviceUrl = '';
|
|
94
|
+
try {
|
|
95
|
+
serviceUrl = execSync(cmd, { encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'] }).trim();
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const err = error;
|
|
99
|
+
this.error(`Deployment failed: ${err.stdout || ''}\n${err.stderr || error.message}`);
|
|
100
|
+
}
|
|
101
|
+
if (!serviceUrl) {
|
|
102
|
+
this.error('Deployment succeeded but failed to retrieve service URL.');
|
|
103
|
+
}
|
|
104
|
+
this.log(green(`\n✔ Successfully deployed to: ${bold(serviceUrl)}`));
|
|
105
|
+
this.log(gray('Running health check...'));
|
|
106
|
+
try {
|
|
107
|
+
const health = execSync(`curl -sf ${serviceUrl}/health`, { encoding: 'utf8', timeout: 10_000 }).trim();
|
|
108
|
+
this.log(green(` ✓ ${health}`));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
this.warn('Health check failed — server may still be starting up. Try: curl ' + serviceUrl + '/health');
|
|
112
|
+
}
|
|
113
|
+
const mcpConfig = {
|
|
114
|
+
mcpServers: {
|
|
115
|
+
"duoops-carbon": {
|
|
116
|
+
approvedTools: true,
|
|
117
|
+
type: "http",
|
|
118
|
+
url: `${serviceUrl}/mcp`
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const configStr = JSON.stringify(mcpConfig, null, 2);
|
|
123
|
+
this.log(bold('\nMCP Configuration:'));
|
|
124
|
+
this.log(cyan(configStr));
|
|
125
|
+
const writeConfig = await confirm({
|
|
126
|
+
default: true,
|
|
127
|
+
message: 'Write mcp.json to .gitlab/duo/mcp.json?',
|
|
128
|
+
});
|
|
129
|
+
if (writeConfig) {
|
|
130
|
+
const configDir = path.join(process.cwd(), '.gitlab', 'duo');
|
|
131
|
+
const configPath = path.join(configDir, 'mcp.json');
|
|
132
|
+
if (!fs.existsSync(configDir)) {
|
|
133
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
134
|
+
}
|
|
135
|
+
fs.writeFileSync(configPath, configStr);
|
|
136
|
+
this.log(green(`Wrote configuration to ${configPath}`));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -80,13 +80,13 @@ export default class CarbonCalculate extends Command {
|
|
|
80
80
|
this.log(gray(`Loaded ${cpuTimeseries.length} CPU points, ${ramUsedTimeseries.length} RAM points`));
|
|
81
81
|
// Initialize dependencies
|
|
82
82
|
// Locate data directory relative to the compiled file or project root
|
|
83
|
-
// In production (dist), __dirname is .../dist/commands/
|
|
83
|
+
// In production (dist), __dirname is .../dist/commands/measure
|
|
84
84
|
// data is at .../data
|
|
85
85
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
86
86
|
// Try to resolve data directory
|
|
87
87
|
// 1. From source (development)
|
|
88
88
|
// 2. From dist (production/built)
|
|
89
|
-
let dataDir = path.resolve(__dirname, '
|
|
89
|
+
let dataDir = path.resolve(__dirname, '../../../data');
|
|
90
90
|
if (!fs.existsSync(dataDir)) {
|
|
91
91
|
// Fallback for different structures or if run from different location
|
|
92
92
|
dataDir = path.resolve(process.cwd(), 'data');
|