duoops 0.1.9 → 0.2.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/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 +104 -33
- 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 +147 -2
- 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' });
|
|
@@ -18,6 +20,29 @@ const hasBinary = (command) => {
|
|
|
18
20
|
return false;
|
|
19
21
|
}
|
|
20
22
|
};
|
|
23
|
+
const detectGlabToken = (host = 'gitlab.com') => {
|
|
24
|
+
const candidates = [
|
|
25
|
+
path.join(os.homedir(), '.config', 'glab-cli', 'config.yml'),
|
|
26
|
+
path.join(os.homedir(), 'Library', 'Application Support', 'glab-cli', 'config.yml'),
|
|
27
|
+
];
|
|
28
|
+
for (const configPath of candidates) {
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(configPath))
|
|
31
|
+
continue;
|
|
32
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
33
|
+
const hostSection = content.split(`${host}:`)[1];
|
|
34
|
+
if (!hostSection)
|
|
35
|
+
continue;
|
|
36
|
+
const isOauth = hostSection.match(/is_oauth2:\s*"?true"?/);
|
|
37
|
+
if (isOauth)
|
|
38
|
+
continue;
|
|
39
|
+
const tokenMatch = hostSection.match(/token:\s*(?:!!null\s+)?(\S+)/);
|
|
40
|
+
if (tokenMatch?.[1] && tokenMatch[1].startsWith('glpat-'))
|
|
41
|
+
return tokenMatch[1];
|
|
42
|
+
}
|
|
43
|
+
catch { /* ignore */ }
|
|
44
|
+
}
|
|
45
|
+
};
|
|
21
46
|
const detectGitRemotePath = () => {
|
|
22
47
|
try {
|
|
23
48
|
const remote = execSync('git remote get-url origin', { encoding: 'utf8' }).trim();
|
|
@@ -32,13 +57,6 @@ const detectGitRemotePath = () => {
|
|
|
32
57
|
}
|
|
33
58
|
catch { /* ignore */ }
|
|
34
59
|
};
|
|
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
60
|
const normalizeProjectPath = (project) => project.replace(/^\/+/, '');
|
|
43
61
|
const normalizeGitLabUrl = (url) => url.replace(/\/+$/, '');
|
|
44
62
|
const setGitlabVariable = async (auth, projectPath, variable) => {
|
|
@@ -79,23 +97,6 @@ const createServiceAccountKey = (projectId, serviceAccount) => {
|
|
|
79
97
|
fs.unlinkSync(tmpPath);
|
|
80
98
|
return contents.toString('base64');
|
|
81
99
|
};
|
|
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
100
|
const sleep = (ms) => new Promise((resolve) => {
|
|
100
101
|
setTimeout(resolve, ms);
|
|
101
102
|
});
|
|
@@ -270,16 +271,41 @@ export default class Init extends Command {
|
|
|
270
271
|
static description = 'Initialize DuoOps, optionally wiring a GitLab project and GCP runner';
|
|
271
272
|
async run() {
|
|
272
273
|
this.log(bold('Welcome to DuoOps!'));
|
|
273
|
-
this.log('You will need a GitLab Personal Access Token with the "api" scope.');
|
|
274
|
-
this.log('Create one at https://gitlab.com/-/user_settings/personal_access_tokens\n');
|
|
275
274
|
const gitlabUrl = await input({
|
|
276
275
|
default: 'https://gitlab.com',
|
|
277
276
|
message: 'GitLab URL',
|
|
278
277
|
});
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
278
|
+
const { host } = new URL(gitlabUrl);
|
|
279
|
+
const glabToken = detectGlabToken(host);
|
|
280
|
+
const envToken = process.env.GITLAB_TOKEN || process.env.GL_TOKEN;
|
|
281
|
+
let gitlabToken;
|
|
282
|
+
if (glabToken || envToken) {
|
|
283
|
+
const source = glabToken ? 'glab CLI' : 'environment';
|
|
284
|
+
const detected = glabToken || envToken;
|
|
285
|
+
const masked = detected.slice(0, 6) + '...' + detected.slice(-4);
|
|
286
|
+
const useDetected = await confirm({
|
|
287
|
+
default: true,
|
|
288
|
+
message: `Found GitLab token from ${source} (${masked}). Use it?`,
|
|
289
|
+
});
|
|
290
|
+
if (useDetected) {
|
|
291
|
+
gitlabToken = detected;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
this.log('Create one at https://gitlab.com/-/user_settings/personal_access_tokens\n');
|
|
295
|
+
gitlabToken = await password({
|
|
296
|
+
mask: '*',
|
|
297
|
+
message: 'GitLab Personal Access Token',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
this.log('You will need a GitLab Personal Access Token with the "api" scope.');
|
|
303
|
+
this.log('Create one at https://gitlab.com/-/user_settings/personal_access_tokens\n');
|
|
304
|
+
gitlabToken = await password({
|
|
305
|
+
mask: '*',
|
|
306
|
+
message: 'GitLab Personal Access Token',
|
|
307
|
+
});
|
|
308
|
+
}
|
|
283
309
|
const enableMeasure = await confirm({
|
|
284
310
|
message: 'Enable Measure/Sustainability Tracking (BigQuery)?',
|
|
285
311
|
});
|
|
@@ -354,7 +380,40 @@ export default class Init extends Command {
|
|
|
354
380
|
};
|
|
355
381
|
configManager.set(config);
|
|
356
382
|
this.log(green('Configuration saved.'));
|
|
357
|
-
|
|
383
|
+
const setupAgents = await confirm({
|
|
384
|
+
default: true,
|
|
385
|
+
message: 'Set up DuoOps agents and flows in this project?',
|
|
386
|
+
});
|
|
387
|
+
if (setupAgents) {
|
|
388
|
+
const { scaffoldProject } = await import('../lib/scaffold.js');
|
|
389
|
+
const result = scaffoldProject(process.cwd());
|
|
390
|
+
for (const f of result.created) {
|
|
391
|
+
this.log(green(` ✓ Created ${f}`));
|
|
392
|
+
}
|
|
393
|
+
for (const f of result.skipped) {
|
|
394
|
+
this.log(gray(` · Skipped ${f} (already exists)`));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
this.log(`\nTry '${this.config.bin} pipelines:list <project>' to verify GitLab access.\n`);
|
|
398
|
+
if (enableMeasure) {
|
|
399
|
+
const deployMcp = await confirm({
|
|
400
|
+
default: true,
|
|
401
|
+
message: 'Deploy the DuoOps MCP Server to Cloud Run now?',
|
|
402
|
+
});
|
|
403
|
+
if (deployMcp && googleProjectId && bigqueryDataset && bigqueryTable) {
|
|
404
|
+
try {
|
|
405
|
+
await McpDeploy.run([
|
|
406
|
+
'--gcp-project', googleProjectId,
|
|
407
|
+
'--bq-dataset', bigqueryDataset,
|
|
408
|
+
'--bq-table', bigqueryTable,
|
|
409
|
+
'--gitlab-url', gitlabUrl,
|
|
410
|
+
]);
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
this.warn(`MCP Deployment failed: ${error}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
358
417
|
const configureProject = await confirm({
|
|
359
418
|
default: true,
|
|
360
419
|
message: 'Configure a GitLab project (CI variables + optional GCP runner) now?',
|
|
@@ -418,6 +477,14 @@ export default class Init extends Command {
|
|
|
418
477
|
this.warn('GCP Project ID is required to collect metrics.');
|
|
419
478
|
return setAsDefault ? projectPath : undefined;
|
|
420
479
|
}
|
|
480
|
+
try {
|
|
481
|
+
validateProjectAccess(gcpProjectId);
|
|
482
|
+
this.log(green(` ✓ Verified access to ${gcpProjectId}`));
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
this.warn(error.message);
|
|
486
|
+
return setAsDefault ? projectPath : undefined;
|
|
487
|
+
}
|
|
421
488
|
let serviceAccountEmail = await input({
|
|
422
489
|
default: `duoops-runner@${gcpProjectId}.iam.gserviceaccount.com`,
|
|
423
490
|
message: 'Service account email for DuoOps measurements',
|
|
@@ -522,8 +589,12 @@ export default class Init extends Command {
|
|
|
522
589
|
default: 'gcp',
|
|
523
590
|
message: 'Runner tag (GitLab jobs will use this)',
|
|
524
591
|
});
|
|
525
|
-
|
|
526
|
-
|
|
592
|
+
try {
|
|
593
|
+
enableApis(gcpProjectId, ['compute.googleapis.com', 'monitoring.googleapis.com'], (msg) => this.log(gray(msg)));
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
this.warn(error.message);
|
|
597
|
+
}
|
|
527
598
|
let runnerToken = tryCreateRunnerToken(projectPath, vmName, runnerTag);
|
|
528
599
|
if (!runnerToken) {
|
|
529
600
|
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
|
+
}
|