Repository: cdk8s-team/cdk8s-plus
Pull request #1122 (2022-09-11 06:27:52)

feat(cronjob): introduce `CronJob` construct (#1052)
# Backport

This will backport the following commits from `k8s-24/main` to `k8s-22/main`:
 - [feat(cronjob): introduce `CronJob` construct (#1052)](https://github.com/cdk8s-team/cdk8s-plus/pull/1052)

<!--- Backport version: 8.9.3 -->

### Questions ?
Please refer to the [Backport tool documentation](https://github.com/sqren/backport)


Patch:
diff --git a/src/cron-job.ts b/src/cron-job.ts
new file mode 100644
index 00000000..ef0012ad
--- /dev/null
+++ b/src/cron-job.ts
@@ -0,0 +1,199 @@
+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 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 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.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,
+    };
+  }
+
+  /**
+   * 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..2b8609f8
--- /dev/null
+++ b/test/__snapshots__/cron-job.test.ts.snap
@@ -0,0 +1,133 @@
+// 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,
+    },
+  },
+]
+`;
+
+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..4ea39612
--- /dev/null
+++ b/test/cron-job.test.ts
@@ -0,0 +1,91 @@
+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.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,
+    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.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