Repository: cdk8s-team/cdk8s-plus
Pull request #1223 (2022-10-04 07:42:37)

feat(stateful-set): `service` is now optional
Based on my readings, a stateful set is usually accompanied by a headless service.

> See https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/

This means we can provide a sane default here, and create a service on the user's behalf. The auto created service can then be accessed via the `.service` property, which is now made public.

### Misc

- Added `.port` property on a service to get easy access to the first port of the service, which is also many times the only one.

ryparker: FYI there's now a v25 branch, I'd consider changing the target to v25 and adding the `backport-to-k8s-24/main` label.

Patch:
diff --git a/src/service.ts b/src/service.ts
index 713fa8fb..871ab86b 100644
--- a/src/service.ts
+++ b/src/service.ts
@@ -265,6 +265,13 @@ export class Service extends base.Resource {
     return [...this._ports];
   }
 
+  /**
+   * Return the first port of the service.
+   */
+  public get port(): number {
+    return [...this._ports][0].port;
+  }
+
   /**
    * Configure a port the service will bind to.
    * This method can be called multiple times.
diff --git a/src/stateful-set.ts b/src/stateful-set.ts
index fd423b35..2ffa15d2 100644
--- a/src/stateful-set.ts
+++ b/src/stateful-set.ts
@@ -1,5 +1,6 @@
 import { ApiObject, Lazy, Duration } from 'cdk8s';
 import { Construct } from 'constructs';
+import * as container from './container';
 import * as k8s from './imports/k8s';
 import * as service from './service';
 import * as workload from './workload';
@@ -26,8 +27,10 @@ export enum PodManagementPolicy {
 export interface StatefulSetProps extends workload.WorkloadProps {
   /**
    * Service to associate with the statefulset.
+   *
+   * @default - A new headless service will be created.
    */
