Repository: cdk8s-team/cdk8s-plus
Pull request #1052 (2022-08-17 16:28:14)

feat(cronjob): introduce `CronJob` construct
This PR adds the Kubernetes [CronJob Resource L2](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/cron-job-v1/) to cdk8s-plus. This is part of the effort to make cdk8s+ GA. 

Having this L2 abstraction would enable the customer to provision Kubernetes CronJob Resource using cdk8s+ and that would generate/add it to the Kubernetes manifest.

Resolves https://github.com/cdk8s-team/cdk8s-plus/issues/376
Resolves https://github.com/cdk8s-team/cdk8s-plus/issues/813
Resolves https://github.com/cdk8s-team/cdk8s-plus/issues/814

## Next Steps

Since timezone is behind a feature gate and works with K8s-24, we would need to fix the backport PR.

## DCO

Signed-off-by: Vinayak Kukreja <vinakuk@amazon.com>

iliapolo: @vinayak-kukreja Let me know when you ready for another review, just re-request the review on the top right. 

Patch:
diff --git a/src/cron-job.ts b/src/cron-job.ts
new file mode 100644
index 00000000..ae69650b
--- /dev/null
+++ b/src/cron-job.ts
@@ -0,0 +1,215 @@
+import { ApiObject, Duration, Lazy, Cron } from 'cdk8s';
+import { Construct } from 'constructs';
+import * as k8s from './imports/k8s';
+import { JobProps } from './job';
+import { RestartPolicy } from './pod';
+import * as workload from './workload';
+
+/**
+ * Concurrency policy for CronJobs.
+ */
+export enum ConcurrencyPolicy {
+  /**
+   * This policy allows to run job concurrently.
+   */
+  ALLOW = 'Allow',
+  /**
+   * This policy does not allow to run job concurrently. It does not let a new job to be scheduled if the previous one is not finished yet.
+   */
+  FORBID = 'Forbid',
+  /**
+   * This policy replaces the currently running job if a new job is being scheduled.
+   */
+  REPLACE = 'Replace',
+}
+
+/**
+ * Properties for `CronJob`.
+ */
+export interface CronJobProps extends JobProps {
+  /**
+   * Specifies the time in which the job would run again. This is defined as a cron expression in the CronJob resource.
+   */
+  readonly schedule: Cron;
+
+  /**
+   * Specifies the timezone for the job. This helps aligining the schedule to follow the specified timezone.
+   *
+   * @see {@link https://en.wikipedia.org/wiki/List_of_tz_database_time_zones} for list of valid timezone values.
+   *
+   * @default - Timezone of kube-controller-manager process.
+   */
+  readonly timeZone?: string;
+
+  /**
+   * Specifies the concurrency policy for the job.
+   *
+   * @default ConcurrencyPolicy.Forbid
+   */
+  readonly concurrencyPolicy?: ConcurrencyPolicy;
+
+  /**
+   * Kubernetes attempts to start cron jobs at its schedule time, but this is not guaranteed. This deadline specifies
+   * how much time can pass after a schedule point, for which kubernetes can still start the job.
+   * For example, if this is set to 100 seconds, kubernetes is allowed to start the job at a maximum 100 seconds after
+   * the scheduled time.
+   *
+   * Note that the Kubernetes CronJobController checks for things every 10 seconds, for this reason, a deadline below 10
+   * seconds is not allowed, as it may cause your job to never be scheduled.
+   *
+   * In addition, kubernetes will stop scheduling jobs if more than 100 schedules were missed (for any reason).
+   * This property also controls what time interval should kubernetes consider when counting for missed schedules.
+   *
+   * For example, suppose a CronJob is set to schedule a new Job every one minute beginning at 08:30:00,
+   * and its `startingDeadline` field is not set. If the CronJob controller happens to be down from 08:29:00 to 10:21:00,
+   * the job will not start as the number of missed jobs which missed their schedule is greater than 100.
+   * However, if `startingDeadline` is set to 200 seconds, kubernetes will only count 3 missed schedules, and thus
+   * start a new execution at 10:22:00.
+   *
+   * @default Duration.seconds(10)
+   */
+  readonly startingDeadline?: Duration;
+
+  /**
+   * Specifies if the cron job should be suspended. Only applies to future executions, current ones are remained untouched.
+   *
+   * @default false
+   */
+  readonly suspend?: boolean;
+
+  /**
+   * Specifies the number of successful jobs history retained. This would retain the Job and the associated Pod resource and can be useful for debugging.
+   *
+   * @default 3
+   */
+  readonly successfulJobsRetained?: number;
+
+  /**
+   * Specifies the number of failed jobs history retained. This would retain the Job and the associated Pod resource and can be useful for debugging.
+   *
+   * @default 1
+   */
+  readonly failedJobsRetained?: number;
+}
+
+/**
+ * A CronJob is responsible for creating a Job and scheduling it based on provided cron schedule. This helps running Jobs in a recurring manner.
+ */
+export class CronJob extends workload.Workload {
+  /**
+   * The schedule this cron job is scheduled to run in.
+   */
+  public readonly schedule: Cron;
+
+  /**
+   * The timezone which this cron job would follow to schedule jobs.
+   */
+  public readonly timeZone?: string;
+
+  /**
+   * The policy used by this cron job to determine the concurrency mode in which to schedule jobs.
+   */
+  public readonly concurrencyPolicy: string;
+
+  /**
+   * The time by which the running cron job needs to schedule the next job execution. The job is considered as failed if it misses this deadline.
+   */
+  public readonly startingDeadline: Duration;
+
+  /**
+   * Whether or not the cron job is currently suspended or not.
+   */
+  public readonly suspend: boolean;
+
+  /**
+   * The number of successful jobs retained by this cron job.
+   */
+  public readonly successfulJobsRetained: number;
+
+  /**
+   * The number of failed jobs retained by this cron job.
+   */
+  public readonly failedJobsRetained: number;
+
+  /**
+   * The properties of the recurring `Job` that this cronjob will schedule.
+   */
+  private readonly jobProperties: JobProps;
+
+  /**
+   * @see base.Resource.apiObject
+   */
+  protected readonly apiObject: ApiObject;
+
+  /**
+   * Represents the resource type.
+   */
+  public readonly resourceType = 'cronjob';
+
+  constructor(scope: Construct, id: string, props: CronJobProps) {
+    super(scope, id, {
+      restartPolicy: RestartPolicy.NEVER,
+      select: false,
+      ...props,
+    });
+
+    this.apiObject = new k8s.KubeCronJob(this, 'Resource', {
+      metadata: props.metadata,
+      spec: Lazy.any({ produce: () => this._toKube() }),
+    });
+
+    if (props.startingDeadline != undefined && props.startingDeadline.toSeconds() < 10) {
+      throw new Error(`The 'startingDeadline' property cannot be less than 10 seconds since the Kubernetes CronJobController checks things every 10 seconds and hence the CronJob may not be scheduled. The value passed is: ${props.startingDeadline}`);
+    }
+
+    if (props.ttlAfterFinished != undefined && (props.successfulJobsRetained != undefined || props.failedJobsRetained != undefined)) {
+      throw new Error('The \'ttlAfterFinished\' property cannot be set if \'successfulJobsRetained\' property or \'failedJobsRetained\' property is set. This would cause the retention of jobs to not work properly since it would delete the job based on its value.');
+    }
+
+    this.schedule = props.schedule;
+    this.timeZone = props.timeZone;
+    this.concurrencyPolicy = props.concurrencyPolicy ?? ConcurrencyPolicy.FORBID;
+    this.startingDeadline = props.startingDeadline ?? Duration.seconds(10);
+    this.suspend = props.suspend ?? false;
+    this.successfulJobsRetained = props.successfulJobsRetained ?? 3;
+    this.failedJobsRetained = props.failedJobsRetained ?? 1;
+    this.jobProperties = {
+      activeDeadline: props.activeDeadline,
+      backoffLimit: props.backoffLimit,
+      ttlAfterFinished: props.ttlAfterFinished,
+    };
+  }
+
+  /**
+   * @internal
+   */
+  public _toKube(): k8s.CronJobSpec {
+    return {
+      concurrencyPolicy: this.concurrencyPolicy,
+      failedJobsHistoryLimit: this.failedJobsRetained,
+      jobTemplate: {
+        spec: this._toJobSpec(),
+      },
+      schedule: this.schedule.expressionString,
+      startingDeadlineSeconds: this.startingDeadline.toSeconds(),
+      successfulJobsHistoryLimit: this.successfulJobsRetained,
+      suspend: this.suspend,
+      timeZone: this.timeZone,
+    };
+  }
+
+  /**
+   * Returns the job spec.
+   */
+  private _toJobSpec(): k8s.JobSpec {
+    return {
+      template: {
+        metadata: this.podMetadata.toJson(),
+        spec: this._toPodSpec(),
+      },
+      activeDeadlineSeconds: this.jobProperties.activeDeadline?.toSeconds(),
+      backoffLimit: this.jobProperties.backoffLimit,
+      ttlSecondsAfterFinished: this.jobProperties.ttlAfterFinished?.toSeconds(),
+    };
+  }
+}
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index e4f6cbdd..0b6b036b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,7 @@
 export * from './base';
 export * from './config-map';
 export * from './container';
