@bluedynamics/cdk8s-plone 0.1.40 → 0.1.42

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.
Files changed (35) hide show
  1. package/.jsii +294 -28
  2. package/API.md +225 -10
  3. package/CLAUDE.md +1 -1
  4. package/README.md +4 -4
  5. package/docs/superpowers/plans/2026-06-12-configurable-service-spec.md +608 -0
  6. package/docs/superpowers/specs/2026-06-12-configurable-service-spec-design.md +192 -0
  7. package/documentation/sources/explanation/architecture.md +7 -6
  8. package/documentation/sources/explanation/features.md +9 -7
  9. package/documentation/sources/how-to/{deploy-classic-ui.md → deploy-blicca.md} +43 -39
  10. package/documentation/sources/how-to/deploy-production-volto.md +1 -1
  11. package/documentation/sources/how-to/index.md +1 -1
  12. package/documentation/sources/reference/api/index.md +1 -1
  13. package/documentation/sources/reference/configuration-options.md +55 -11
  14. package/documentation/sources/tutorials/01-quick-start.md +1 -1
  15. package/examples/{classic-ui → blicca}/README.md +24 -25
  16. package/examples/{classic-ui → blicca}/__snapshots__/main.test.ts.snap +3 -3
  17. package/examples/{classic-ui → blicca}/config/varnish.tpl.vcl +1 -1
  18. package/examples/{classic-ui → blicca}/main.test.ts +3 -3
  19. package/examples/{classic-ui → blicca}/main.ts +6 -6
  20. package/examples/{classic-ui → blicca}/package.json +2 -2
  21. package/lib/httpcache.js +1 -1
  22. package/lib/index.d.ts +1 -0
  23. package/lib/index.js +1 -1
  24. package/lib/plone.d.ts +23 -5
  25. package/lib/plone.js +22 -8
  26. package/lib/service.d.ts +81 -0
  27. package/lib/service.js +24 -6
  28. package/lib/vinylcache.js +1 -1
  29. package/package.json +1 -1
  30. /package/examples/{classic-ui → blicca}/.env.example +0 -0
  31. /package/examples/{classic-ui → blicca}/cdk8s.yaml +0 -0
  32. /package/examples/{classic-ui → blicca}/ingress.ts +0 -0
  33. /package/examples/{classic-ui → blicca}/jest.config.js +0 -0
  34. /package/examples/{classic-ui → blicca}/postgres.bitnami.ts +0 -0
  35. /package/examples/{classic-ui → blicca}/postgres.cloudnativepg.ts +0 -0