-  readonly service: service.Service;
+  readonly service?: service.Service;
 
   /**
     * Number of desired pods.
@@ -118,7 +121,7 @@ export class StatefulSet extends workload.Workload {
 
   public readonly resourceType = 'statefulsets';
 
-  private readonly _service: service.Service;
+  public readonly service: service.Service;
 
   constructor(scope: Construct, id: string, props: StatefulSetProps) {
     super(scope, id, props);
@@ -127,20 +130,49 @@ export class StatefulSet extends workload.Workload {
       metadata: props.metadata,
       spec: Lazy.any({ produce: () => this._toKube() }),
     });
-    this._service = props.service;
+    this.service = props.service ?? this._createHeadlessService();
 
-    this.apiObject.addDependency(this._service);
+    this.apiObject.addDependency(this.service);
 
     this.replicas = props.replicas ?? 1;
     this.strategy = props.strategy ?? StatefulSetUpdateStrategy.rollingUpdate(),
     this.podManagementPolicy = props.podManagementPolicy ?? PodManagementPolicy.ORDERED_READY;
     this.minReady = props.minReady ?? Duration.seconds(0);
 
-    this._service.select(this);
+    this.service.select(this);
 
     if (this.isolate) {
       this.connections.isolate();
     }
+
+  }
+
+  private _createHeadlessService() {
+
+    const myPorts = container.extractContainerPorts(this);
+    const myPortNumbers = myPorts.map(p => p.number);
+    const ports: service.ServicePort[] = myPorts.map(p => ({ port: p.number, targetPort: p.number, protocol: p.protocol }));
+    if (ports.length === 0) {
+      throw new Error(`Unable to create a service for the stateful set ${this.name}: StatefulSet ports cannot be determined.`);
+    }
+
+    // validate the ports are owned by our containers
+    for (const port of ports) {
+      const targetPort = port.targetPort ?? port.port;
+      if (!myPortNumbers.includes(targetPort)) {
+        throw new Error(`Unable to expose stateful set ${this.name} via a service: Port ${targetPort} is not exposed by any container`);
+      }
+    }
+
+    const metadata: any = { namespace: this.metadata.namespace };
+    return new service.Service(this, 'Service', {
+      selector: this,
+      ports,
+      metadata,
+      clusterIP: 'None',
+      type: service.ServiceType.CLUSTER_IP,
+    });
+
   }
 
   /**
@@ -149,7 +181,7 @@ export class StatefulSet extends workload.Workload {
   public _toKube(): k8s.StatefulSetSpec {
 
     return {
-      serviceName: this._service.name,
+      serviceName: this.service.name,
       replicas: this.replicas,
       minReadySeconds: this.minReady.toSeconds(),
       template: {


Test patch:
diff --git a/test/__snapshots__/statefulset.test.ts.snap b/test/__snapshots__/statefulset.test.ts.snap
index bf7c1239..f1a0c780 100644
--- a/test/__snapshots__/statefulset.test.ts.snap
+++ b/test/__snapshots__/statefulset.test.ts.snap
@@ -107,3 +107,106 @@ Array [
   },
 ]
 `;
+
+exports[`StatefulSet gets defaults 1`] = `
+Array [
+  Object {
+    "apiVersion": "v1",
+    "kind": "Service",
+    "metadata": Object {
+      "name": "test-statefulset-service-c8d576f5",
+    },
+    "spec": Object {
+      "clusterIP": "None",
+      "externalIPs": Array [],
+      "ports": Array [
+        Object {
+          "port": 6000,
+          "targetPort": 6000,
+        },
+      ],
+      "selector": Object {
+        "cdk8s.io/metadata.addr": "test-StatefulSet-c809b559",
+      },
+      "type": "ClusterIP",
+    },
+  },
+  Object {
+    "apiVersion": "apps/v1",
+    "kind": "StatefulSet",
+    "metadata": Object {
+      "name": "test-statefulset-c8a6ec86",
+    },
+    "spec": Object {
+      "minReadySeconds": 0,
+      "podManagementPolicy": "OrderedReady",
+      "replicas": 1,
+      "selector": Object {
+        "matchLabels": Object {
+          "cdk8s.io/metadata.addr": "test-StatefulSet-c809b559",
+        },
+      },
+      "serviceName": "test-statefulset-service-c8d576f5",
+      "template": Object {
+        "metadata": Object {
+          "labels": Object {
+            "cdk8s.io/metadata.addr": "test-StatefulSet-c809b559",
+          },
+        },
+        "spec": Object {
+          "automountServiceAccountToken": false,
+          "containers": Array [
+            Object {
+              "image": "image",
+              "imagePullPolicy": "Always",
+              "name": "main",
+              "ports": Array [
+                Object {
+                  "containerPort": 6000,
+                },
+              ],
+              "resources": Object {
+                "limits": Object {
+                  "cpu": "1500m",
+                  "memory": "2048Mi",
+                },
+                "requests": Object {
+                  "cpu": "1000m",
+                  "memory": "512Mi",
+                },
+              },
+              "securityContext": Object {
+                "allowPrivilegeEscalation": false,
+                "privileged": false,
+                "readOnlyRootFilesystem": true,
+                "runAsGroup": 26000,
+                "runAsNonRoot": true,
+                "runAsUser": 25000,
+              },
+              "startupProbe": Object {
+                "failureThreshold": 3,
+                "tcpSocket": Object {
+                  "port": 6000,
+                },
+              },
+            },
+          ],
+          "dnsPolicy": "ClusterFirst",
+          "restartPolicy": "Always",
+          "securityContext": Object {
+            "fsGroupChangePolicy": "Always",
+            "runAsNonRoot": true,
+          },
+          "setHostnameAsFQDN": false,
+        },
+      },
+      "updateStrategy": Object {
+        "rollingUpdate": Object {
+          "partition": 0,
+        },
+        "type": "RollingUpdate",
+      },
+    },
+  },
+]
+`;
diff --git a/test/statefulset.test.ts b/test/statefulset.test.ts
index 96f71d8b..b80d1b60 100644
--- a/test/statefulset.test.ts
+++ b/test/statefulset.test.ts
@@ -88,18 +88,11 @@ test('StatefulSet gets defaults', () => {
 
   const chart = Testing.chart();
 
-  const service = new kplus.Service(chart, 'TestService', { ports: [{ port: 80 }] });
   new kplus.StatefulSet(chart, 'StatefulSet', {
-    containers: [{ image: 'image' }],
-    service,
+    containers: [{ image: 'image', portNumber: 6000 }],
   });
 
-  const resources = Testing.synth(chart);
-  const setSpec: k8s.StatefulSetSpec = resources[1].spec;
-  expect(setSpec.replicas).toEqual(1);
-  expect(setSpec.serviceName).toEqual(resources[0].metadata.name);
-  expect(setSpec.podManagementPolicy).toEqual(kplus.PodManagementPolicy.ORDERED_READY);
-  expect(setSpec.minReadySeconds).toEqual(0);
+  expect(Testing.synth(chart)).toMatchSnapshot();
 });