+export * from './cron-job';
 export * from './deployment';
 export * from './job';
 export * from './pod';


Test patch:
diff --git a/test/__snapshots__/cron-job.test.ts.snap b/test/__snapshots__/cron-job.test.ts.snap
new file mode 100644
index 00000000..86df5b8e
--- /dev/null
+++ b/test/__snapshots__/cron-job.test.ts.snap
@@ -0,0 +1,134 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`custom configuration 1`] = `
+Array [
+  Object {
+    "apiVersion": "batch/v1",
+    "kind": "CronJob",
+    "metadata": Object {
+      "name": "test-cronjob-c812e113",
+    },
+    "spec": Object {
+      "concurrencyPolicy": "Allow",
+      "failedJobsHistoryLimit": 3,
+      "jobTemplate": Object {
+        "spec": Object {
+          "activeDeadlineSeconds": 60,
+          "backoffLimit": 4,
+          "template": Object {
+            "metadata": Object {
+              "labels": Object {
+                "cdk8s.io/metadata.addr": "test-CronJob-c80e4ce0",
+              },
+            },
+            "spec": Object {
+              "automountServiceAccountToken": true,
+              "containers": Array [
+                Object {
+                  "image": "image",
+                  "imagePullPolicy": "Always",
+                  "name": "main",
+                  "resources": Object {
+                    "limits": Object {
+                      "cpu": "1500m",
+                      "memory": "2048Mi",
+                    },
+                    "requests": Object {
+                      "cpu": "1000m",
+                      "memory": "512Mi",
+                    },
+                  },
+                  "securityContext": Object {
+                    "privileged": false,
+                    "readOnlyRootFilesystem": false,
+                    "runAsGroup": 26000,
+                    "runAsNonRoot": false,
+                    "runAsUser": 25000,
+                  },
+                },
+              ],
+              "dnsPolicy": "ClusterFirst",
+              "restartPolicy": "Never",
+              "securityContext": Object {
+                "fsGroupChangePolicy": "Always",
+                "runAsNonRoot": false,
+              },
+              "setHostnameAsFQDN": false,
+            },
+          },
+        },
+      },
+      "schedule": "5 * * * *",
+      "startingDeadlineSeconds": 60,
+      "successfulJobsHistoryLimit": 3,
+      "suspend": false,
+      "timeZone": "America/Los_Angeles",
+    },
+  },
+]
+`;
+
+exports[`default configuration 1`] = `
+Array [
+  Object {
+    "apiVersion": "batch/v1",
+    "kind": "CronJob",
+    "metadata": Object {
+      "name": "test-cronjob-c812e113",
+    },
+    "spec": Object {
+      "concurrencyPolicy": "Forbid",
+      "failedJobsHistoryLimit": 1,
+      "jobTemplate": Object {
+        "spec": Object {
+          "template": Object {
+            "metadata": Object {
+              "labels": Object {
+                "cdk8s.io/metadata.addr": "test-CronJob-c80e4ce0",
+              },
+            },
+            "spec": Object {
+              "automountServiceAccountToken": true,
+              "containers": Array [
+                Object {
+                  "image": "image",
+                  "imagePullPolicy": "Always",
+                  "name": "main",
+                  "resources": Object {
+                    "limits": Object {
+                      "cpu": "1500m",
+                      "memory": "2048Mi",
+                    },
+                    "requests": Object {
+                      "cpu": "1000m",
+                      "memory": "512Mi",
+                    },
+                  },
+                  "securityContext": Object {
+                    "privileged": false,
+                    "readOnlyRootFilesystem": false,
+                    "runAsGroup": 26000,
+                    "runAsNonRoot": false,
+                    "runAsUser": 25000,
+                  },
+                },
+              ],
+              "dnsPolicy": "ClusterFirst",
+              "restartPolicy": "Never",
+              "securityContext": Object {
+                "fsGroupChangePolicy": "Always",
+                "runAsNonRoot": false,
+              },
+              "setHostnameAsFQDN": false,
+            },
+          },
+        },
+      },
+      "schedule": "* * * * *",
+      "startingDeadlineSeconds": 10,
+      "successfulJobsHistoryLimit": 3,
+      "suspend": false,
+    },
+  },
+]
+`;
diff --git a/test/cron-job.test.ts b/test/cron-job.test.ts
new file mode 100644
index 00000000..f798fafd
--- /dev/null
+++ b/test/cron-job.test.ts
@@ -0,0 +1,94 @@
+import { Testing, ApiObject, Duration, CronOptions, Cron } from 'cdk8s';
+import { Node } from 'constructs';
+import * as kplus from '../src';
+
+test('defaultChild', () => {
+
+  const chart = Testing.chart();
+
+  const cronOptions: CronOptions = {
+    minute: '*',
+    hour: '*',
+    day: '*',
+    month: '*',
+    weekDay: '*',
+  };
+
+  const schedule = new Cron(cronOptions);
+
+  const defaultChild = Node.of(new kplus.CronJob(chart, 'CronJob', {
+    schedule: schedule,
+  })).defaultChild as ApiObject;
+
+  expect(defaultChild.kind).toEqual('CronJob');
+});
+
+test('default configuration', () => {
+
+  const chart = Testing.chart();
+
+  const schedule = Cron.everyMinute();
+
+  new kplus.CronJob(chart, 'CronJob', {
+    schedule: schedule,
+    containers: [{ image: 'image' }],
+  });
+
+  // assert the k8s spec has it.
+  const manifest = Testing.synth(chart);
+  expect(manifest).toMatchSnapshot();
+  const spec = manifest[0].spec;
+
+  expect(spec.schedule).toEqual('* * * * *');
+  expect(spec.concurrencyPolicy).toEqual('Forbid');
+  expect(spec.timeZone).toEqual(undefined);
+  expect(spec.startingDeadlineSeconds).toEqual(10);
+  expect(spec.suspend).toEqual(false);
+  expect(spec.successfulJobsHistoryLimit).toEqual(3);
+  expect(spec.failedJobsHistoryLimit).toEqual(1);
+});
+
+test('custom configuration', () => {
+
+  const chart = Testing.chart();
+
+  const cronOptions: CronOptions = {
+    minute: '5',
+    hour: '*',
+    day: '*',
+    month: '*',
+    weekDay: '*',
+  };
+
+  const schedule = Cron.schedule(cronOptions);
+
+  new kplus.CronJob(chart, 'CronJob', {
+    activeDeadline: Duration.seconds(60),
+    backoffLimit: 4,
+    schedule: schedule,
+    timeZone: 'America/Los_Angeles',
+    concurrencyPolicy: kplus.ConcurrencyPolicy.ALLOW,
+    startingDeadline: Duration.seconds(60),
+    suspend: false,
+    successfulJobsRetained: 3,
+    failedJobsRetained: 3,
+    containers: [{ image: 'image' }],
+  });
+
+  // assert the k8s spec has it.
+  const manifest = Testing.synth(chart);
+  expect(manifest).toMatchSnapshot();
+  const spec = manifest[0].spec;
+
+  expect(spec.schedule).toEqual('5 * * * *');
+  expect(spec.concurrencyPolicy).toEqual('Allow');
+  expect(spec.timeZone).toEqual('America/Los_Angeles');
+  expect(spec.startingDeadlineSeconds).toEqual(60);
+  expect(spec.suspend).toEqual(false);
+  expect(spec.successfulJobsHistoryLimit).toEqual(3);
+  expect(spec.failedJobsHistoryLimit).toEqual(3);
+
+  expect(spec.jobTemplate.spec.activeDeadlineSeconds).toEqual(60);
+  expect(spec.jobTemplate.spec.backoffLimit).toEqual(4);
+  expect(spec.jobTemplate.spec.template.spec.containers[0].image).toEqual('image');
+});
\ No newline at end of file