@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.
@@ -190,7 +190,8 @@ export interface DavClientLike {
190
190
  calendar: DAVCalendar;
191
191
  timeRange?: { start: string; end: string };
192
192
  expand?: boolean;
193
- }): Promise<{ url: string; data?: unknown }[]>;
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
- const res = await fetch(url, {
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 (uses current cluster credentials).",
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 runCliOkThrowing(
105
- [
106
- "gcloud",
107
- "container",
108
- "clusters",
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
  }