@@ -0,0 +1,608 @@
1
+ # Configurable Service Spec Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make the generated Kubernetes `Service` spec configurable — curated typed fields (incl. `trafficDistribution`) plus a generic `overrides` escape hatch — plumbed through a single grouped `service` option on backend/frontend.
6
+
7
+ **Architecture:** Add a `PloneServiceSpec` interface in `src/service.ts` with curated optional fields and an `overrides?: k8s.ServiceSpec` escape hatch. `PloneService` merges construct-managed base (`ports`/`selector`) < curated fields < `overrides` (highest precedence). A single `service?: PloneServiceSpec` prop on `PloneBaseOptions` (shared by backend & frontend) carries the config; the legacy `serviceAnnotations` is deprecated and merged for backward compatibility.
8
+
9
+ **Tech Stack:** TypeScript, cdk8s (`k8s.KubeService` raw API), Jest snapshot tests, Projen build, JSII (multi-language publish).
10
+
11
+ **Reference spec:** `docs/superpowers/specs/2026-06-12-configurable-service-spec-design.md`
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ - `src/service.ts` — new `PloneServiceSpec` interface; `PloneServiceOptions` gains `spec?`; merge logic in `PloneService` constructor.
18
+ - `src/plone.ts` — import `PloneServiceSpec`; add `service?` to `PloneBaseOptions`; deprecate `serviceAnnotations`; pass `spec` in backend & frontend `PloneService` calls.
19
+ - `test/service.test.ts` (+ `test/__snapshots__/service.test.ts.snap`) — new test cases.
20
+ - `documentation/sources/reference/configuration-options.md` — document `PloneServiceSpec`.
21
+ - `API.md` — regenerated via `npx projen docgen` (auto-generated, no manual edit).
22
+
23
+ ---
24
+
25
+ ## Task 1: Add `PloneServiceSpec` type and merge logic in `PloneService`
26
+
27
+ **Files:**
28
+ - Modify: `src/service.ts:5-37` (interface area), `src/service.ts:58-82` (constructor)
29
+ - Test: `test/service.test.ts`
30
+
31
+ - [ ] **Step 1: Write failing tests for the new behavior**
32
+
33
+ Replace the contents of `test/service.test.ts` with:
34
+
35
+ ```typescript
36
+ import { Chart, Testing } from 'cdk8s';
37
+ import { PloneService } from '../src/service';
38
+
39
+ function synthService(props: any) {
40
+ const app = Testing.app();
41
+ const chart = new Chart(app, 'plone');
42
+ new PloneService(chart, 'test', props);
43
+ return Testing.synth(chart)[0];
44
+ }
45
+
46
+ test('defaults', () => {
47
+ const app = Testing.app();
48
+ const chart = new Chart(app, 'plone');
49
+ new PloneService(chart, 'test', { targetPort: 8080, selectorLabel: { app: 'plone' } });
50
+ expect(Testing.synth(chart)).toMatchSnapshot();
51
+ });
52
+
53
+ test('curated spec fields are applied', () => {
54
+ const manifest = synthService({
55
+ targetPort: 8080,
56
+ selectorLabel: { app: 'plone' },
57
+ spec: {
58
+ type: 'LoadBalancer',
59
+ trafficDistribution: 'PreferClose',
60
+ sessionAffinity: 'ClientIP',
61
+ externalTrafficPolicy: 'Local',
62
+ internalTrafficPolicy: 'Local',
63
+ publishNotReadyAddresses: true,
64
+ loadBalancerClass: 'service.k8s.aws/nlb',
65
+ loadBalancerSourceRanges: ['10.0.0.0/8'],
66
+ },
67
+ });
68
+ expect(manifest.spec.type).toBe('LoadBalancer');
69
+ expect(manifest.spec.trafficDistribution).toBe('PreferClose');
70
+ expect(manifest.spec.sessionAffinity).toBe('ClientIP');
71
+ expect(manifest.spec.externalTrafficPolicy).toBe('Local');
72
+ expect(manifest.spec.internalTrafficPolicy).toBe('Local');
73
+ expect(manifest.spec.publishNotReadyAddresses).toBe(true);
74
+ expect(manifest.spec.loadBalancerClass).toBe('service.k8s.aws/nlb');
75
+ expect(manifest.spec.loadBalancerSourceRanges).toEqual(['10.0.0.0/8']);
76
+ // construct-managed base still present
77
+ expect(manifest.spec.ports[0].port).toBe(8080);
78
+ expect(manifest.spec.selector).toEqual({ app: 'plone' });
79
+ });
80
+
81
+ test('overrides escape hatch sets arbitrary spec fields', () => {
82
+ const manifest = synthService({
83
+ targetPort: 8080,
84
+ selectorLabel: { app: 'plone' },
85
+ spec: { overrides: { ipFamilyPolicy: 'PreferDualStack' } },
86
+ });
87
+ expect(manifest.spec.ipFamilyPolicy).toBe('PreferDualStack');
88
+ });
89
+
90
+ test('overrides take precedence over curated fields', () => {
91
+ const manifest = synthService({
92
+ targetPort: 8080,
93
+ selectorLabel: { app: 'plone' },
94
+ spec: { type: 'ClusterIP', overrides: { type: 'NodePort' } },
95
+ });
96
+ expect(manifest.spec.type).toBe('NodePort');
97
+ });
98
+
99
+ test('spec.annotations and spec.labels reach metadata', () => {
100
+ const manifest = synthService({
101
+ targetPort: 8080,
102
+ selectorLabel: { app: 'plone' },
103
+ spec: {
104
+ annotations: { 'external-dns.alpha.kubernetes.io/hostname': 'plone.example.com' },
105
+ labels: { tier: 'web' },
106
+ },
107
+ });
108
+ expect(manifest.metadata.annotations['external-dns.alpha.kubernetes.io/hostname']).toBe('plone.example.com');
109
+ expect(manifest.metadata.labels.tier).toBe('web');
110
+ // construct-managed labels still win/present
111
+ expect(manifest.metadata.labels['app.kubernetes.io/part-of']).toBe('plone');
112
+ });
113
+
114
+ test('legacy options.annotations still merge with spec.annotations', () => {
115
+ const manifest = synthService({
116
+ targetPort: 8080,
117
+ selectorLabel: { app: 'plone' },
118
+ annotations: { a: '1' },
119
+ spec: { annotations: { b: '2' } },
120
+ });
121
+ expect(manifest.metadata.annotations).toEqual({ a: '1', b: '2' });
122
+ });
123
+ ```
124
+
125
+ - [ ] **Step 2: Run tests to verify they fail**
126
+
127
+ Run: `npx jest test/service.test.ts`
128
+ Expected: FAIL — new tests fail because `spec` is ignored (e.g. `manifest.spec.type` is `undefined`).
129
+
130
+ - [ ] **Step 3: Add `PloneServiceSpec` interface and `spec?` field in `src/service.ts`**
131
+
132
+ Insert the new interface immediately before `export interface PloneServiceOptions {` (before `src/service.ts:5`):
133
+
134
+ ```typescript
135
+ /**
136
+ * Configuration for the generated Kubernetes Service spec.
137
+ *
138
+ * Curated fields cover the common cases; use `overrides` as an escape hatch
139
+ * for any other `ServiceSpec` field. `overrides` has the highest precedence and
140
+ * can override every field, including the construct-managed `ports`/`selector`
141
+ * (at your own risk).
142
+ */
143
+ export interface PloneServiceSpec {
144
+ /**
145
+ * Service type, e.g. ClusterIP | NodePort | LoadBalancer | ExternalName.
146
+ * @default - ClusterIP (Kubernetes default)
147
+ */
148
+ readonly type?: string;
149
+
150
+ /**
151
+ * Traffic distribution preference, e.g. 'PreferClose' for topology-aware routing.
152
+ * @default - none
153
+ */
154
+ readonly trafficDistribution?: string;
155
+
156
+ /**
157
+ * Session affinity, 'ClientIP' | 'None'.
158
+ * @default - None (Kubernetes default)
159
+ */
160
+ readonly sessionAffinity?: string;
161
+
162
+ /**
163
+ * External traffic policy, 'Cluster' | 'Local'.
164
+ * @default - Cluster (Kubernetes default)
165
+ */
166
+ readonly externalTrafficPolicy?: string;
167
+
168
+ /**
169
+ * Internal traffic policy, 'Cluster' | 'Local'.
170
+ * @default - Cluster (Kubernetes default)
171
+ */
172
+ readonly internalTrafficPolicy?: string;
173
+
174
+ /**
175
+ * Publish not-ready addresses (e.g. for headless services with StatefulSets).
176
+ * @default - false
177
+ */
178
+ readonly publishNotReadyAddresses?: boolean;
179
+
180
+ /**
181
+ * Load balancer implementation class.
182
+ * @default - none
183
+ */
184
+ readonly loadBalancerClass?: string;
185
+
186
+ /**
187
+ * Source IP ranges allowed to access a LoadBalancer service.
188
+ * @default - none
189
+ */
190
+ readonly loadBalancerSourceRanges?: string[];
191
+
192
+ /**
193
+ * Annotations to add to the Service metadata.
194
+ * @default - none
195
+ */
196
+ readonly annotations?: { [name: string]: string };
197
+
198
+ /**
199
+ * Extra labels to add to the Service metadata.
200
+ * @default - none
201
+ */
202
+ readonly labels?: { [name: string]: string };
203
+
204
+ /**
205
+ * Raw ServiceSpec overrides. Highest precedence — merged on top of all curated
206
+ * fields and the construct-managed base. Use for any field not covered above.
207
+ * @default - none
208
+ */
209
+ readonly overrides?: k8s.ServiceSpec;
210
+ }
211
+ ```
212
+
213
+ Then add a `spec?` field inside `PloneServiceOptions`, immediately after the existing `annotations` field (after `src/service.ts:36`, before the closing `}` at line 37):
214
+
215
+ ```typescript
216
+ /**
217
+ * Service spec configuration (type, trafficDistribution, sessionAffinity,
218
+ * raw overrides, ...).
219
+ * @default - construct-managed defaults only
220
+ */
221
+ readonly spec?: PloneServiceSpec;
222
+ ```
223
+
224
+ - [ ] **Step 4: Implement the merge in the `PloneService` constructor**
225
+
226
+ Replace the constructor body (`src/service.ts:58-82`, from `const targetPort` through `this.labels = service_labels;`) with:
227
+
228
+ ```typescript
229
+ const targetPort = k8s.IntOrString.fromNumber(options.targetPort);
230
+ const selectorLabel = options.selectorLabel;
231
+ const userSpec = options.spec ?? {};
232
+
233
+ const service_labels = {
234
+ ...userSpec.labels ?? {},
235
+ ...options.labels ?? {},
236
+ 'app.kubernetes.io/part-of': 'plone',
237
+ 'app.kubernetes.io/managed-by': 'cdk8s-plone',
238
+ };
239
+
240
+ const mergedAnnotations = {
241
+ ...options.annotations ?? {},
242
+ ...userSpec.annotations ?? {},
243
+ };
244
+ const annotations = Object.keys(mergedAnnotations).length > 0 ? mergedAnnotations : undefined;
245
+
246
+ const spec: k8s.ServiceSpec = {
247
+ ports: [{ port: options.targetPort, targetPort: targetPort, name: options.portName ?? 'http' }],
248
+ selector: selectorLabel,
249
+ type: userSpec.type,
250
+ trafficDistribution: userSpec.trafficDistribution,
251
+ sessionAffinity: userSpec.sessionAffinity,
252
+ externalTrafficPolicy: userSpec.externalTrafficPolicy,
253
+ internalTrafficPolicy: userSpec.internalTrafficPolicy,
254
+ publishNotReadyAddresses: userSpec.publishNotReadyAddresses,
255
+ loadBalancerClass: userSpec.loadBalancerClass,
256
+ loadBalancerSourceRanges: userSpec.loadBalancerSourceRanges,
257
+ // overrides win over everything, including ports/selector
258
+ ...userSpec.overrides ?? {},
259
+ };
260
+
261
+ const serviceOpts: k8s.KubeServiceProps = {
262
+ metadata: {
263
+ labels: service_labels,
264
+ annotations: annotations,
265
+ },
266
+ spec: spec,
267
+ };
268
+ const service = new k8s.KubeService(this, 'service', serviceOpts);
269
+ this.name = service.name;
270
+ this.labels = service_labels;
271
+ ```
272
+
273
+ > Note: curated fields left `undefined` are dropped by cdk8s during synth, so the `defaults` snapshot stays unchanged.
274
+
275
+ - [ ] **Step 5: Run tests to verify they pass**
276
+
277
+ Run: `npx jest test/service.test.ts`
278
+ Expected: PASS for all new tests. The `defaults` snapshot should still match (no change). If it reports an obsolete/changed snapshot for `defaults`, confirm the diff is empty; do not `-u` yet.
279
+
280
+ - [ ] **Step 6: Lint**
281
+
282
+ Run: `npx projen eslint`
283
+ Expected: no errors in `src/service.ts`.
284
+
285
+ - [ ] **Step 7: Commit**
286
+
287
+ ```bash
288
+ git add src/service.ts test/service.test.ts
289
+ git commit -m "feat(service): add configurable PloneServiceSpec with curated fields and overrides
290
+
291
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
292
+
293
+ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
294
+ ```
295
+
296
+ ---
297
+
298
+ ## Task 2: Plumb `service` through `PloneBaseOptions` (backend & frontend)
299
+
300
+ **Files:**
301
+ - Modify: `src/plone.ts:8` (import), `src/plone.ts:242-248` (add `service`, deprecate `serviceAnnotations`), `src/plone.ts:468-477` (backend), `src/plone.ts:562-571` (frontend)
302
+ - Test: `test/plone.test.ts` (snapshot regeneration)
303
+
304
+ - [ ] **Step 1: Import `PloneServiceSpec`**
305
+
306
+ In `src/plone.ts:8`, change:
307
+
308
+ ```typescript
309
+ import { PloneService } from './service';
310
+ ```
311
+
312
+ to:
313
+
314
+ ```typescript
315
+ import { PloneService, PloneServiceSpec } from './service';
316
+ ```
317
+
318
+ - [ ] **Step 2: Add `service` prop and deprecate `serviceAnnotations` in `PloneBaseOptions`**
319
+
320
+ In `src/plone.ts`, replace the existing `serviceAnnotations` doc-comment + field (currently `src/plone.ts:242-248`):
321
+
322
+ ```typescript
323
+ /**
324
+ * Annotations to add to the Service metadata.
325
+ * Common for external-dns, load balancers, service mesh, etc.
326
+ * @example { 'external-dns.alpha.kubernetes.io/hostname': 'plone.example.com' }
327
+ * @default - no additional annotations
328
+ */
329
+ readonly serviceAnnotations?: { [name: string]: string };
330
+ ```
331
+
332
+ with:
333
+
334
+ ```typescript
335
+ /**
336
+ * Annotations to add to the Service metadata.
337
+ * Common for external-dns, load balancers, service mesh, etc.
338
+ * @example { 'external-dns.alpha.kubernetes.io/hostname': 'plone.example.com' }
339
+ * @deprecated use `service.annotations` instead
340
+ * @default - no additional annotations
341
+ */
342
+ readonly serviceAnnotations?: { [name: string]: string };
343
+
344
+ /**
345
+ * Service configuration: type, trafficDistribution, sessionAffinity,
346
+ * annotations/labels, and a raw `overrides` escape hatch for any other
347
+ * ServiceSpec field. Applies to this component's Service.
348
+ * @example { type: 'LoadBalancer', trafficDistribution: 'PreferClose' }
349
+ * @default - construct-managed defaults only
350
+ */
351
+ readonly service?: PloneServiceSpec;
352
+ ```
353
+
354
+ - [ ] **Step 3: Pass `spec` in the backend `PloneService` call**
355
+
356
+ In `src/plone.ts:468-477`, replace:
357
+
358
+ ```typescript
359
+ const backendService = new PloneService(backendDeployment, 'service', {
360
+ labels: {
361
+ 'app.kubernetes.io/name': 'plone-backend-service',
362
+ 'app.kubernetes.io/component': 'service',
363
+ },
364
+ targetPort: backendPort,
365
+ selectorLabel: { app: Names.toLabelValue(backendDeployment) },
366
+ portName: 'backend-http',
367
+ annotations: backend.serviceAnnotations,
368
+ });
369
+ ```
370
+
371
+ with:
372
+
373
+ ```typescript
374
+ const backendService = new PloneService(backendDeployment, 'service', {
375
+ labels: {
376
+ 'app.kubernetes.io/name': 'plone-backend-service',
377
+ 'app.kubernetes.io/component': 'service',
378
+ },
379
+ targetPort: backendPort,
380
+ selectorLabel: { app: Names.toLabelValue(backendDeployment) },
381
+ portName: 'backend-http',
382
+ spec: {
383
+ ...backend.service,
384
+ annotations: { ...backend.serviceAnnotations, ...backend.service?.annotations },
385
+ },
386
+ });
387
+ ```
388
+
389
+ - [ ] **Step 4: Pass `spec` in the frontend `PloneService` call**
390
+
391
+ In `src/plone.ts:562-571`, replace:
392
+
393
+ ```typescript
394
+ const frontendService = new PloneService(frontendDeployment, 'service', {
395
+ labels: {
396
+ 'app.kubernetes.io/name': 'plone-frontend-service',
397
+ 'app.kubernetes.io/component': 'service',
398
+ },
399
+ targetPort: frontendPort,
400
+ selectorLabel: { app: Names.toLabelValue(frontendDeployment) },
401
+ portName: 'frontend-http',
402
+ annotations: frontend.serviceAnnotations,
403
+ });
404
+ ```
405
+
406
+ with:
407
+
408
+ ```typescript
409
+ const frontendService = new PloneService(frontendDeployment, 'service', {
410
+ labels: {
411
+ 'app.kubernetes.io/name': 'plone-frontend-service',
412
+ 'app.kubernetes.io/component': 'service',
413
+ },
414
+ targetPort: frontendPort,
415
+ selectorLabel: { app: Names.toLabelValue(frontendDeployment) },
416
+ portName: 'frontend-http',
417
+ spec: {
418
+ ...frontend.service,
419
+ annotations: { ...frontend.serviceAnnotations, ...frontend.service?.annotations },
420
+ },
421
+ });
422
+ ```
423
+
424
+ - [ ] **Step 5: Verify existing snapshots are unchanged (no regression)**
425
+
426
+ Run: `npx jest test/plone.test.ts`
427
+ Expected: PASS with NO snapshot changes — when neither `serviceAnnotations` nor `service` is set, `spec.annotations` resolves to `{}` → dropped to `undefined`, so output is identical.
428
+ If a snapshot diff appears, STOP and inspect: a non-empty diff means a regression, not an expected update.
429
+
430
+ - [ ] **Step 6: Add a Plone-level integration test for `service`**
431
+
432
+ Append to `test/plone.test.ts` (a test that exercises the plumbing end-to-end). First confirm the import style at the top of that file matches (it uses `import { Plone } from '../src/plone';`). Add:
433
+
434
+ ```typescript
435
+ test('backend service config is plumbed through', () => {
436
+ const app = Testing.app();
437
+ const chart = new Chart(app, 'plone');
438
+ new Plone(chart, 'plone', {
439
+ backend: {
440
+ service: {
441
+ type: 'LoadBalancer',
442
+ trafficDistribution: 'PreferClose',
443
+ },
444
+ },
445
+ });
446
+ const manifests = Testing.synth(chart);
447
+ const backendSvc = manifests.find(
448
+ (m: any) => m.kind === 'Service' && m.metadata.labels['app.kubernetes.io/name'] === 'plone-backend-service',
449
+ );
450
+ expect(backendSvc.spec.type).toBe('LoadBalancer');
451
+ expect(backendSvc.spec.trafficDistribution).toBe('PreferClose');
452
+ });
453
+ ```
454
+
455
+ > If `test/plone.test.ts` does not already import `Chart`/`Testing` from `cdk8s`, add `import { Chart, Testing } from 'cdk8s';` at the top (check first — most test files in this repo already do).
456
+
457
+ - [ ] **Step 7: Run the new integration test**
458
+
459
+ Run: `npx jest test/plone.test.ts -t 'backend service config is plumbed through'`
460
+ Expected: PASS.
461
+
462
+ - [ ] **Step 8: Run full test suite + lint**
463
+
464
+ Run: `npx projen test`
465
+ Expected: all suites PASS, no unexpected snapshot changes.
466
+ Run: `npx projen eslint`
467
+ Expected: no errors.
468
+
469
+ - [ ] **Step 9: Commit**
470
+
471
+ ```bash
472
+ git add src/plone.ts test/plone.test.ts test/__snapshots__/
473
+ git commit -m "feat(plone): expose service config on backend/frontend, deprecate serviceAnnotations
474
+
475
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
476
+
477
+ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
478
+ ```
479
+
480
+ ---
481
+
482
+ ## Task 3: Documentation
483
+
484
+ **Files:**
485
+ - Modify: `documentation/sources/reference/configuration-options.md:200-220` (Annotations section → add Service section)
486
+ - Regenerate: `API.md`
487
+
488
+ - [ ] **Step 1: Invoke the doc-style skill**
489
+
490
+ Before editing the handwritten docs under `documentation/sources/`, invoke the `plone-doc-style:author` skill (Diataxis classification, MyST markup, style enforcement). The `configuration-options.md` page is **Reference** quadrant. Do NOT apply the skill to the auto-generated `API.md`.
491
+
492
+ - [ ] **Step 2: Document `PloneServiceSpec` in the Reference page**
493
+
494
+ In `documentation/sources/reference/configuration-options.md`, locate the "Annotations" subsection (around line 200). Mark `serviceAnnotations` as deprecated in its table row and add a new "Service" subsection after the Annotations example block (after line 220). Insert:
495
+
496
+ ```markdown
497
+ #### Service
498
+
499
+ Configure the generated Kubernetes `Service` via the grouped `service` option
500
+ (available on both `backend` and `frontend`). Curated fields cover common cases;
501
+ `overrides` is an escape hatch for any other `ServiceSpec` field and has the
502
+ highest precedence.
503
+
504
+ | Property | Type | Description |
505
+ |----------|------|-------------|
506
+ | `service.type` | `string` | Service type: `ClusterIP` (default), `NodePort`, `LoadBalancer`, `ExternalName` |
507
+ | `service.trafficDistribution` | `string` | Traffic distribution preference, e.g. `PreferClose` |
508
+ | `service.sessionAffinity` | `string` | `ClientIP` or `None` |
509
+ | `service.externalTrafficPolicy` | `string` | `Cluster` or `Local` |
510
+ | `service.internalTrafficPolicy` | `string` | `Cluster` or `Local` |
511
+ | `service.publishNotReadyAddresses` | `boolean` | Publish not-ready addresses |
512
+ | `service.loadBalancerClass` | `string` | Load balancer implementation class |
513
+ | `service.loadBalancerSourceRanges` | `string[]` | Allowed source IP ranges for LoadBalancer |
514
+ | `service.annotations` | `Record<string, string>` | Service metadata annotations (replaces `serviceAnnotations`) |
515
+ | `service.labels` | `Record<string, string>` | Extra Service metadata labels |
516
+ | `service.overrides` | `ServiceSpec` | Raw spec overrides; highest precedence |
517
+
518
+ > `serviceAnnotations` is deprecated — use `service.annotations` instead.
519
+
520
+ **Example:**
521
+ ```typescript
522
+ backend: {
523
+ service: {
524
+ type: 'LoadBalancer',
525
+ trafficDistribution: 'PreferClose',
526
+ loadBalancerSourceRanges: ['10.0.0.0/8'],
527
+ annotations: {
528
+ 'external-dns.alpha.kubernetes.io/hostname': 'backend.example.com',
529
+ },
530
+ overrides: {
531
+ ipFamilyPolicy: 'PreferDualStack',
532
+ },
533
+ },
534
+ }
535
+ ```
536
+ ```
537
+
538
+ - [ ] **Step 3: Update the deprecated `serviceAnnotations` table row**
539
+
540
+ In the same file, change the `serviceAnnotations` row (line ~206) to:
541
+
542
+ ```markdown
543
+ | `serviceAnnotations` | `Record<string, string>` | **Deprecated** — use `service.annotations`. Service annotations (e.g., for external-dns) |
544
+ ```
545
+
546
+ - [ ] **Step 4: Regenerate API docs**
547
+
548
+ Run: `npx projen docgen`
549
+ Expected: `API.md` updated to include `PloneServiceSpec` and the new `service` property.
550
+
551
+ - [ ] **Step 5: Build the docs to verify**
552
+
553
+ Run: `cd documentation && make docs`
554
+ Expected: build succeeds with no errors/warnings for the edited page.
555
+
556
+ - [ ] **Step 6: Commit**
557
+
558
+ ```bash
559
+ git add documentation/sources/reference/configuration-options.md API.md
560
+ git commit -m "docs(service): document PloneServiceSpec configuration
561
+
562
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
563
+
564
+ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
565
+ ```
566
+
567
+ ---
568
+
569
+ ## Task 4: Final verification & build
570
+
571
+ - [ ] **Step 1: Full build**
572
+
573
+ Run: `npx projen build`
574
+ Expected: compile + JSII bindings succeed (confirms `k8s.ServiceSpec` in the public API is JSII-compatible), tests pass, no lint errors.
575
+
576
+ - [ ] **Step 2: Confirm snapshots intentional**
577
+
578
+ Run: `git status` and `git diff --stat`
579
+ Expected: only intended files changed. If `test/__snapshots__/service.test.ts.snap` changed beyond the new test cases, inspect the diff — the `defaults` snapshot must be unchanged.
580
+
581
+ - [ ] **Step 3: Push and open PR**
582
+
583
+ ```bash
584
+ git push -u origin feat/configurable-service-spec
585
+ gh pr create --title "feat(service): configurable Service spec (trafficDistribution + overrides)" --body "$(cat <<'EOF'
586
+ ## Summary
587
+ - Add `PloneServiceSpec` with curated fields (`type`, `trafficDistribution`, `sessionAffinity`, traffic policies, load balancer settings) plus a generic `overrides` escape hatch.
588
+ - Plumb a single grouped `service?` option through `PloneBaseOptions` (backend & frontend).
589
+ - Deprecate `serviceAnnotations` in favor of `service.annotations` (backward compatible, merged).
590
+ - Document in reference docs; regenerate API.md.
591
+
592
+ ## Test plan
593
+ - New unit tests in `test/service.test.ts` (curated fields, overrides precedence, annotations/labels merge, legacy compat).
594
+ - Integration test in `test/plone.test.ts` (end-to-end plumbing).
595
+ - Existing snapshots unchanged when `service` is unset.
596
+
597
+ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
598
+ EOF
599
+ )"
600
+ ```
601
+
602
+ ---
603
+
604
+ ## Self-Review notes
605
+
606
+ - **Spec coverage:** §1 PloneServiceSpec → Task 1 Step 3; §2 PloneServiceOptions.spec → Task 1 Step 3; §3 merge precedence → Task 1 Step 4 + tests; §4 plumbing → Task 2; §5 backward-compat → Task 1 Step 4 (annotation merge) + Task 2 Step 5; §6 tests → Task 1 Step 1, Task 2 Step 6; §7 docs → Task 3.
607
+ - **Type consistency:** `PloneServiceSpec` (with `overrides?: k8s.ServiceSpec`) used identically in `service.ts`, `PloneServiceOptions.spec`, and `PloneBaseOptions.service`. Field names match across interface, merge code, tests, and docs.
608
+ - **No placeholders:** all steps contain concrete code/commands.