@intentius/chant-k8s-client 0.31.0 → 0.32.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.
@@ -12,7 +12,8 @@
12
12
 
13
13
  import { describe, test, expect } from "vitest";
14
14
  import { createK8sClient } from "./client";
15
- import { K8sApiError, K8sTransportError, ExecCredentialNotAllowedError, KubeConfigError, UnknownResourceError } from "./errors";
15
+ import { K8sApiError, K8sTransportError, ExecCredentialNotAllowedError, FieldManagerError, KubeConfigError, UnknownResourceError } from "./errors";
16
+ import { FieldManagerConflictError } from "./conflict";
16
17
  import { apiResourceList, fakeKubeconfig, fakeRequestLayer, statusBody } from "./testing";
17
18
  import type { RecordedRequest } from "./testing";
18
19
 
@@ -358,6 +359,110 @@ describe("list", () => {
358
359
  await c.list({ apiVersion: "apps/v1", kind: "Deployment" }, { namespace: "prod" });
359
360
  expect(layer.paths()).toContain("/apis/apps/v1/namespaces/prod/deployments");
360
361
  });
362
+
363
+ test("a label selector is sent to the server, not filtered afterwards (chant #1075)", async () => {
364
+ const layer = cluster({ "/apis/apps/v1/namespaces/prod/deployments": { items: [deployment("a")] } });
365
+ const c = await client(layer);
366
+ await c.list(
367
+ { apiVersion: "apps/v1", kind: "Deployment" },
368
+ { namespace: "prod", labelSelector: "app.kubernetes.io/managed-by=chant" },
369
+ );
370
+ const listed = layer.requests.find((r) => r.path === "/apis/apps/v1/namespaces/prod/deployments")!;
371
+ expect(listed.query.labelSelector).toBe("app.kubernetes.io/managed-by=chant");
372
+ });
373
+ });
374
+
375
+ describe("readLog (chant #1079)", () => {
376
+ test("GETs the pod's /log subresource and returns the raw text, not JSON", async () => {
377
+ const layer = fakeRequestLayer((req) => {
378
+ if (req.path in ROOT_DISCOVERY) return { body: ROOT_DISCOVERY[req.path] };
379
+ if (req.path === "/api/v1/namespaces/prod/pods/web/log") {
380
+ return { body: "line one\nline two\n" };
381
+ }
382
+ return { status: 404, body: statusBody(404, "NotFound", "no") };
383
+ });
384
+ const c = await client(layer as unknown as ReturnType<typeof cluster>);
385
+ const text = await c.readLog({ apiVersion: "v1", kind: "Pod", name: "web", namespace: "prod" });
386
+ expect(text).toBe("line one\nline two\n");
387
+ });
388
+
389
+ test("container, tailLines, sinceSeconds, previous and timestamps are query parameters", async () => {
390
+ const layer = fakeRequestLayer((req) => {
391
+ if (req.path in ROOT_DISCOVERY) return { body: ROOT_DISCOVERY[req.path] };
392
+ if (req.path === "/api/v1/namespaces/prod/pods/web/log") return { body: "" };
393
+ return { status: 404, body: statusBody(404, "NotFound", "no") };
394
+ });
395
+ const c = await client(layer as unknown as ReturnType<typeof cluster>);
396
+ await c.readLog(
397
+ { apiVersion: "v1", kind: "Pod", name: "web", namespace: "prod" },
398
+ { container: "app", tailLines: 50, sinceSeconds: 3600, previous: true, timestamps: true },
399
+ );
400
+ const req = layer.requests.find((r) => r.path === "/api/v1/namespaces/prod/pods/web/log")!;
401
+ expect(req.query).toEqual({
402
+ container: "app",
403
+ tailLines: "50",
404
+ sinceSeconds: "3600",
405
+ previous: "true",
406
+ timestamps: "true",
407
+ });
408
+ });
409
+
410
+ test("a failure is a typed K8sApiError, not a swallowed empty string", async () => {
411
+ const layer = fakeRequestLayer((req) => {
412
+ if (req.path in ROOT_DISCOVERY) return { body: ROOT_DISCOVERY[req.path] };
413
+ return { status: 404, body: statusBody(404, "NotFound", 'pods "web" not found') };
414
+ });
415
+ const c = await client(layer as unknown as ReturnType<typeof cluster>);
416
+ const err = (await c
417
+ .readLog({ apiVersion: "v1", kind: "Pod", name: "web", namespace: "prod" })
418
+ .catch((e: unknown) => e)) as K8sApiError;
419
+ expect(err).toBeInstanceOf(K8sApiError);
420
+ expect(err.notFound).toBe(true);
421
+ });
422
+
423
+ test("an unresolvable kind throws UnknownResourceError before any log request", async () => {
424
+ const layer = fakeRequestLayer((req) => {
425
+ if (req.path in ROOT_DISCOVERY) return { body: ROOT_DISCOVERY[req.path] };
426
+ return { status: 404, body: statusBody(404, "NotFound", "no") };
427
+ });
428
+ const c = await client(layer as unknown as ReturnType<typeof cluster>);
429
+ await expect(
430
+ c.readLog({ apiVersion: "v1", kind: "Widget", name: "web", namespace: "prod" }),
431
+ ).rejects.toThrow(UnknownResourceError);
432
+ expect(layer.requests.some((r) => r.path.endsWith("/log"))).toBe(false);
433
+ });
434
+ });
435
+
436
+ describe("delete (chant #1075 — the prune path)", () => {
437
+ test("DELETEs the addressed object", async () => {
438
+ const layer = cluster({}, (req) => (req.method === "DELETE" ? { body: statusBody(200, "", "ok") } : undefined));
439
+ const c = await client(layer);
440
+ await c.delete({ apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" });
441
+ const deleted = layer.requests.find((r) => r.method === "DELETE")!;
442
+ expect(deleted.path).toBe("/apis/apps/v1/namespaces/prod/deployments/web");
443
+ expect(deleted.query.propagationPolicy).toBeUndefined();
444
+ });
445
+
446
+ test("a propagation policy and a dry run are query parameters", async () => {
447
+ const layer = cluster({}, (req) => (req.method === "DELETE" ? { body: {} } : undefined));
448
+ const c = await client(layer);
449
+ await c.delete(
450
+ { apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" },
451
+ { propagationPolicy: "Foreground", dryRun: true },
452
+ );
453
+ const deleted = layer.requests.find((r) => r.method === "DELETE")!;
454
+ expect(deleted.query).toMatchObject({ propagationPolicy: "Foreground", dryRun: "All" });
455
+ });
456
+
457
+ test("a 404 surfaces as a typed notFound rather than being swallowed", async () => {
458
+ const layer = cluster();
459
+ const c = await client(layer);
460
+ const err = (await c
461
+ .delete({ apiVersion: "apps/v1", kind: "Deployment", name: "gone", namespace: "prod" })
462
+ .catch((e: unknown) => e)) as K8sApiError;
463
+ expect(err).toBeInstanceOf(K8sApiError);
464
+ expect(err.notFound).toBe(true);
465
+ });
361
466
  });
362
467
 
363
468
  describe("apply", () => {
@@ -397,6 +502,37 @@ describe("apply", () => {
397
502
  expect(err.apiMessage).toContain("conflict with");
398
503
  });
399
504
 
505
+ test("that 409 is presented, naming the owner and the contested paths (chant #1075)", async () => {
506
+ const conflict = {
507
+ ...statusBody(409, "Conflict", 'Apply failed with 1 conflict: conflict with "helm" using apps/v1'),
508
+ details: {
509
+ causes: [
510
+ { type: "FieldManagerConflict", message: 'conflict with "helm" using apps/v1', field: ".spec.replicas" },
511
+ ],
512
+ },
513
+ };
514
+ const layer = cluster({}, (req) => (req.method === "PATCH" ? { status: 409, body: conflict } : undefined));
515
+ const c = await client(layer);
516
+ const err = (await c
517
+ .apply(deployment("web") as never, { fieldManager: "chant:web" })
518
+ .catch((e: unknown) => e)) as FieldManagerConflictError;
519
+
520
+ expect(err).toBeInstanceOf(FieldManagerConflictError);
521
+ expect(err.byManager).toEqual({ helm: [".spec.replicas"] });
522
+ expect(err.fieldManager).toBe("chant:web");
523
+ expect(err.message).toContain("apps/v1 Deployment prod/web");
524
+ expect(err.message).toContain("chant does not force this for you");
525
+ });
526
+
527
+ test("a field manager the API server would reject is refused before any request", async () => {
528
+ const layer = cluster();
529
+ const c = await client(layer);
530
+ await expect(c.apply(deployment("web") as never, { fieldManager: "chant web" })).rejects.toThrow(
531
+ FieldManagerError,
532
+ );
533
+ expect(layer.requests).toHaveLength(0);
534
+ });
535
+
400
536
  test("an object missing apiVersion/kind/name is refused before any request", async () => {
401
537
  const layer = cluster();
402
538
  const c = await client(layer);
package/src/client.ts CHANGED
@@ -44,6 +44,8 @@ import {
44
44
  UnknownResourceError,
45
45
  } from "./errors";
46
46
  import { assertExecCredentialAllowed, credentialPathOf, DEFAULT_EXEC_ALLOWLIST } from "./credentials";
47
+ import { asFieldManagerConflict } from "./conflict";
48
+ import { assertValidFieldManager, CHANT_FIELD_MANAGER } from "./field-manager";
47
49
  import { DEFAULT_CONCURRENCY, mapConcurrent } from "./concurrency";
48
50
  import type {
49
51
  ApiResourceInfo,
@@ -120,12 +122,17 @@ export interface ReadOptions {
120
122
 
121
123
  /** Options for {@link K8sClient.apply}. */
122
124
  export interface ApplyOptions {
123
- /** Field manager recorded on the objects this apply owns. Default `chant`. */
125
+ /**
126
+ * Field manager recorded on the fields this apply owns. Defaults to the bare
127
+ * `chant`; the k8s lexicon passes the stack-qualified `chant:<stack>` derived
128
+ * by {@link import("./field-manager").fieldManagerFor} (chant #1075).
129
+ */
124
130
  fieldManager?: string;
125
131
  /**
126
132
  * Take ownership of fields another manager owns instead of failing with a
127
- * 409. Default false — chant #1075 is where the conflict surface proper
128
- * lives; here a conflict simply arrives as a typed {@link K8sApiError}.
133
+ * {@link import("./conflict").FieldManagerConflictError}. **Default false,
134
+ * and nothing in chant sets it for you** transferring ownership of a live
135
+ * field is a decision, not a retry (chant #1075).
129
136
  */
130
137
  force?: boolean;
131
138
  /** Server-side dry run — validates and returns the result, persists nothing. */
@@ -133,6 +140,39 @@ export interface ApplyOptions {
133
140
  signal?: AbortSignal;
134
141
  }
135
142
 
143
+ /** Options for {@link K8sClient.delete}. */
144
+ export interface DeleteOptions {
145
+ /** `Foreground`, `Background` or `Orphan`. Omitted leaves the server's default. */
146
+ propagationPolicy?: "Foreground" | "Background" | "Orphan";
147
+ /** Server-side dry run — validates, deletes nothing. */
148
+ dryRun?: boolean;
149
+ signal?: AbortSignal;
150
+ }
151
+
152
+ /** Options for {@link K8sClient.list}. */
153
+ export interface ListOptions {
154
+ /** Restrict to one namespace. Omitted lists across all of them. */
155
+ namespace?: string;
156
+ /** A label selector, e.g. `app.kubernetes.io/managed-by=chant`. */
157
+ labelSelector?: string;
158
+ signal?: AbortSignal;
159
+ }
160
+
161
+ /** Options for {@link K8sClient.readLog} (chant #1079). */
162
+ export interface ReadLogOptions {
163
+ /** Container name. Required by the API server when a Pod has more than one. */
164
+ container?: string;
165
+ /** Read the previous (crashed/restarted) container instance's log. */
166
+ previous?: boolean;
167
+ /** Only the last N lines. */
168
+ tailLines?: number;
169
+ /** Only entries from the last N seconds. */
170
+ sinceSeconds?: number;
171
+ /** Prefix each line with its RFC3339 timestamp. */
172
+ timestamps?: boolean;
173
+ signal?: AbortSignal;
174
+ }
175
+
136
176
  /** The client surface the k8s lexicon consumes. */
137
177
  export interface K8sClient {
138
178
  /** Where this client is pointed and what authorized it. */
@@ -149,10 +189,22 @@ export interface K8sClient {
149
189
  read(ref: ObjectRef, options?: ReadOptions): Promise<K8sObject>;
150
190
  /** GET one object, returning undefined instead of throwing on a 404. */
151
191
  readIfPresent(ref: ObjectRef, options?: ReadOptions): Promise<K8sObject | undefined>;
152
- /** LIST a kind, optionally namespaced. Follows `continue` tokens. */
153
- list(selector: ResourceSelector, options?: { namespace?: string; signal?: AbortSignal }): Promise<K8sObject[]>;
192
+ /** LIST a kind, optionally namespaced and label-filtered. Follows `continue` tokens. */
193
+ list(selector: ResourceSelector, options?: ListOptions): Promise<K8sObject[]>;
194
+ /**
195
+ * GET a Pod's `/log` subresource — plain text, not JSON, which is why this
196
+ * is its own method rather than a `read` variant. A snapshot only: the
197
+ * server's log endpoint supports `follow` as a chunked stream, but this
198
+ * client's transport seam (`ResponseContextLike.body.text()`) reads a
199
+ * response to completion rather than exposing it as a stream, so `--follow`
200
+ * is out of reach without widening that seam — chant #1079 leaves it there
201
+ * deliberately rather than half-implementing it.
202
+ */
203
+ readLog(ref: ObjectRef, options?: ReadLogOptions): Promise<string>;
154
204
  /** Server-side apply one object. Creates it when absent. */
155
205
  apply(object: K8sObject, options?: ApplyOptions): Promise<K8sObject>;
206
+ /** DELETE one object. Throws {@link K8sApiError} with `notFound` when absent. */
207
+ delete(ref: ObjectRef, options?: DeleteOptions): Promise<void>;
156
208
  /** Run `fn` over `items` with this client's concurrency ceiling. */
157
209
  concurrently<T, R>(items: readonly T[], fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
158
210
  /** The API resource lists discovery has been asked for so far, for tests and diagnostics. */
@@ -475,14 +527,14 @@ export async function createK8sClient(options: K8sClientOptions = {}): Promise<K
475
527
  }
476
528
  }
477
529
 
478
- async function list(
479
- selector: ResourceSelector,
480
- opts: { namespace?: string; signal?: AbortSignal } = {},
481
- ): Promise<K8sObject[]> {
530
+ async function list(selector: ResourceSelector, opts: ListOptions = {}): Promise<K8sObject[]> {
482
531
  const info = await resolveOrThrow(selector, opts.signal);
483
532
  const items: K8sObject[] = [];
484
533
  let cont: string | undefined;
485
534
  do {
535
+ const query: Record<string, string> = {};
536
+ if (cont) query.continue = cont;
537
+ if (opts.labelSelector) query.labelSelector = opts.labelSelector;
486
538
  const page = await sendJson<{ items?: K8sObject[]; metadata?: { continue?: string } }>(
487
539
  // Omitting the namespace segment lists across all namespaces, which is
488
540
  // what `kubectl get <kind> -A` does and what the import path wants.
@@ -492,7 +544,7 @@ export async function createK8sClient(options: K8sClientOptions = {}): Promise<K
492
544
  "GET",
493
545
  {
494
546
  signal: opts.signal,
495
- query: cont ? { continue: cont } : undefined,
547
+ query: Object.keys(query).length > 0 ? query : undefined,
496
548
  target: `list ${selectorText(selector)}`,
497
549
  },
498
550
  );
@@ -514,20 +566,64 @@ export async function createK8sClient(options: K8sClientOptions = {}): Promise<K
514
566
  if (!name) {
515
567
  throw new KubeConfigError(`cannot apply a ${apiVersion} ${kind} without metadata.name`);
516
568
  }
569
+ const fieldManager = opts.fieldManager ?? CHANT_FIELD_MANAGER;
570
+ // Checked before the request so an unusable identity is reported against
571
+ // the config that produced it, not as a 400 from the cluster.
572
+ assertValidFieldManager(fieldManager);
517
573
  const info = await resolveOrThrow({ apiVersion, kind }, opts.signal);
518
574
  const query: Record<string, string> = {
519
- fieldManager: opts.fieldManager ?? "chant",
575
+ fieldManager,
520
576
  force: String(opts.force ?? false),
521
577
  };
522
578
  if (opts.dryRun) query.dryRun = "All";
523
- return sendJson<K8sObject>(objectPath(info, name, object.metadata?.namespace), "PATCH", {
524
- // Server-side apply. JSON is valid YAML, so the JSON body is accepted
525
- // under the apply-patch content type without a YAML round trip.
526
- contentType: "application/apply-patch+yaml",
527
- body: JSON.stringify(object),
528
- query,
579
+ try {
580
+ return await sendJson<K8sObject>(objectPath(info, name, object.metadata?.namespace), "PATCH", {
581
+ // Server-side apply. JSON is valid YAML, so the JSON body is accepted
582
+ // under the apply-patch content type without a YAML round trip.
583
+ contentType: "application/apply-patch+yaml",
584
+ body: JSON.stringify(object),
585
+ query,
586
+ signal: opts.signal,
587
+ target: refText({ apiVersion, kind, name, namespace: object.metadata?.namespace }),
588
+ });
589
+ } catch (err) {
590
+ // A 409 here is the one Kubernetes failure that already carries a
591
+ // machine-readable account of itself; chant #1075 presents it rather
592
+ // than letting a one-line 409 stand in for it.
593
+ throw asFieldManagerConflict(err, fieldManager);
594
+ }
595
+ }
596
+
597
+ async function readLog(ref: ObjectRef, opts: ReadLogOptions = {}): Promise<string> {
598
+ const info = await resolveOrThrow({ apiVersion: ref.apiVersion, kind: ref.kind }, opts.signal);
599
+ const query: Record<string, string> = {};
600
+ if (opts.container) query.container = opts.container;
601
+ if (opts.previous) query.previous = "true";
602
+ if (opts.tailLines !== undefined) query.tailLines = String(opts.tailLines);
603
+ if (opts.sinceSeconds !== undefined) query.sinceSeconds = String(opts.sinceSeconds);
604
+ if (opts.timestamps) query.timestamps = "true";
605
+
606
+ const target = `${refText(ref)} logs`;
607
+ const { status, body } = await send(`${objectPath(info, ref.name, ref.namespace)}/log`, "GET", {
608
+ query: Object.keys(query).length > 0 ? query : undefined,
609
+ signal: opts.signal,
610
+ target,
611
+ });
612
+ if (status < 200 || status > 299) {
613
+ throw K8sApiError.fromResponse(status, body, target);
614
+ }
615
+ return body;
616
+ }
617
+
618
+ async function remove(ref: ObjectRef, opts: DeleteOptions = {}): Promise<void> {
619
+ const info = await resolveOrThrow({ apiVersion: ref.apiVersion, kind: ref.kind }, opts.signal);
620
+ const query: Record<string, string> = {};
621
+ if (opts.propagationPolicy) query.propagationPolicy = opts.propagationPolicy;
622
+ if (opts.dryRun) query.dryRun = "All";
623
+ await sendJson<unknown>(objectPath(info, ref.name, ref.namespace), "DELETE", {
529
624
  signal: opts.signal,
530
- target: refText({ apiVersion, kind, name, namespace: object.metadata?.namespace }),
625
+ query: Object.keys(query).length > 0 ? query : undefined,
626
+ target: refText(ref),
531
627
  });
532
628
  }
533
629
 
@@ -538,7 +634,9 @@ export async function createK8sClient(options: K8sClientOptions = {}): Promise<K
538
634
  read,
539
635
  readIfPresent,
540
636
  list,
637
+ readLog,
541
638
  apply,
639
+ delete: remove,
542
640
  concurrently: (items, fn) => mapConcurrent(items, fn, concurrency),
543
641
  discoveryCacheKeys: () => [...discoveryCache.keys()].sort(),
544
642
  };
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The conflict surface (chant #1075).
3
+ *
4
+ * The `Status` bodies below are shaped like real API-server output for a
5
+ * refused server-side apply: `details.causes` on a current server, and the
6
+ * prose-only form some aggregated/older servers send instead.
7
+ */
8
+
9
+ import { describe, test, expect } from "vitest";
10
+ import {
11
+ FieldManagerConflictError,
12
+ asFieldManagerConflict,
13
+ parseConflictMessage,
14
+ parseFieldConflicts,
15
+ renderConflictReport,
16
+ } from "./conflict";
17
+ import { K8sApiError, type K8sStatus } from "./errors";
18
+
19
+ const CAUSES_STATUS: K8sStatus = {
20
+ kind: "Status",
21
+ apiVersion: "v1",
22
+ status: "Failure",
23
+ reason: "Conflict",
24
+ code: 409,
25
+ message:
26
+ 'Apply failed with 2 conflicts: conflicts with "kubectl-client-side-apply" using apps/v1:\n' +
27
+ "- .spec.replicas\n" +
28
+ '- .spec.template.spec.containers[name="web"].image',
29
+ details: {
30
+ causes: [
31
+ {
32
+ type: "FieldManagerConflict",
33
+ message: 'conflict with "kubectl-client-side-apply" using apps/v1',
34
+ field: ".spec.replicas",
35
+ },
36
+ {
37
+ type: "FieldManagerConflict",
38
+ message: 'conflict with "kubectl-client-side-apply" using apps/v1',
39
+ field: '.spec.template.spec.containers[name="web"].image',
40
+ },
41
+ ],
42
+ },
43
+ };
44
+
45
+ function apiError(status: K8sStatus, target = "apps/v1 Deployment prod/web"): K8sApiError {
46
+ return new K8sApiError(409, status.reason, status.message ?? "", target, status);
47
+ }
48
+
49
+ describe("parsing the conflict out of the Status", () => {
50
+ test("details.causes is the machine-readable form and is used when present", () => {
51
+ expect(parseFieldConflicts(CAUSES_STATUS)).toEqual([
52
+ { manager: "kubectl-client-side-apply", field: ".spec.replicas", apiVersion: "apps/v1" },
53
+ {
54
+ manager: "kubectl-client-side-apply",
55
+ field: '.spec.template.spec.containers[name="web"].image',
56
+ apiVersion: "apps/v1",
57
+ },
58
+ ]);
59
+ });
60
+
61
+ test("two managers in one refusal are kept apart", () => {
62
+ const conflicts = parseFieldConflicts({
63
+ reason: "Conflict",
64
+ details: {
65
+ causes: [
66
+ { type: "FieldManagerConflict", message: 'conflict with "helm"', field: ".spec.replicas" },
67
+ { type: "FieldManagerConflict", message: 'conflict with "argo"', field: ".metadata.labels.env" },
68
+ ],
69
+ },
70
+ });
71
+ expect(conflicts.map((c) => c.manager)).toEqual(["helm", "argo"]);
72
+ });
73
+
74
+ test("a cause of some other type is not read as a field conflict", () => {
75
+ expect(
76
+ parseFieldConflicts({
77
+ reason: "Conflict",
78
+ details: { causes: [{ type: "FieldValueInvalid", message: "bad", field: ".spec.replicas" }] },
79
+ }),
80
+ ).toEqual([]);
81
+ });
82
+
83
+ test("the prose form is parsed when the server sent no causes", () => {
84
+ expect(
85
+ parseConflictMessage(
86
+ 'Apply failed with 2 conflicts: conflicts with "kubectl" using apps/v1:\n' +
87
+ "- .spec.replicas\n" +
88
+ "- .spec.paused",
89
+ ),
90
+ ).toEqual([
91
+ { manager: "kubectl", field: ".spec.replicas", apiVersion: "apps/v1" },
92
+ { manager: "kubectl", field: ".spec.paused", apiVersion: "apps/v1" },
93
+ ]);
94
+ });
95
+
96
+ test("the prose form with several managers attributes each block to its own", () => {
97
+ const conflicts = parseConflictMessage(
98
+ 'Apply failed with 2 conflicts: conflicts with "helm":\n' +
99
+ "- .spec.replicas\n" +
100
+ 'conflicts with "argocd":\n' +
101
+ "- .metadata.labels.env",
102
+ );
103
+ expect(conflicts).toEqual([
104
+ { manager: "helm", field: ".spec.replicas" },
105
+ { manager: "argocd", field: ".metadata.labels.env" },
106
+ ]);
107
+ });
108
+
109
+ test("a single inline conflict is read out of the header line", () => {
110
+ expect(parseConflictMessage('Apply failed with 1 conflict: conflict with "kubectl": .spec.replicas')).toEqual([
111
+ { manager: "kubectl", field: ".spec.replicas" },
112
+ ]);
113
+ });
114
+
115
+ test("a message that names nothing parseable yields nothing rather than a guess", () => {
116
+ expect(parseConflictMessage("the object has been modified")).toEqual([]);
117
+ expect(parseFieldConflicts(undefined, "")).toEqual([]);
118
+ });
119
+ });
120
+
121
+ describe("FieldManagerConflictError", () => {
122
+ const error = asFieldManagerConflict(apiError(CAUSES_STATUS), "chant:web") as FieldManagerConflictError;
123
+
124
+ test("it is still a K8sApiError, so nothing that caught 409s stops working", () => {
125
+ expect(error).toBeInstanceOf(FieldManagerConflictError);
126
+ expect(error).toBeInstanceOf(K8sApiError);
127
+ expect(error.statusCode).toBe(409);
128
+ expect(error.conflict).toBe(true);
129
+ expect(error.name).toBe("FieldManagerConflictError");
130
+ });
131
+
132
+ test("it names the competing manager and the contested paths", () => {
133
+ expect(error.managers).toEqual(["kubectl-client-side-apply"]);
134
+ expect(error.fields).toEqual([
135
+ ".spec.replicas",
136
+ '.spec.template.spec.containers[name="web"].image',
137
+ ]);
138
+ expect(error.byManager).toEqual({
139
+ "kubectl-client-side-apply": [
140
+ ".spec.replicas",
141
+ '.spec.template.spec.containers[name="web"].image',
142
+ ],
143
+ });
144
+ });
145
+
146
+ test("it records which manager chant applied as", () => {
147
+ expect(error.fieldManager).toBe("chant:web");
148
+ });
149
+
150
+ test("the message names the object, the owner, every field, and the way out", () => {
151
+ const text = error.message;
152
+ expect(text).toContain("apps/v1 Deployment prod/web");
153
+ expect(text).toContain('"kubectl-client-side-apply" owns:');
154
+ expect(text).toContain(".spec.replicas");
155
+ expect(text).toContain('.spec.template.spec.containers[name="web"].image');
156
+ expect(text).toContain('chant applied as field manager "chant:web"');
157
+ expect(text).toContain("chant does not force this for you");
158
+ expect(text).toContain("force-conflicts");
159
+ });
160
+
161
+ test("nothing in the rendering recommends forcing — both ways out are stated", () => {
162
+ expect(error.message).toContain("remove the contested fields from your chant source");
163
+ expect(error.message).toContain("deliberately");
164
+ });
165
+
166
+ test("a 409 with no parseable causes still says what happened and quotes the server", () => {
167
+ const bare = asFieldManagerConflict(
168
+ apiError({ reason: "Conflict", code: 409, message: "the object has been modified" }),
169
+ "chant",
170
+ ) as FieldManagerConflictError;
171
+ expect(bare.conflicts).toEqual([]);
172
+ expect(bare.message).toContain("the object has been modified");
173
+ expect(bare.message).toContain('field manager "chant"');
174
+ });
175
+
176
+ test("singular and plural both read correctly", () => {
177
+ const one = renderConflictReport({
178
+ conflicts: [{ manager: "helm", field: ".spec.replicas" }],
179
+ fieldManager: "chant",
180
+ target: "apps/v1 Deployment prod/web",
181
+ });
182
+ expect(one).toContain("1 field is owned by another field manager");
183
+ const two = renderConflictReport({
184
+ conflicts: [
185
+ { manager: "helm", field: ".spec.replicas" },
186
+ { manager: "helm", field: ".spec.paused" },
187
+ ],
188
+ fieldManager: "chant",
189
+ });
190
+ expect(two).toContain("2 fields are owned by another field manager");
191
+ });
192
+ });
193
+
194
+ describe("asFieldManagerConflict is a narrowing, not a catch-all", () => {
195
+ test("a non-409 API error passes through untouched", () => {
196
+ const notFound = new K8sApiError(404, "NotFound", "not found", "apps/v1 Deployment prod/web");
197
+ expect(asFieldManagerConflict(notFound, "chant")).toBe(notFound);
198
+ });
199
+
200
+ test("something that is not an API error at all passes through untouched", () => {
201
+ const boom = new Error("socket hang up");
202
+ expect(asFieldManagerConflict(boom, "chant")).toBe(boom);
203
+ });
204
+
205
+ test("an already-presented conflict is not re-wrapped", () => {
206
+ const once = asFieldManagerConflict(apiError(CAUSES_STATUS), "chant:web");
207
+ expect(asFieldManagerConflict(once, "chant:web")).toBe(once);
208
+ });
209
+ });