@nimbus-dev/connectors 0.7.0 → 0.8.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/connectors/apple/src/adapters.ts +56 -1
- package/connectors/apple/src/caldav-core.ts +7 -0
- package/connectors/apple/src/calendar-tools.ts +34 -0
- package/connectors/apple/src/tools.ts +2 -1
- package/connectors/aws/src/tools.ts +110 -0
- package/connectors/azure/src/tools.ts +70 -0
- package/connectors/circleci/src/tools.ts +63 -1
- package/connectors/gcp/src/tools.ts +74 -16
- package/connectors/github/src/tools.ts +298 -6
- package/connectors/gitlab/src/gitlab-files.ts +213 -0
- package/connectors/gitlab/src/tools.ts +226 -0
- package/connectors/kubernetes/src/tools.ts +35 -15
- package/connectors/snowflake/src/server.ts +69 -2
- package/package.json +1 -1
- package/shared/audit-chain.ts +5 -2
- package/shared/commit-files.ts +138 -0
- package/shared/consent-kit.ts +141 -31
- package/shared/github-files.ts +195 -0
- package/shared/github-rest.ts +12 -0
- package/shared/k8s-deployment-view.ts +68 -0
- package/shared/prepare-tool-ids.ts +21 -0
- package/shared/private-kubeconfig.ts +110 -0
|
@@ -190,7 +190,8 @@ export interface DavClientLike {
|
|
|
190
190
|
calendar: DAVCalendar;
|
|
191
191
|
timeRange?: { start: string; end: string };
|
|
192
192
|
expand?: boolean;
|
|
193
|
-
|
|
193
|
+
objectUrls?: string[];
|
|
194
|
+
}): Promise<{ url: string; etag?: string | undefined; data?: unknown }[]>;
|
|
194
195
|
createCalendarObject(params: {
|
|
195
196
|
calendar: DAVCalendar;
|
|
196
197
|
iCalString: string;
|
|
@@ -204,6 +205,8 @@ export interface LoggingInCalDavClient extends CalDavClient {
|
|
|
204
205
|
login(): Promise<void>;
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
const NOT_IN_YOUR_CALENDARS = "href is not in one of your calendars";
|
|
209
|
+
|
|
207
210
|
class TsdavCalDavClient implements LoggingInCalDavClient {
|
|
208
211
|
/**
|
|
209
212
|
* Discovered DAVCalendar objects keyed by their url. Populated after the
|
|
@@ -282,7 +285,59 @@ class TsdavCalDavClient implements LoggingInCalDavClient {
|
|
|
282
285
|
return { href };
|
|
283
286
|
}
|
|
284
287
|
|
|
288
|
+
/**
|
|
289
|
+
* The discovered calendar whose URL contains `href`, or undefined. Both are compared as normalised
|
|
290
|
+
* absolute URLs (dot segments resolved), the calendar's with a trailing slash, so `.../home/` does
|
|
291
|
+
* not own `.../home-evil/x.ics` or `.../home/../other/x.ics`, and another host owns nothing. The
|
|
292
|
+
* calendars are discovered first when none has been.
|
|
293
|
+
*/
|
|
294
|
+
private async calendarOwning(href: string): Promise<DAVCalendar | undefined> {
|
|
295
|
+
if (this.discovered.size === 0) await this.listCalendars();
|
|
296
|
+
let target: string;
|
|
297
|
+
try {
|
|
298
|
+
target = new URL(href).toString();
|
|
299
|
+
} catch {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
for (const [url, cal] of this.discovered) {
|
|
303
|
+
let base: string;
|
|
304
|
+
try {
|
|
305
|
+
base = new URL(url).toString();
|
|
306
|
+
} catch {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (!base.endsWith("/")) base = `${base}/`;
|
|
310
|
+
if (target.startsWith(base) && target.length > base.length) return cal;
|
|
311
|
+
}
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async getEvent(
|
|
316
|
+
href: string,
|
|
317
|
+
): Promise<{ calendar: string; etag: string | null; event: ParsedEvent }> {
|
|
318
|
+
const cal = await this.calendarOwning(href);
|
|
319
|
+
if (cal === undefined) throw new Error(NOT_IN_YOUR_CALENDARS);
|
|
320
|
+
const objects = await this.davClient.fetchCalendarObjects({
|
|
321
|
+
calendar: cal,
|
|
322
|
+
objectUrls: [href],
|
|
323
|
+
});
|
|
324
|
+
for (const obj of objects) {
|
|
325
|
+
if (typeof obj.data !== "string" || obj.data === "") continue;
|
|
326
|
+
const event = parseICalendar(obj.data)[0];
|
|
327
|
+
if (event === undefined) continue;
|
|
328
|
+
const name =
|
|
329
|
+
typeof cal.displayName === "string" && cal.displayName !== "" ? cal.displayName : cal.url;
|
|
330
|
+
return { calendar: name, etag: typeof obj.etag === "string" ? obj.etag : null, event };
|
|
331
|
+
}
|
|
332
|
+
throw new Error("event not found");
|
|
333
|
+
}
|
|
334
|
+
|
|
285
335
|
async deleteEvent(href: string): Promise<void> {
|
|
336
|
+
// The credential goes to whatever host the href names, and the href is the model's: refuse one
|
|
337
|
+
// that is not under a calendar this account discovered, before any request.
|
|
338
|
+
if ((await this.calendarOwning(href)) === undefined) {
|
|
339
|
+
throw new Error(NOT_IN_YOUR_CALENDARS);
|
|
340
|
+
}
|
|
286
341
|
// etag: "" means "unconditional delete" — acceptable because we don't track
|
|
287
342
|
// etags in the CalendarRef/EventWindow flow.
|
|
288
343
|
await this.davClient.deleteCalendarObject({
|
|
@@ -38,6 +38,13 @@ export interface CalDavClient {
|
|
|
38
38
|
window: EventWindow,
|
|
39
39
|
): Promise<{ href: string; event: ParsedEvent }[]>;
|
|
40
40
|
putEvent(cal: CalendarRef, uid: string, ics: string): Promise<{ href: string }>;
|
|
41
|
+
/**
|
|
42
|
+
* One event by its object URL, with the entity tag the server holds for it (RFC 4791 5.3.4: a GET on
|
|
43
|
+
* a calendar object resource MUST return an ETag). `href` must lie under one of the account's own
|
|
44
|
+
* calendars; any other is refused BEFORE a request is made.
|
|
45
|
+
*/
|
|
46
|
+
getEvent(href: string): Promise<{ calendar: string; etag: string | null; event: ParsedEvent }>;
|
|
47
|
+
/** Delete by object URL; `href` must lie under one of the account's own calendars. */
|
|
41
48
|
deleteEvent(href: string): Promise<void>;
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -229,6 +229,40 @@ export function registerAppleCalendarTools(
|
|
|
229
229
|
},
|
|
230
230
|
);
|
|
231
231
|
|
|
232
|
+
// -------------------------------------------------------------------------
|
|
233
|
+
// apple_calendar_event_get
|
|
234
|
+
// -------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
registerSimpleTool(
|
|
237
|
+
"apple_calendar_event_get",
|
|
238
|
+
"Get one iCloud Calendar event by its object href (as apple_calendar_list returns it), with its ETag. The href must be under one of your calendars. The before-state of apple_calendar_event_delete; same href.",
|
|
239
|
+
deleteArgs.shape,
|
|
240
|
+
async (args: unknown) => {
|
|
241
|
+
const parsed = deleteArgs.safeParse(args);
|
|
242
|
+
if (!parsed.success) {
|
|
243
|
+
throw new Error(parsed.error.message);
|
|
244
|
+
}
|
|
245
|
+
const { calendar: calendarName, etag, event } = await calendar.getEvent(parsed.data.href);
|
|
246
|
+
const view: ViewEvent & { etag: string | null } = {
|
|
247
|
+
href: parsed.data.href,
|
|
248
|
+
uid: event.uid,
|
|
249
|
+
summary: event.summary,
|
|
250
|
+
start: event.start,
|
|
251
|
+
end: event.end,
|
|
252
|
+
allDay: event.allDay,
|
|
253
|
+
location: event.location,
|
|
254
|
+
organizer: event.organizer,
|
|
255
|
+
status: event.status,
|
|
256
|
+
attendees: event.attendees,
|
|
257
|
+
rrule: event.rrule,
|
|
258
|
+
notes: capPreview(event.description ?? ""),
|
|
259
|
+
calendar: calendarName,
|
|
260
|
+
etag,
|
|
261
|
+
};
|
|
262
|
+
return mcpJsonResult(view);
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
|
|
232
266
|
// -------------------------------------------------------------------------
|
|
233
267
|
// apple_calendar_event_create
|
|
234
268
|
// -------------------------------------------------------------------------
|
|
@@ -90,7 +90,7 @@ export function registerAppleTools(
|
|
|
90
90
|
},
|
|
91
91
|
);
|
|
92
92
|
|
|
93
|
-
// apple_calendar_list, apple_calendar_event_create, apple_calendar_event_delete
|
|
93
|
+
// apple_calendar_list, apple_calendar_event_get, apple_calendar_event_create, apple_calendar_event_delete
|
|
94
94
|
registerAppleCalendarTools(server, {
|
|
95
95
|
calendar,
|
|
96
96
|
now,
|
|
@@ -107,6 +107,7 @@ export const APPLE_TOOL_NAMES = [
|
|
|
107
107
|
"apple_mail_send",
|
|
108
108
|
"apple_mail_draft_create",
|
|
109
109
|
"apple_calendar_list",
|
|
110
|
+
"apple_calendar_event_get",
|
|
110
111
|
"apple_calendar_event_create",
|
|
111
112
|
"apple_calendar_event_delete",
|
|
112
113
|
] as const;
|
|
@@ -38,10 +38,79 @@ async function awsJson(args: string[]): Promise<unknown> {
|
|
|
38
38
|
return (await runCliJsonThrowing(cmd, awsEnv())) ?? {};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** One EC2 instance id: `i-` and 8 to 17 lowercase hex digits (the long and the legacy short form). */
|
|
42
|
+
const ec2InstanceId = awsCliArg.regex(/^i-[0-9a-f]{8,17}$/, "not an EC2 instance id (i-...)");
|
|
43
|
+
|
|
44
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
45
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The projection of `aws ec2 describe-instances --instance-ids <id>`. Field names are those on
|
|
50
|
+
* https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instances.html:
|
|
51
|
+
* `Reservations[].Instances[]` with `InstanceId`, `InstanceType`, `LaunchTime`, `State{Code,Name}`,
|
|
52
|
+
* `IamInstanceProfile{Arn,Id}` and `Tags[]{Key,Value}`. No instance in the reply (AWS omits one you
|
|
53
|
+
* do not own) throws "instance not found".
|
|
54
|
+
*/
|
|
55
|
+
function projectInstance(raw: unknown): Record<string, unknown> {
|
|
56
|
+
const reservations =
|
|
57
|
+
isRecord(raw) && Array.isArray(raw["Reservations"]) ? raw["Reservations"] : [];
|
|
58
|
+
for (const r of reservations) {
|
|
59
|
+
const instances = isRecord(r) && Array.isArray(r["Instances"]) ? r["Instances"] : [];
|
|
60
|
+
for (const i of instances) {
|
|
61
|
+
if (!isRecord(i) || typeof i["InstanceId"] !== "string") continue;
|
|
62
|
+
const state = isRecord(i["State"]) ? i["State"]["Name"] : undefined;
|
|
63
|
+
const profile = isRecord(i["IamInstanceProfile"])
|
|
64
|
+
? i["IamInstanceProfile"]["Arn"]
|
|
65
|
+
: undefined;
|
|
66
|
+
const tags = (Array.isArray(i["Tags"]) ? i["Tags"] : [])
|
|
67
|
+
.filter(isRecord)
|
|
68
|
+
.map((t) => ({ key: t["Key"], value: t["Value"] }));
|
|
69
|
+
return {
|
|
70
|
+
instanceId: i["InstanceId"],
|
|
71
|
+
state: typeof state === "string" ? state : undefined,
|
|
72
|
+
instanceType: i["InstanceType"],
|
|
73
|
+
launchTime: i["LaunchTime"],
|
|
74
|
+
tags,
|
|
75
|
+
iamProfileArn: typeof profile === "string" ? profile : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
throw new Error("instance not found");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The projection of `aws ecs describe-services --cluster X --services Y`
|
|
84
|
+
* (https://docs.aws.amazon.com/cli/latest/reference/ecs/describe-services.html): `services[]` with
|
|
85
|
+
* `serviceName, status, desiredCount, runningCount, taskDefinition, launchType`, and `failures[]`
|
|
86
|
+
* for a service it could not describe (a missing one comes back there, with exit code 0). A service
|
|
87
|
+
* in `failures` with none in `services`, or no entry in `services` at all, is "service not found".
|
|
88
|
+
* An INACTIVE service is still listed by the API and is reported with its status.
|
|
89
|
+
*/
|
|
90
|
+
function projectEcsService(raw: unknown): Record<string, unknown> {
|
|
91
|
+
const services = isRecord(raw) && Array.isArray(raw["services"]) ? raw["services"] : [];
|
|
92
|
+
const service = services.find((s) => isRecord(s) && typeof s["serviceName"] === "string");
|
|
93
|
+
if (!isRecord(service)) throw new Error("service not found");
|
|
94
|
+
const out: Record<string, unknown> = {};
|
|
95
|
+
for (const k of [
|
|
96
|
+
"serviceName",
|
|
97
|
+
"status",
|
|
98
|
+
"desiredCount",
|
|
99
|
+
"runningCount",
|
|
100
|
+
"taskDefinition",
|
|
101
|
+
"launchType",
|
|
102
|
+
] as const) {
|
|
103
|
+
if (service[k] !== undefined && service[k] !== null) out[k] = service[k];
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
41
108
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
42
109
|
export const AWS_TOOL_NAMES = [
|
|
43
110
|
"aws_ecs_service_list",
|
|
111
|
+
"aws_ecs_service_get",
|
|
44
112
|
"aws_lambda_list",
|
|
113
|
+
"aws_ec2_instance_get",
|
|
45
114
|
"aws_ecs_service_update",
|
|
46
115
|
"aws_lambda_invoke",
|
|
47
116
|
"aws_ec2_instance_stop",
|
|
@@ -71,10 +140,51 @@ export function registerAwsTools(
|
|
|
71
140
|
async (p) => jsonResult(await awsJson(["ecs", "list-services", "--cluster", p.cluster])),
|
|
72
141
|
);
|
|
73
142
|
|
|
143
|
+
reg(
|
|
144
|
+
"aws_ecs_service_get",
|
|
145
|
+
"Get one ECS service's state: {serviceName, status, desiredCount, runningCount, taskDefinition, launchType}. The before-state of aws_ecs_service_update; same cluster and service. A service the API reports as a failure, or does not return, is 'service not found'.",
|
|
146
|
+
z.object({ cluster: awsCliArg, service: awsCliArg }),
|
|
147
|
+
async (p) =>
|
|
148
|
+
jsonResult(
|
|
149
|
+
projectEcsService(
|
|
150
|
+
await awsJson([
|
|
151
|
+
"ecs",
|
|
152
|
+
"describe-services",
|
|
153
|
+
"--cluster",
|
|
154
|
+
p.cluster,
|
|
155
|
+
"--services",
|
|
156
|
+
p.service,
|
|
157
|
+
]),
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
|
|
74
162
|
reg("aws_lambda_list", "List Lambda functions (first page).", z.object({}), async () =>
|
|
75
163
|
jsonResult(await awsJson(["lambda", "list-functions"])),
|
|
76
164
|
);
|
|
77
165
|
|
|
166
|
+
reg(
|
|
167
|
+
"aws_ec2_instance_get",
|
|
168
|
+
"Get one EC2 instance's state: {instanceId, state, instanceType, launchTime, tags, iamProfileArn}. The before-state of a start or a stop.",
|
|
169
|
+
z.object({ instanceId: ec2InstanceId }),
|
|
170
|
+
async (p) => {
|
|
171
|
+
try {
|
|
172
|
+
return jsonResult(
|
|
173
|
+
projectInstance(
|
|
174
|
+
await awsJson(["ec2", "describe-instances", "--instance-ids", p.instanceId]),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
// The error code for a well-formed id that does not exist is InvalidInstanceID.NotFound
|
|
179
|
+
// (EC2 error codes); the describe-instances page names none, so the code is matched here.
|
|
180
|
+
if (e instanceof Error && /InvalidInstanceID.NotFound/.test(e.message)) {
|
|
181
|
+
throw new Error("instance not found", { cause: e });
|
|
182
|
+
}
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
78
188
|
registerWriteTool(
|
|
79
189
|
"aws_ecs_service_update",
|
|
80
190
|
{
|
|
@@ -18,9 +18,50 @@ async function azJson(args: string[]): Promise<unknown> {
|
|
|
18
18
|
return (await runCliJsonThrowing(cmd, azEnv())) ?? {};
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The node-pool keys `az aks nodepool show` is projected to. The page
|
|
23
|
+
* (https://learn.microsoft.com/en-us/cli/azure/aks/nodepool) does not list the output fields, so
|
|
24
|
+
* only the keys PRESENT in the reply are kept and an absent one stays absent.
|
|
25
|
+
*/
|
|
26
|
+
const NODE_POOL_KEYS = [
|
|
27
|
+
"count",
|
|
28
|
+
"vmSize",
|
|
29
|
+
"mode",
|
|
30
|
+
"powerState",
|
|
31
|
+
"provisioningState",
|
|
32
|
+
"orchestratorVersion",
|
|
33
|
+
"enableAutoScaling",
|
|
34
|
+
"minCount",
|
|
35
|
+
"maxCount",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
function projectNodePool(raw: unknown): Record<string, unknown> {
|
|
39
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
40
|
+
throw new Error("node pool not found");
|
|
41
|
+
}
|
|
42
|
+
const rec = raw as Record<string, unknown>;
|
|
43
|
+
const out: Record<string, unknown> = {};
|
|
44
|
+
for (const k of NODE_POOL_KEYS) {
|
|
45
|
+
if (rec[k] !== undefined && rec[k] !== null) out[k] = rec[k];
|
|
46
|
+
}
|
|
47
|
+
// None of the keys present: not a node pool reply, so the owner is not shown an empty before-state.
|
|
48
|
+
if (Object.keys(out).length === 0) throw new Error("node pool not found");
|
|
49
|
+
// powerState is an object ({code}) in az's output; report its code as the plain string it is.
|
|
50
|
+
const ps = out["powerState"];
|
|
51
|
+
if (
|
|
52
|
+
typeof ps === "object" &&
|
|
53
|
+
ps !== null &&
|
|
54
|
+
typeof (ps as { code?: unknown }).code === "string"
|
|
55
|
+
) {
|
|
56
|
+
out["powerState"] = (ps as { code: string }).code;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
21
61
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
22
62
|
export const AZURE_TOOL_NAMES = [
|
|
23
63
|
"azure_app_service_list",
|
|
64
|
+
"azure_aks_node_pool_get",
|
|
24
65
|
"azure_app_service_restart",
|
|
25
66
|
"azure_aks_node_pool_scale",
|
|
26
67
|
] as const;
|
|
@@ -61,6 +102,35 @@ export function registerAzureTools(
|
|
|
61
102
|
),
|
|
62
103
|
);
|
|
63
104
|
|
|
105
|
+
reg(
|
|
106
|
+
"azure_aks_node_pool_get",
|
|
107
|
+
"Get one AKS node pool's state (count, vmSize, mode, powerState, provisioningState, orchestratorVersion, autoscaler bounds; absent keys omitted). The before-state of azure_aks_node_pool_scale; same keys.",
|
|
108
|
+
z.object({
|
|
109
|
+
subscriptionId: azCliArg,
|
|
110
|
+
resourceGroup: azCliArg,
|
|
111
|
+
clusterName: azCliArg,
|
|
112
|
+
poolName: azCliArg,
|
|
113
|
+
}),
|
|
114
|
+
async (p) =>
|
|
115
|
+
jsonResult(
|
|
116
|
+
projectNodePool(
|
|
117
|
+
await azJson([
|
|
118
|
+
"aks",
|
|
119
|
+
"nodepool",
|
|
120
|
+
"show",
|
|
121
|
+
"--subscription",
|
|
122
|
+
p.subscriptionId,
|
|
123
|
+
"--resource-group",
|
|
124
|
+
p.resourceGroup,
|
|
125
|
+
"--cluster-name",
|
|
126
|
+
p.clusterName,
|
|
127
|
+
"--name",
|
|
128
|
+
p.poolName,
|
|
129
|
+
]),
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
|
|
64
134
|
registerWriteTool(
|
|
65
135
|
"azure_app_service_restart",
|
|
66
136
|
{
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { ConsentServer } from "../../../shared/consent-kit.ts";
|
|
3
3
|
import { createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
4
|
+
import { guardedFetch } from "../../../shared/guarded-fetch.ts";
|
|
4
5
|
import {
|
|
5
6
|
createRegisterSimpleTool,
|
|
6
7
|
createZodToolRegistrar,
|
|
@@ -30,7 +31,9 @@ async function circleciFetch(
|
|
|
30
31
|
init?: RequestInit,
|
|
31
32
|
): Promise<RestFetchResult> {
|
|
32
33
|
const url = path.startsWith("http") ? path : `${CCI_API}${path}`;
|
|
33
|
-
|
|
34
|
+
// guardedFetch: the Circle-Token header must never follow a redirect to another origin, and a
|
|
35
|
+
// redirect on a write is refused rather than replayed.
|
|
36
|
+
const res = await guardedFetch("CircleCI", url, {
|
|
34
37
|
...init,
|
|
35
38
|
headers: {
|
|
36
39
|
Accept: "application/json",
|
|
@@ -41,6 +44,47 @@ async function circleciFetch(
|
|
|
41
44
|
return toRestFetchResult(res);
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
function str(v: unknown): string | undefined {
|
|
48
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The job fields a cancel preview needs, read defensively: `GET /project/{slug}/job/{n}` is not on
|
|
53
|
+
* the CircleCI v2 reference page (only `GET /jobs/{job_id}`, whose fields are `name, job_number,
|
|
54
|
+
* status, pipeline_id, started_at, stopped_at`), so each field is taken under both spellings when
|
|
55
|
+
* one is plausible and an absent one is left out. Live check item.
|
|
56
|
+
*/
|
|
57
|
+
export function projectJob(raw: unknown): Record<string, unknown> {
|
|
58
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
59
|
+
throw new Error("CircleCI returned no job");
|
|
60
|
+
}
|
|
61
|
+
const r = raw as Record<string, unknown>;
|
|
62
|
+
const pipeline =
|
|
63
|
+
typeof r["pipeline"] === "object" && r["pipeline"] !== null
|
|
64
|
+
? (r["pipeline"] as Record<string, unknown>)
|
|
65
|
+
: {};
|
|
66
|
+
const out: Record<string, unknown> = {};
|
|
67
|
+
const set = (k: string, v: unknown): void => {
|
|
68
|
+
if (v !== undefined) out[k] = v;
|
|
69
|
+
};
|
|
70
|
+
set("name", str(r["name"]));
|
|
71
|
+
set(
|
|
72
|
+
"number",
|
|
73
|
+
typeof r["number"] === "number"
|
|
74
|
+
? r["number"]
|
|
75
|
+
: typeof r["job_number"] === "number"
|
|
76
|
+
? r["job_number"]
|
|
77
|
+
: undefined,
|
|
78
|
+
);
|
|
79
|
+
set("status", str(r["status"]));
|
|
80
|
+
set("startedAt", str(r["started_at"]));
|
|
81
|
+
set("stoppedAt", str(r["stopped_at"]));
|
|
82
|
+
set("webUrl", str(r["web_url"]));
|
|
83
|
+
set("pipelineId", str(r["pipeline_id"]) ?? str(pipeline["id"]));
|
|
84
|
+
if (Object.keys(out).length === 0) throw new Error("CircleCI returned no job");
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
44
88
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
45
89
|
export const CIRCLECI_TOOL_NAMES = [
|
|
46
90
|
"circleci_pipeline_list",
|
|
@@ -48,6 +92,7 @@ export const CIRCLECI_TOOL_NAMES = [
|
|
|
48
92
|
"circleci_workflow_list",
|
|
49
93
|
"circleci_job_list",
|
|
50
94
|
"circleci_job_artifacts",
|
|
95
|
+
"circleci_job_get",
|
|
51
96
|
"circleci_pipeline_trigger",
|
|
52
97
|
"circleci_job_cancel",
|
|
53
98
|
] as const;
|
|
@@ -132,6 +177,23 @@ export function registerCircleciTools(
|
|
|
132
177
|
`/project/${projectPathSegments(parsed.projectSlug)}/job/${String(parsed.jobNumber)}/artifacts`,
|
|
133
178
|
);
|
|
134
179
|
|
|
180
|
+
reg(
|
|
181
|
+
"circleci_job_get",
|
|
182
|
+
"Get one job under a project by job number: {name, number, status, startedAt, stoppedAt, webUrl, pipelineId}. The before-state of circleci_job_cancel; same arguments.",
|
|
183
|
+
projectSlugSchema.extend({ jobNumber: z.number().int().min(1) }),
|
|
184
|
+
async (parsed) => {
|
|
185
|
+
const token = requireProcessEnv("CIRCLECI_API_TOKEN");
|
|
186
|
+
const res = await circleciFetch(
|
|
187
|
+
token,
|
|
188
|
+
`/project/${projectPathSegments(parsed.projectSlug)}/job/${String(parsed.jobNumber)}`,
|
|
189
|
+
);
|
|
190
|
+
if (!res.ok) {
|
|
191
|
+
throw new Error(`CircleCI ${String(res.status)}: ${res.text.slice(0, 400)}`);
|
|
192
|
+
}
|
|
193
|
+
return jsonResult(projectJob(res.json));
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
135
197
|
registerWriteTool(
|
|
136
198
|
"circleci_pipeline_trigger",
|
|
137
199
|
{
|
|
@@ -2,11 +2,13 @@ import { z } from "zod";
|
|
|
2
2
|
import { cliArg, gcloudEnv } from "../../../shared/cli-json-kit.ts";
|
|
3
3
|
import type { ConsentServer } from "../../../shared/consent-kit.ts";
|
|
4
4
|
import { createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
5
|
+
import { isKubectlNotFound, projectDeployment } from "../../../shared/k8s-deployment-view.ts";
|
|
5
6
|
import {
|
|
6
7
|
createRegisterSimpleTool,
|
|
7
8
|
createZodToolRegistrar,
|
|
8
9
|
mcpJsonResult as jsonResult,
|
|
9
10
|
} from "../../../shared/mcp-tool-kit.ts";
|
|
11
|
+
import { withPrivateKubeconfig } from "../../../shared/private-kubeconfig.ts";
|
|
10
12
|
import { runCliJsonThrowing, runCliOkThrowing } from "../../../shared/run-cli-json.ts";
|
|
11
13
|
|
|
12
14
|
async function gcloudJson(args: string[]): Promise<unknown> {
|
|
@@ -14,11 +16,47 @@ async function gcloudJson(args: string[]): Promise<unknown> {
|
|
|
14
16
|
return (await runCliJsonThrowing(cmd, gcloudEnv())) ?? {};
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
type GkeTarget = {
|
|
20
|
+
readonly projectId: string;
|
|
21
|
+
readonly location: string;
|
|
22
|
+
readonly cluster: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run `body` with credentials for one GKE cluster in a PRIVATE kubeconfig: a fresh empty file,
|
|
27
|
+
* which `get-credentials` fills (it writes `$HOME/.kube/config` only when `KUBECONFIG` is unset,
|
|
28
|
+
* so the owner's file is never touched). `--location` is gcloud's own preferred flag and, unlike
|
|
29
|
+
* `--zone`, also names a regional cluster.
|
|
30
|
+
* https://docs.cloud.google.com/sdk/gcloud/reference/container/clusters/get-credentials
|
|
31
|
+
*/
|
|
32
|
+
function withGkeCredentials<T>(
|
|
33
|
+
t: GkeTarget,
|
|
34
|
+
body: (env: Record<string, string | undefined>) => Promise<T>,
|
|
35
|
+
): Promise<T> {
|
|
36
|
+
return withPrivateKubeconfig({}, async (kube) => {
|
|
37
|
+
const env = { ...gcloudEnv(), ...kube };
|
|
38
|
+
await runCliOkThrowing(
|
|
39
|
+
[
|
|
40
|
+
"gcloud",
|
|
41
|
+
"container",
|
|
42
|
+
"clusters",
|
|
43
|
+
"get-credentials",
|
|
44
|
+
t.cluster,
|
|
45
|
+
`--project=${t.projectId}`,
|
|
46
|
+
`--location=${t.location}`,
|
|
47
|
+
],
|
|
48
|
+
env,
|
|
49
|
+
);
|
|
50
|
+
return body(env);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
17
54
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
18
55
|
export const GCP_TOOL_NAMES = [
|
|
19
56
|
"gcp_cloud_run_service_list",
|
|
20
57
|
"gcp_cloud_run_deploy",
|
|
21
58
|
"gcp_gke_workload_restart",
|
|
59
|
+
"gcp_gke_workload_get",
|
|
22
60
|
] as const;
|
|
23
61
|
|
|
24
62
|
export function registerGcpTools(
|
|
@@ -92,7 +130,7 @@ export function registerGcpTools(
|
|
|
92
130
|
recoverable: true,
|
|
93
131
|
scopeTargetOf: (p) => ({ kind: "project", value: p.projectId }),
|
|
94
132
|
},
|
|
95
|
-
"Restart a GKE deployment rollout via kubectl (
|
|
133
|
+
"Restart a GKE deployment rollout via kubectl, with cluster credentials fetched into a private kubeconfig (the owner's kubeconfig is never read or changed).",
|
|
96
134
|
z.object({
|
|
97
135
|
projectId: cliArg,
|
|
98
136
|
location: cliArg,
|
|
@@ -101,23 +139,43 @@ export function registerGcpTools(
|
|
|
101
139
|
deployment: cliArg,
|
|
102
140
|
}),
|
|
103
141
|
async (p) => {
|
|
104
|
-
await
|
|
105
|
-
|
|
106
|
-
"
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
"get-credentials",
|
|
110
|
-
p.cluster,
|
|
111
|
-
`--project=${p.projectId}`,
|
|
112
|
-
`--zone=${p.location}`,
|
|
113
|
-
],
|
|
114
|
-
gcloudEnv(),
|
|
115
|
-
);
|
|
116
|
-
await runCliOkThrowing(
|
|
117
|
-
["kubectl", "rollout", "restart", "deployment", p.deployment, "-n", p.namespace],
|
|
118
|
-
gcloudEnv(),
|
|
142
|
+
await withGkeCredentials(p, (env) =>
|
|
143
|
+
runCliOkThrowing(
|
|
144
|
+
["kubectl", "rollout", "restart", "deployment", p.deployment, "-n", p.namespace],
|
|
145
|
+
env,
|
|
146
|
+
),
|
|
119
147
|
);
|
|
120
148
|
return jsonResult({ ok: true });
|
|
121
149
|
},
|
|
122
150
|
);
|
|
151
|
+
|
|
152
|
+
reg(
|
|
153
|
+
"gcp_gke_workload_get",
|
|
154
|
+
"Get one GKE deployment's state: {name, namespace, replicas, readyReplicas, generation, observedGeneration, restartedAt, images}. The before-state of gcp_gke_workload_restart; it takes the same arguments.",
|
|
155
|
+
z.object({
|
|
156
|
+
projectId: cliArg,
|
|
157
|
+
location: cliArg,
|
|
158
|
+
cluster: cliArg,
|
|
159
|
+
namespace: cliArg,
|
|
160
|
+
deployment: cliArg,
|
|
161
|
+
}),
|
|
162
|
+
async (p) => {
|
|
163
|
+
// NotFound is read from the kubectl step ONLY: a get-credentials failure ("cluster not found")
|
|
164
|
+
// is a different problem and must reach the caller as itself.
|
|
165
|
+
const data = await withGkeCredentials(p, async (env) => {
|
|
166
|
+
try {
|
|
167
|
+
return await runCliJsonThrowing(
|
|
168
|
+
["kubectl", "get", "deployment", p.deployment, "-n", p.namespace, "-o", "json"],
|
|
169
|
+
env,
|
|
170
|
+
);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (e instanceof Error && isKubectlNotFound(e.message)) {
|
|
173
|
+
throw new Error("deployment not found", { cause: e });
|
|
174
|
+
}
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
return jsonResult(projectDeployment(data));
|
|
179
|
+
},
|
|
180
|
+
);
|
|
123
181
|
}
|