Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
- Add a "Run once" option to scheduled task scopes: the task runs a single time when the scope is deployed instead of on a recurring schedule, and the deployment waits for it to finish

## [1.15.0] - 2026-08-10
- Fix: **finalize** and **rollback** on blue/green k8s scopes now wait until the load balancer sends all traffic to the surviving deployment before deleting the other one, preventing the 5xx window that happened when it was deleted mid-switch (these actions may take slightly longer as a result)
Expand Down
10 changes: 9 additions & 1 deletion scheduled_task/deployment/build_deployment
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ SECRET_PATH="$OUTPUT_DIR/secret-$SCOPE_ID-$DEPLOYMENT_ID.yaml"
SECRET_FILES_PATH="$OUTPUT_DIR/secret-files-$SCOPE_ID-$DEPLOYMENT_ID.yaml"
CONTEXT_PATH="$OUTPUT_DIR/context-$SCOPE_ID.json"

echo "$CONTEXT" | jq --arg replicas "$REPLICAS" '. + {replicas: $replicas}' > "$CONTEXT_PATH"
# In "run-once" mode the template renders a Job with activeDeadlineSeconds set to
# this value, so the Job self-terminates at the same deadline the deployment wait
# gives up at. Ignored by the CronJob branch.
JOB_WAIT_TIMEOUT="${JOB_WAIT_TIMEOUT:-600}"

echo "$CONTEXT" | jq \
--arg replicas "$REPLICAS" \
--argjson job_wait_timeout "$JOB_WAIT_TIMEOUT" \
'. + {replicas: $replicas, job_wait_timeout: $job_wait_timeout}' > "$CONTEXT_PATH"

echo "Building Template: $DEPLOYMENT_TEMPLATE to $DEPLOYMENT_PATH"

