diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa174b9..85362f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file. 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] +- Fix: triggering a scheduled task job on a scope that is not deployed now fails with a clear "deploy the scope first" message instead of an opaque error + ## [1.13.0] - 2026-07-10 - Add support to get AWS credentials via assume role - Add support to auto-create ALBs on scope create diff --git a/scheduled_task/logging b/scheduled_task/logging new file mode 100644 index 00000000..d0df55d7 --- /dev/null +++ b/scheduled_task/logging @@ -0,0 +1,41 @@ +#!/bin/bash + +# Logging utility — log4j-style level filtering +# Usage: log "level" "message" +# Levels: debug < info < warn < error +# Control: LOG_LEVEL env var (default: info) +# +# Example: +# LOG_LEVEL=info +# log debug "verbose details" # suppressed +# log info "deployment done" # shown +log() { + local level="${1:-info}" + local message="${2:-}" + + local -i msg_num threshold + + case "${level,,}" in + debug) msg_num=0 ;; + info) msg_num=1 ;; + warn) msg_num=2 ;; + error) msg_num=3 ;; + *) msg_num=1 ;; + esac + + case "${LOG_LEVEL:-info}" in + debug) threshold=0 ;; + info) threshold=1 ;; + warn) threshold=2 ;; + error) threshold=3 ;; + *) threshold=1 ;; + esac + + if [ "$msg_num" -ge "$threshold" ]; then + if [ "$msg_num" -ge 3 ]; then + echo "$message" >&2 + else + echo "$message" + fi + fi +} diff --git a/scheduled_task/scope/tests/trigger.bats b/scheduled_task/scope/tests/trigger.bats new file mode 100644 index 00000000..18ff2732 --- /dev/null +++ b/scheduled_task/scope/tests/trigger.bats @@ -0,0 +1,236 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for scheduled_task scope/trigger - manually trigger the CronJob +# +# Contract: +# - A scope with no active deployment has no CronJob, so triggering must fail +# early with a clear "deploy first" message instead of an opaque kubectl +# error from `kubectl create job --from=cronjob/`. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + source "$PROJECT_ROOT/testing/assertions.sh" + + # The workflow loads the `log` function via a `load logging` step, so the + # script assumes it exists. Mock it here (errors go to stderr, like the real one). + log() { if [ "$1" = "error" ]; then echo "$2" >&2; else echo "$2"; fi; } + export -f log + + export K8S_NAMESPACE="default-namespace" + + # Base CONTEXT: deployed scope with an active deployment + export CONTEXT='{ + "scope": { + "id": "scope-123", + "current_active_deployment": "deploy-456" + }, + "providers": { + "container-orchestration": { + "cluster": { + "namespace": "provider-namespace" + } + } + } + }' + + # Mock kubectl: cronjob exists, job creation succeeds + kubectl() { + case "$*" in + "get cronjob -n provider-namespace -l scope_id=scope-123 -o jsonpath={.items[0].metadata.name}") + echo "my-cronjob" + return 0 + ;; + "create job --from=cronjob/my-cronjob"*) + return 0 + ;; + *) + return 0 + ;; + esac + } + export -f kubectl + + # Deterministic timestamp for job name suffix + date() { echo "1700000000"; } + export -f date +} + +teardown() { + unset -f kubectl date log +} + +# ============================================================================= +# Success Flow +# ============================================================================= +@test "trigger: success flow - finds cronjob and triggers a job" { + run bash "$BATS_TEST_DIRNAME/../trigger" + + [ "$status" -eq 0 ] + assert_contains "$output" "📝 Triggering job my-cronjob" + assert_contains "$output" "✅ The job my-cronjob was triggered, you can follow the execution from the logs screen" +} + +# ============================================================================= +# Error: scope not deployed (no active deployment) +# ============================================================================= +@test "trigger: fails with a clear message when the scope has no active deployment" { + export CONTEXT=$(echo "$CONTEXT" | jq 'del(.scope.current_active_deployment)') + + run bash "$BATS_TEST_DIRNAME/../trigger" + + [ "$status" -eq 1 ] + assert_contains "$output" "❌ The scope has no active deployment, so there is no scheduled job to trigger" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "The scope has not been deployed yet" + assert_contains "$output" "🔧 How to fix:" + assert_contains "$output" "• Deploy the scope first, then trigger the job" +} + +@test "trigger: fails when current_active_deployment is null" { + export CONTEXT=$(echo "$CONTEXT" | jq '.scope.current_active_deployment = null') + + run bash "$BATS_TEST_DIRNAME/../trigger" + + [ "$status" -eq 1 ] + assert_contains "$output" "❌ The scope has no active deployment, so there is no scheduled job to trigger" + assert_contains "$output" "• Deploy the scope first, then trigger the job" +} + +# ============================================================================= +# Error: deployed but no CronJob found +# ============================================================================= +@test "trigger: fails with a clear message when no CronJob exists for the scope" { + kubectl() { + case "$*" in + "get cronjob -n provider-namespace -l scope_id=scope-123 -o jsonpath={.items[0].metadata.name}") + echo "" + return 0 + ;; + *) + return 0 + ;; + esac + } + export -f kubectl + + run bash "$BATS_TEST_DIRNAME/../trigger" + + [ "$status" -eq 1 ] + assert_contains "$output" "❌ No CronJob found for scope 'scope-123' in namespace 'provider-namespace'" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +# ============================================================================= +# Namespace resolution +# ============================================================================= +@test "trigger: uses the namespace from the provider for lookup and job creation" { + # kubectl only succeeds when called against the provider namespace; any other + # namespace makes it fail, so this asserts the resolved namespace is threaded + # through both the cronjob lookup and the job creation. + kubectl() { + case "$*" in + "get cronjob -n provider-namespace"*) + echo "my-cronjob" + return 0 + ;; + "create job --from=cronjob/my-cronjob"*"-n provider-namespace") + return 0 + ;; + *) + echo "unexpected namespace: $*" >&2 + return 1 + ;; + esac + } + export -f kubectl + + run bash "$BATS_TEST_DIRNAME/../trigger" + + [ "$status" -eq 0 ] + assert_contains "$output" "✅ The job my-cronjob was triggered, you can follow the execution from the logs screen" +} + +@test "trigger: falls back to the default namespace when the provider namespace is not set" { + export CONTEXT=$(echo "$CONTEXT" | jq 'del(.providers["container-orchestration"].cluster.namespace)') + + kubectl() { + case "$*" in + "get cronjob -n default-namespace"*) + echo "my-cronjob" + return 0 + ;; + "create job --from=cronjob/my-cronjob"*"-n default-namespace") + return 0 + ;; + *) + echo "unexpected namespace: $*" >&2 + return 1 + ;; + esac + } + export -f kubectl + + run bash "$BATS_TEST_DIRNAME/../trigger" + + [ "$status" -eq 0 ] + assert_contains "$output" "✅ The job my-cronjob was triggered, you can follow the execution from the logs screen" +} + +# The workflow includes values.yaml to set K8S_NAMESPACE, but the script also +# guards the expansion so that a missing include can never abort it under +# `set -u`. These reproduce that unguarded environment (K8S_NAMESPACE unset). +@test "trigger: does not abort under set -u when K8S_NAMESPACE is unset (uses provider namespace)" { + unset K8S_NAMESPACE + + run bash "$BATS_TEST_DIRNAME/../trigger" + + [ "$status" -eq 0 ] + assert_contains "$output" "✅ The job my-cronjob was triggered, you can follow the execution from the logs screen" +} + +@test "trigger: falls back to the nullplatform default when K8S_NAMESPACE is unset and the provider namespace is absent" { + unset K8S_NAMESPACE + export CONTEXT=$(echo "$CONTEXT" | jq 'del(.providers["container-orchestration"].cluster.namespace)') + + kubectl() { + case "$*" in + "get cronjob -n nullplatform"*) + echo "my-cronjob" + return 0 + ;; + "create job --from=cronjob/my-cronjob"*"-n nullplatform") + return 0 + ;; + *) + echo "unexpected namespace: $*" >&2 + return 1 + ;; + esac + } + export -f kubectl + + run bash "$BATS_TEST_DIRNAME/../trigger" + + [ "$status" -eq 0 ] + assert_contains "$output" "✅ The job my-cronjob was triggered, you can follow the execution from the logs screen" +} + +# ============================================================================= +# Workflow wiring (trigger-job.yaml) — the layer the script tests can't see +# ============================================================================= +@test "trigger-job workflow includes values.yaml so K8S_NAMESPACE is provided" { + run grep -A2 "^include:" "$BATS_TEST_DIRNAME/../workflows/trigger-job.yaml" + + assert_equal "$status" "0" + assert_contains "$output" "values.yaml" +} + +@test "trigger-job workflow loads the log function before the trigger step" { + run cat "$BATS_TEST_DIRNAME/../workflows/trigger-job.yaml" + + assert_equal "$status" "0" + assert_contains "$output" "name: load logging" + assert_contains "$output" "\$OVERRIDES_PATH/logging" +} diff --git a/scheduled_task/scope/trigger b/scheduled_task/scope/trigger index 1ebf6cce..0aeefc5a 100644 --- a/scheduled_task/scope/trigger +++ b/scheduled_task/scope/trigger @@ -1,6 +1,22 @@ #!/bin/bash -K8S_NAMESPACE=$(echo "$CONTEXT" | jq -r --arg default "$K8S_NAMESPACE" ' +set -euo pipefail + +DEPLOYMENT_ID=$(echo "$CONTEXT" | jq -r '.scope.current_active_deployment // empty') + +if [[ -z "$DEPLOYMENT_ID" ]]; then + log error "❌ The scope has no active deployment, so there is no scheduled job to trigger" + log error "" + log error "💡 Possible causes:" + log error " The scope has not been deployed yet" + log error "" + log error "🔧 How to fix:" + log error " • Deploy the scope first, then trigger the job" + log error "" + exit 1 +fi + +K8S_NAMESPACE=$(echo "$CONTEXT" | jq -r --arg default "${K8S_NAMESPACE:-nullplatform}" ' .providers["container-orchestration"].cluster.namespace // $default ') @@ -8,8 +24,21 @@ SCOPE_ID=$(echo "$CONTEXT" | jq -r '.scope.id') JOB=$(kubectl get cronjob -n "$K8S_NAMESPACE" -l "scope_id=$SCOPE_ID" -o jsonpath="{.items[0].metadata.name}" 2>/dev/null || echo "") -echo "Triggering job $JOB" +if [[ -z "$JOB" ]]; then + log error "❌ No CronJob found for scope '$SCOPE_ID' in namespace '$K8S_NAMESPACE'" + log error "" + log error "💡 Possible causes:" + log error " The scope's scheduled job may not have been created yet, or the deployment is still in progress" + log error "" + log error "🔧 How to fix:" + log error " • Verify the CronJob exists: kubectl get cronjob -n $K8S_NAMESPACE -l scope_id=$SCOPE_ID" + log error " • Redeploy the scope if the CronJob is missing" + log error "" + exit 1 +fi + +log info "📝 Triggering job $JOB" kubectl create job --from="cronjob/$JOB" "$JOB-$(date +%s)" -n "$K8S_NAMESPACE" -echo "The job $JOB was triggered, you can follow the execution from the logs screen" \ No newline at end of file +log info "✅ The job $JOB was triggered, you can follow the execution from the logs screen" diff --git a/scheduled_task/scope/workflows/trigger-job.yaml b/scheduled_task/scope/workflows/trigger-job.yaml index 6b07c1ce..02df28f0 100644 --- a/scheduled_task/scope/workflows/trigger-job.yaml +++ b/scheduled_task/scope/workflows/trigger-job.yaml @@ -1,7 +1,18 @@ +include: + - "$SERVICE_PATH/values.yaml" provider_categories: - container-orchestration - cloud-providers steps: + - name: load logging + type: script + file: "$OVERRIDES_PATH/logging" + output: + - name: log + type: function + parameters: + level: string + message: string - name: trigger type: script - file: "$OVERRIDES_PATH/scope/trigger" \ No newline at end of file + file: "$OVERRIDES_PATH/scope/trigger"