Expand Down
138 changes: 137 additions & 1 deletion scheduled_task/deployment/templates/deployment.yaml.tpl
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
apiVersion: batch/v1
kind: CronJob
kind: {{ if eq .scope.capabilities.cron "run-once" }}Job{{ else }}CronJob{{ end }}
metadata:
name: job-{{ .scope.id }}-{{ .deployment.id }}
namespace: {{ .k8s_namespace }}
Expand Down Expand Up @@ -31,6 +31,141 @@ metadata:
{{- end }}
{{- end }}
spec:
{{- if eq .scope.capabilities.cron "run-once" }}
backoffLimit: {{ .scope.capabilities.retries }}
activeDeadlineSeconds: {{ .job_wait_timeout }}
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
name: d-{{ .scope.id }}-{{ .deployment.id }}
app.kubernetes.io/part-of: {{ .component }}
nullplatform: "true"
account: "{{ .account.slug }}"
account_id: "{{ .account.id }}"
namespace: "{{ .namespace.slug }}"
namespace_id: "{{ .namespace.id }}"
application: "{{ .application.slug }}"
application_id: "{{ .application.id }}"
scope: "{{ .scope.slug }}"
scope_id: "{{ .scope.id }}"
deployment_id: "{{ .deployment.id }}"
{{- $global := index .k8s_modifiers "global" }}
{{- if $global }}
{{- $labels := index $global "labels" }}
{{- if $labels }}
{{ data.ToYAML $labels | indent 8 }}
{{- end }}
{{- end }}
{{- $deployment := index .k8s_modifiers "deployment" }}
{{- if $deployment }}
{{- $labels := index $deployment "labels" }}
{{- if $labels }}
{{ data.ToYAML $labels | indent 8 }}
{{- end }}
{{- end }}
annotations:
nullplatform.logs.cloudwatch: 'true'
nullplatform.logs.cloudwatch.log_group_name: {{ .namespace.slug }}.{{ .application.slug }}
nullplatform.logs.cloudwatch.log_stream_log_retention_days: '7'
nullplatform.logs.cloudwatch.log_stream_name_pattern: >-
type=${type};application={{ .application.id }};scope={{ .scope.id }};deploy={{ .deployment.id }};instance=${instance};container=${container}
nullplatform.logs.cloudwatch.region: us-east-1
{{- $global := index .k8s_modifiers "global" }}
{{- if $global }}
{{- $annotations := index $global "annotations" }}
{{- if $annotations }}
{{ data.ToYAML $annotations | indent 8 }}
{{- end }}
{{- end }}
{{- $deployment := index .k8s_modifiers "deployment" }}
{{- if $deployment }}
{{- $annotations := index $deployment "annotations" }}
{{- if $annotations }}
{{ data.ToYAML $annotations | indent 8 }}
{{- end }}
{{- end }}
spec:
{{- $deployment := index .k8s_modifiers "deployment" }}
{{- if $deployment }}
{{- $tolerations := index $deployment "tolerations" }}
{{- if $tolerations }}
tolerations:
{{ data.ToYAML $tolerations | indent 6 }}
{{- end }}
{{- $nodeSelector := index $deployment "nodeselector" }}
{{- if $nodeSelector }}
nodeSelector:
{{ data.ToYAML $nodeSelector | indent 8 }}
{{- end }}
{{- end }}
{{- if .pull_secrets.ENABLED }}
imagePullSecrets:
{{- range $secret := .pull_secrets.SECRETS }}
- name: {{ $secret }}
{{- end }}
{{- end }}
{{- if .service_account_name }}
serviceAccountName: {{ .service_account_name }}
{{- end }}
containers:
- name: application
envFrom:
- secretRef:
name: s-{{ .scope.id }}-d-{{ .deployment.id }}
{{- if .parameters.results }}
env:
{{- range .parameters.results }}
{{- if and (eq .type "file") (gt (len .values) 0) }}
{{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }}
- name: {{ printf "app-data-%s" $key }}
value: {{ .destination_path | quote }}
{{- end }}
{{- end }}
{{- end }}
image: {{ .asset.url }}
resources:
limits:
cpu: {{ .scope.capabilities.cpu_millicores }}m
memory: {{ .scope.capabilities.ram_memory }}Mi
requests:
cpu: {{ .scope.capabilities.cpu_millicores }}m
memory: {{ .scope.capabilities.ram_memory }}Mi
imagePullPolicy: IfNotPresent
volumeMounts:
{{- if .parameters.results }}
{{- range .parameters.results }}
{{- if and (eq .type "file") }}
{{- if gt (len .values) 0 }}
{{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }}
- name: {{ printf "file-%s" $key }}
mountPath: {{ .destination_path | quote }}
subPath: {{ filepath.Base .destination_path | quote }}
readOnly: true
{{- end }}
{{- end }}
{{- end }}
{{- end }}
volumes:
{{- if .parameters.results }}
{{- range .parameters.results }}
{{- if and (eq .type "file") }}
{{- if gt (len .values) 0 }}
{{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }}
- name: {{ printf "file-%s" $key }}
secret:
secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }}-files
items:
- key: {{ printf "app-file-%s" $key }}
path: {{ filepath.Base .destination_path | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
restartPolicy: OnFailure
securityContext:
runAsUser: 0
{{- else }}
schedule: "{{ .scope.capabilities.cron }}"
concurrencyPolicy: {{ .scope.capabilities.concurrency_policy }}
successfulJobsHistoryLimit: {{ .scope.capabilities.history_limit }}
Expand Down Expand Up @@ -196,3 +331,4 @@ spec:
restartPolicy: OnFailure
securityContext:
runAsUser: 0
{{- end }}
80 changes: 80 additions & 0 deletions scheduled_task/deployment/tests/build_deployment.bats
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,83 @@ JSON
local secret_files_file="$OUTPUT_DIR/secret-files-scope-123-deploy-456.yaml"
[ ! -f "$secret_files_file" ] || [ ! -s "$secret_files_file" ]
}

# =============================================================================
# Run-once mode — cron == "run-once" renders a Job instead of a CronJob
# =============================================================================
@test "build_deployment: scheduled mode renders a CronJob with a schedule" {
unset -f gomplate

export CONTEXT="$(_render_context)"

run bash "$BATS_TEST_DIRNAME/../build_deployment"
[ "$status" -eq 0 ]

local deploy_file="$OUTPUT_DIR/deployment-scope-123-deploy-456.yaml"
assert_contains "$(cat "$deploy_file")" "kind: CronJob"
assert_contains "$(cat "$deploy_file")" 'schedule: "*/5 * * * *"'
assert_contains "$(cat "$deploy_file")" "jobTemplate:"
}

@test "build_deployment: run-once mode renders a Job that runs on deploy" {
unset -f gomplate

# Default JOB_WAIT_TIMEOUT (600) drives the Job's activeDeadlineSeconds.
export CONTEXT="$(_render_context | jq '.scope.capabilities.cron = "run-once"')"

run bash "$BATS_TEST_DIRNAME/../build_deployment"
[ "$status" -eq 0 ]

local deploy_file="$OUTPUT_DIR/deployment-scope-123-deploy-456.yaml"
local rendered="$(cat "$deploy_file")"

# A one-shot Job, not a CronJob — none of the CronJob-only fields appear.
assert_contains "$rendered" "kind: Job"
! grep -q "kind: CronJob" "$deploy_file"
! grep -q "schedule:" "$deploy_file"
! grep -q "concurrencyPolicy:" "$deploy_file"
! grep -q "jobTemplate:" "$deploy_file"

# Job-only knobs: retries -> backoffLimit, timeout -> activeDeadlineSeconds,
# plus a TTL so finished one-shot Jobs are garbage-collected.
assert_contains "$rendered" "backoffLimit: 0"
assert_contains "$rendered" "activeDeadlineSeconds: 600"
assert_contains "$rendered" "ttlSecondsAfterFinished: 86400"

# The pod block is intact one level shallower (spec.template.spec).
assert_contains "$rendered" "name: job-scope-123-deploy-456"
assert_contains "$rendered" "- name: application"
assert_contains "$rendered" "name: s-scope-123-d-deploy-456"
assert_contains "$rendered" "restartPolicy: OnFailure"
}

@test "build_deployment: run-once Job keeps file-parameter volumes and mounts" {
unset -f gomplate

export CONTEXT="$(_render_context | jq '.scope.capabilities.cron = "run-once"')"

run bash "$BATS_TEST_DIRNAME/../build_deployment"
[ "$status" -eq 0 ]

local deploy_file="$OUTPUT_DIR/deployment-scope-123-deploy-456.yaml"
local rendered="$(cat "$deploy_file")"

assert_contains "$rendered" "- name: app-data-api-p12-cert"
assert_contains "$rendered" 'value: "/app-data/[2026-05-27] cert.p12"'
assert_contains "$rendered" 'mountPath: "/app-data/[2026-05-27] cert.p12"'
assert_contains "$rendered" "secretName: s-scope-123-d-deploy-456-files"
assert_contains "$rendered" "key: app-file-api-p12-cert"
}

@test "build_deployment: JOB_WAIT_TIMEOUT overrides the Job activeDeadlineSeconds" {
unset -f gomplate

export JOB_WAIT_TIMEOUT=120
export CONTEXT="$(_render_context | jq '.scope.capabilities.cron = "run-once"')"

run bash "$BATS_TEST_DIRNAME/../build_deployment"
[ "$status" -eq 0 ]

local deploy_file="$OUTPUT_DIR/deployment-scope-123-deploy-456.yaml"
assert_contains "$(cat "$deploy_file")" "activeDeadlineSeconds: 120"
}
Loading
Loading