diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..f4aa404 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,59 @@ +name: Build and Push CI Deployment Cleanup Docker Image + +on: + push: + tags: [ 'ci-deployment-cleanup-v*' ] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: swissdatasciencecenter/renku-ci-cleanup + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: | + VERSION=$(echo "${{ github.ref_name }}" | sed 's/ci-deployment-cleanup-v//') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}},value=${{ steps.version.outputs.version }} + type=sha + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ci-deployment-cleanup/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml new file mode 100644 index 0000000..86bc77a --- /dev/null +++ b/.github/workflows/helm-chart.yml @@ -0,0 +1,56 @@ +name: Package and Push Helm Chart + +on: + push: + tags: [ 'ci-deployment-cleanup-v*' ] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + +jobs: + helm-chart: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: '3.14.0' + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine app version + id: version + run: | + VERSION=$(echo "${{ github.ref_name }}" | sed 's/ci-deployment-cleanup-v//') + echo "appVersion=$VERSION" >> $GITHUB_OUTPUT + + - name: Update Chart.yaml with app version + run: | + cd ci-deployment-cleanup/helm-chart + sed -i "s/appVersion: .*/appVersion: \"${{ steps.version.outputs.appVersion }}\"/" Chart.yaml + + - name: Lint Helm chart + run: | + cd ci-deployment-cleanup + helm lint helm-chart/ + + - name: Package and push Helm chart + run: | + cd ci-deployment-cleanup + helm package helm-chart/ + helm push *.tgz oci://${{ env.REGISTRY }}/swissdatasciencecenter/helm-charts diff --git a/.github/workflows/lint-cleanup-script.yml b/.github/workflows/lint-cleanup-script.yml new file mode 100644 index 0000000..01e4317 --- /dev/null +++ b/.github/workflows/lint-cleanup-script.yml @@ -0,0 +1,26 @@ +name: Lint cleanup.sh script + +on: + push: + paths: + - 'ci-deployment-cleanup/helm-chart/cleanup.sh' + pull_request: + paths: + - 'ci-deployment-cleanup/helm-chart/cleanup.sh' + +permissions: + contents: read + +jobs: + shellcheck: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run ShellCheck + uses: ludeeus/action-shellcheck@master + with: + scandir: './ci-deployment-cleanup/helm-chart' + format: gcc + severity: error \ No newline at end of file diff --git a/ci-deployment-cleanup/Dockerfile b/ci-deployment-cleanup/Dockerfile new file mode 100644 index 0000000..23688d0 --- /dev/null +++ b/ci-deployment-cleanup/Dockerfile @@ -0,0 +1,52 @@ +FROM golang:1.24-alpine AS builder + +RUN apk add --no-cache make bash + +WORKDIR /app + +# Copy renku-dev-utils files +COPY . . + +# Build the rdu binary +RUN make rdu + +FROM alpine:3.18 + +RUN apk add --no-cache \ + bash \ + curl \ + ca-certificates \ + jq \ + openssl \ + && ARCH=$(case $(uname -m) in x86_64) echo amd64;; aarch64) echo arm64;; *) echo amd64;; esac) \ + && curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/${ARCH}/kubectl" \ + && chmod +x kubectl \ + && mv kubectl /usr/local/bin/ + +RUN curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 \ + && chmod 700 get_helm.sh \ + && ./get_helm.sh \ + && rm get_helm.sh + +# Copy the rdu binary from builder stage +COPY --from=builder /app/build/renku-dev-utils /usr/local/bin/rdu + +# Make rdu executable +RUN chmod +x /usr/local/bin/rdu + +# Create a non-root user +RUN addgroup -g 1000 appuser && \ + adduser -u 1000 -G appuser -s /bin/bash -D appuser + +# Switch to non-root user +USER appuser + +# Set working directory +WORKDIR /home/appuser + +# Verify installations +RUN rdu version || echo "rdu installed" && \ + kubectl version --client && \ + helm version + +CMD ["/bin/bash"] diff --git a/ci-deployment-cleanup/README.md b/ci-deployment-cleanup/README.md new file mode 100644 index 0000000..5a245b0 --- /dev/null +++ b/ci-deployment-cleanup/README.md @@ -0,0 +1,51 @@ +# Renku CI Deployment Cleanup + +A Kubernetes-based CI deployment cleanup system that uses a Helm chart to deploy automated cleanup of old Renku CI deployments. This system runs as a CronJob that leverages the `rdu` tool for comprehensive cleanup. + +## Installation + +Install the Helm chart: +```bash +helm install renku-ci-cleanup ./helm-chart +``` + +## Exemption + +Namespaces can be exempted from cleanup by adding the label `renku.io/cleanup-exempt: "true"` to the namespace. + +## How It Works + +1. The CronJob runs on the specified schedule (default: every 6 hours) +2. It queries Kubernetes for ALL namespaces in the cluster +3. For each namespace found: + - Checks if the namespace has the exemption label (if so, skips it) + - Checks if the namespace name matches any of the configured patterns (if enforcement is enabled) + - Calculates the age based on the namespace creation timestamp + - Checks GitHub PR status for PR-based cleanup (if enabled) + - If the namespace is older than the configured threshold AND matches the naming patterns AND is not exempt, it uses `rdu cleanup-deployment` to: + - Delete all sessions + - Uninstall all Helm releases + - Delete all jobs and PVCs + - Delete the entire namespace +4. Logging shows what actions were taken, including exemption and pattern matching results + +## Key Configuration + +The main configuration options in `values.yaml`: + +- `cleanup.maxAge`: Maximum age in hours before cleanup (default: 720 hours / 30 days) +- `cleanup.dryRun`: Enable dry-run mode (default: false) +- `cleanup.namespacePatterns`: List of regex patterns for namespace names +- `cleanup.enforceNamePatterns`: Enable strict pattern matching (default: true) +- `cleanup.prCleanup.enabled`: Enable GitHub PR-based cleanup (default: false) +- `cronJob.schedule`: Cron schedule (default: "0 */6 * * *" - every 6 hours) + +## PR-Based Cleanup + +The system supports GitHub PR-based cleanup that can automatically clean up namespaces when their associated pull requests are closed or merged. This feature requires: + +- `cleanup.prCleanup.enabled: true` +- GitHub API token configured +- Repository mappings in `cleanup.prCleanup.repositories` + +Example configuration maps namespace patterns to GitHub repositories and PR numbers. diff --git a/ci-deployment-cleanup/helm-chart/Chart.yaml b/ci-deployment-cleanup/helm-chart/Chart.yaml new file mode 100644 index 0000000..b2930d1 --- /dev/null +++ b/ci-deployment-cleanup/helm-chart/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: renku-ci-cleanup +description: A Helm chart for cleaning up old Renku CI deployments +type: application +version: 1.0.0 +appVersion: "1.0.0" +keywords: + - renku + - ci + - cleanup + - deployment +home: https://github.com/SwissDataScienceCenter/renku-dev-utils +sources: + - https://github.com/SwissDataScienceCenter/renku-dev-utils +maintainers: + - name: Renku Team + email: hello@renku.io + url: https://renkulab.io diff --git a/ci-deployment-cleanup/helm-chart/cleanup.sh b/ci-deployment-cleanup/helm-chart/cleanup.sh new file mode 100644 index 0000000..cea9e8a --- /dev/null +++ b/ci-deployment-cleanup/helm-chart/cleanup.sh @@ -0,0 +1,386 @@ +#!/bin/bash +set -e + +# Debug logging function +debug_log() { + if [ "$DEBUG_MODE" = "true" ]; then + echo "[DEBUG] $*" >&2 + fi +} + +echo "Starting Renku CI deployment cleanup..." +debug_log "Debug mode is enabled" +debug_log "Environment variables: DRY_RUN=$DRY_RUN, MAX_AGE_HOURS=$MAX_AGE_HOURS" + +echo "Max age: $MAX_AGE_HOURS hours" +echo "Exemption label: $EXEMPTION_LABEL" + +if [ "$ENFORCE_NAME_PATTERNS" = "true" ]; then + echo "Name pattern enforcement: enabled" + echo "Allowed patterns:" + # NAMESPACE_PATTERNS should be a space-separated list + for pattern in $NAMESPACE_PATTERNS; do + echo " - $pattern" + done +else + echo "Name pattern enforcement: disabled" +fi + +if [ "$PR_CLEANUP_ENABLED" = "true" ]; then + echo "PR-based cleanup: enabled" + echo "Repository mappings:" + # PR_REPOSITORIES should be formatted as "pattern1:repo1 pattern2:repo2" + for mapping in $PR_REPOSITORIES; do + pattern=$(echo "$mapping" | cut -d':' -f1) + repo=$(echo "$mapping" | cut -d':' -f2) + echo " - $pattern -> $repo" + done +else + echo "PR-based cleanup: disabled" +fi + +if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN MODE: No actual deletions will be performed" +fi + +debug_log "Initialization complete, starting namespace discovery" + +# Function to calculate age in seconds +calculate_age() { + local timestamp="$1" + local current_time=$(date +%s) + + debug_log "Calculating age for timestamp: $timestamp" + + # Kubernetes timestamps are in ISO 8601 format, need to handle them properly + local creation_time + if command -v gdate >/dev/null 2>&1; then + # Use GNU date if available (Linux with coreutils) + creation_time=$(gdate -d "$timestamp" +%s 2>/dev/null || echo "0") + debug_log "Used gdate for timestamp parsing" + else + # For Alpine Linux/BusyBox date, we need to parse the ISO 8601 format manually + # Format: 2025-05-28T13:50:39Z + local year month day hour minute second + year=$(echo "$timestamp" | cut -d'-' -f1) + month=$(echo "$timestamp" | cut -d'-' -f2) + day=$(echo "$timestamp" | cut -d'T' -f1 | cut -d'-' -f3) + hour=$(echo "$timestamp" | cut -d'T' -f2 | cut -d':' -f1) + minute=$(echo "$timestamp" | cut -d':' -f2) + second=$(echo "$timestamp" | cut -d':' -f3 | sed 's/Z$//') + + debug_log "Parsed timestamp components: $year-$month-$day $hour:$minute:$second" + + # Use BusyBox date with explicit format + local formatted_timestamp="${year}-${month}-${day} ${hour}:${minute}:${second}" + creation_time=$(date -d "$formatted_timestamp" +%s 2>/dev/null || echo "0") + debug_log "Used BusyBox date for timestamp parsing" + fi + + if [ "$creation_time" = "0" ]; then + debug_log "Failed to parse timestamp, returning age 0" + echo "0" + else + local age=$((current_time - creation_time)) + debug_log "Calculated age: $age seconds" + echo "$age" + fi +} + +# Function to format age for display +format_age() { + local age_seconds="$1" + local age_hours=$((age_seconds / 3600)) + local age_days=$((age_hours / 24)) + + if [ $age_days -gt 0 ]; then + echo "${age_days}d $((age_hours % 24))h" + else + echo "${age_hours}h" + fi +} + +# Function to format hours to days+hours for thresholds +format_hours_threshold() { + local hours="$1" + local days=$((hours / 24)) + + if [ $days -gt 0 ]; then + echo "${days}d ($((hours % 24))h)" + else + echo "${hours}h" + fi +} + +# Function to check if namespace matches any allowed pattern +matches_pattern() { + local namespace="$1" + debug_log "Checking if namespace '$namespace' matches any allowed patterns" + + if [ "$ENFORCE_NAME_PATTERNS" = "true" ]; then + for pattern in $NAMESPACE_PATTERNS; do + debug_log "Testing pattern: $pattern" + if [[ "$namespace" =~ $pattern ]]; then + debug_log "Namespace matches pattern: $pattern" + return 0 + fi + done + debug_log "Namespace does not match any patterns" + return 1 + else + # Pattern enforcement disabled, always return true + debug_log "Pattern enforcement disabled, allowing all namespaces" + return 0 + fi +} + +# Function to check GitHub PR status +check_pr_status() { + local namespace="$1" + local github_token="${GITHUB_TOKEN}" + + debug_log "Checking PR status for namespace: $namespace" + + if [ -z "$github_token" ]; then + echo " → GitHub token not configured, skipping PR status check" + debug_log "No GitHub token available" + return 1 + fi + + # Check each repository mapping + for mapping in $PR_REPOSITORIES; do + local pattern=$(echo "$mapping" | cut -d':' -f1) + local repo=$(echo "$mapping" | cut -d':' -f2) + + debug_log "Checking mapping: $pattern -> $repo" + + if [[ "$namespace" =~ $pattern ]]; then + debug_log "Namespace matches PR pattern: $pattern" + + # Extract PR number (assuming first capture group) + local pr_number="${BASH_REMATCH[1]}" + + if [ -z "$pr_number" ]; then + echo " → Could not extract PR number from namespace $namespace" + debug_log "Failed to extract PR number" + return 1 + fi + + echo " → Checking PR #$pr_number status in $repo" + debug_log "Querying GitHub API for PR #$pr_number in $repo" + + # Query GitHub API for PR status + local pr_response + pr_response=$(curl -s -H "Authorization: token $github_token" \ + "https://api.github.com/repos/$repo/pulls/$pr_number" 2>/dev/null) + + if [ $? -ne 0 ]; then + echo " → Failed to query GitHub API for PR #$pr_number" + debug_log "GitHub API request failed" + return 1 + fi + + debug_log "GitHub API response received" + + # Check for authentication errors + local auth_error + auth_error=$(echo "$pr_response" | grep -o '"message":[[:space:]]*"[^"]*"' | sed 's/"message":[[:space:]]*"\([^"]*\)"/\1/') + + if [ "$auth_error" = "Bad credentials" ]; then + echo " → GitHub authentication failed (bad credentials), skipping PR cleanup for all namespaces" + debug_log "GitHub authentication failed - bad credentials" + export GITHUB_AUTH_FAILED=true + return 1 + fi + + # Check if PR exists and get its state + local pr_state + pr_state=$(echo "$pr_response" | grep -o '"state":[[:space:]]*"[^"]*"' | sed 's/"state":[[:space:]]*"\([^"]*\)"/\1/') + + if [ -z "$pr_state" ]; then + echo " → PR #$pr_number not found in $repo" + debug_log "PR not found in repository" + # Set global variable for dry run messaging + PR_CLEANUP_REASON="PR #$pr_number not found in $repo" + return 0 # PR doesn't exist, can clean up + fi + + echo " → PR #$pr_number state: $pr_state" + debug_log "PR state: $pr_state" + + # Check if PR is closed or merged + if [ "$pr_state" = "closed" ]; then + # For closed PRs, check if it was merged + local merged + merged=$(echo "$pr_response" | grep -o '"merged":[[:space:]]*[^,}]*' | sed 's/"merged":[[:space:]]*\([^,}]*\)/\1/') + debug_log "PR merged status: $merged" + + if [ "$merged" = "true" ]; then + echo " → PR #$pr_number is merged, eligible for cleanup" + PR_CLEANUP_REASON="PR #$pr_number is merged in $repo" + else + echo " → PR #$pr_number is closed but not merged, eligible for cleanup" + PR_CLEANUP_REASON="PR #$pr_number is closed (not merged) in $repo" + fi + return 0 # Can clean up + elif [ "$pr_state" = "open" ]; then + echo " → PR #$pr_number is still open, skipping cleanup" + debug_log "PR is still open, cannot clean up" + return 1 # Cannot clean up + else + echo " → PR #$pr_number has unknown state: $pr_state" + debug_log "Unknown PR state: $pr_state" + return 1 # Unknown state, skip cleanup + fi + fi + done + + echo " → Namespace $namespace does not match any PR cleanup patterns" + debug_log "No matching PR cleanup patterns" + return 1 # No matching pattern +} + +# Get maximum age in seconds +MAX_AGE_SECONDS=$((MAX_AGE_HOURS * 3600)) +debug_log "Maximum age threshold: $MAX_AGE_SECONDS seconds ($MAX_AGE_HOURS hours)" + +# Find and process all namespaces +debug_log "Starting namespace enumeration" +kubectl get namespaces \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.creationTimestamp}{"\t"}{.metadata.labels}{"\n"}{end}' | + while IFS=$'\t' read -r namespace timestamp labels; do + if [ -z "$namespace" ] || [ -z "$timestamp" ]; then + debug_log "Skipping empty namespace or timestamp" + continue + fi + + debug_log "Processing namespace: $namespace" + + age_seconds=$(calculate_age "$timestamp") + age_display=$(format_age "$age_seconds") + + echo "Checking namespace: $namespace (age: $age_display)" + + # Check if namespace is exempt from cleanup + exemption_key=$(echo "$EXEMPTION_LABEL" | cut -d'=' -f1) + exemption_value=$(echo "$EXEMPTION_LABEL" | cut -d'=' -f2) + + debug_log "Checking exemption: key='$exemption_key', value='$exemption_value'" + debug_log "Namespace labels: $labels" + + # Check if the exemption label key exists with the correct value in the JSON labels + if [[ "$labels" == *"\"$exemption_key\":\"$exemption_value\""* ]] || [[ "$labels" == *"\"$exemption_key\":$exemption_value"* ]]; then + echo " → Namespace $namespace is exempt from cleanup (has exemption label), skipping" + debug_log "Namespace has exemption label: $exemption_key=$exemption_value" + continue + fi + + # Check if namespace matches allowed patterns + if ! matches_pattern "$namespace"; then + echo " → Namespace $namespace does not match any allowed patterns, skipping" + continue + fi + + # For matching namespaces, show age comparison with culling threshold + remaining_seconds=$((MAX_AGE_SECONDS - age_seconds)) + remaining_hours=$((remaining_seconds / 3600)) + threshold_display=$(format_hours_threshold $MAX_AGE_HOURS) + + if [ "$remaining_seconds" -gt 0 ]; then + echo " → Namespace $namespace has ${remaining_hours}h remaining before cleanup (${age_display} < ${threshold_display} threshold)" + debug_log "Namespace is within age threshold" + else + overdue_hours=$((-remaining_hours)) + echo " → Namespace $namespace is ${overdue_hours}h overdue for cleanup (${age_display} > ${threshold_display} threshold)" + debug_log "Namespace exceeds age threshold by ${overdue_hours}h" + fi + + # Check cleanup conditions + should_cleanup=false + cleanup_reason="" + + # Initialize PR cleanup reason variable + PR_CLEANUP_REASON="" + + # Check age-based cleanup + if [ "$age_seconds" -gt "$MAX_AGE_SECONDS" ]; then + should_cleanup=true + cleanup_reason="age-based (${age_display} > ${threshold_display})" + debug_log "Age-based cleanup triggered" + fi + + # Check PR-based cleanup if enabled and GitHub auth is working + if [ "$PR_CLEANUP_ENABLED" = "true" ] && [ "$GITHUB_AUTH_FAILED" != "true" ]; then + if check_pr_status "$namespace"; then + should_cleanup=true + if [ -n "$cleanup_reason" ]; then + cleanup_reason="$cleanup_reason and PR-based ($PR_CLEANUP_REASON)" + else + cleanup_reason="PR-based ($PR_CLEANUP_REASON)" + fi + debug_log "PR-based cleanup triggered: $PR_CLEANUP_REASON" + fi + elif [ "$PR_CLEANUP_ENABLED" = "true" ] && [ "$GITHUB_AUTH_FAILED" = "true" ]; then + echo " → Skipping PR-based cleanup due to GitHub authentication failure" + debug_log "Skipping PR cleanup due to GitHub auth failure" + fi + + if [ "$should_cleanup" = "true" ]; then + echo " → Namespace $namespace eligible for cleanup: $cleanup_reason" + debug_log "Namespace eligible for cleanup with reason: $cleanup_reason" + + if [ "$DRY_RUN" = "true" ]; then + echo " → DRY RUN: Would clean up namespace $namespace ($cleanup_reason)" + debug_log "Dry run mode: would clean up namespace" + else + debug_log "Performing actual cleanup" + # Use rdu cleanup command with force flag to avoid interactive prompts + if command -v rdu >/dev/null 2>&1; then + echo " → Using rdu cleanup-deployment for namespace $namespace" + debug_log "Using rdu for cleanup" + # Create .kube directory and empty config file to satisfy rdu's expectations + mkdir -p /home/appuser/.kube + touch /home/appuser/.kube/config + # Unset KUBECONFIG to force rdu to use in-cluster config + unset KUBECONFIG + echo "yes" | rdu cleanup-deployment --namespace "$namespace" --delete-namespace || { + echo " → Warning: rdu cleanup failed for $namespace" + debug_log "rdu cleanup failed, checking remaining resources" + + # Check what resources still exist in the namespace + echo " → Checking remaining resources in namespace $namespace:" + if kubectl get all -n "$namespace" 2>/dev/null | grep -v "^NAME" | grep -v "No resources found"; then + kubectl get all -n "$namespace" 2>/dev/null || echo " No standard resources found" + else + echo " No standard resources found" + fi + + # Also check for other common resources + echo " → Checking for PVCs, secrets, and configmaps:" + kubectl get pvc,secrets,configmaps -n "$namespace" 2>/dev/null | grep -v "^NAME" | grep -v "No resources found" || echo " No PVCs, secrets, or configmaps found" + + # Check for any finalizers that might be blocking deletion + echo " → Checking namespace finalizers:" + kubectl get namespace "$namespace" -o jsonpath='{.spec.finalizers}' 2>/dev/null | grep -q . && { + echo " Finalizers found: $(kubectl get namespace "$namespace" -o jsonpath='{.spec.finalizers}' 2>/dev/null)" + } || echo " No finalizers found" + + echo " → Attempting manual cleanup" + debug_log "Attempting manual namespace deletion" + kubectl delete namespace "$namespace" --timeout=300s || echo " → Failed to delete namespace $namespace" + } + else + echo " → rdu not available, performing manual cleanup" + debug_log "rdu not available, using kubectl for cleanup" + kubectl delete namespace "$namespace" --timeout=300s || echo " → Failed to delete namespace $namespace" + fi + echo " → Cleanup completed for namespace: $namespace" + debug_log "Cleanup completed for namespace: $namespace" + fi + else + debug_log "Namespace does not meet cleanup criteria" + fi + done + +debug_log "Namespace processing completed" +echo "Renku CI deployment cleanup completed" diff --git a/ci-deployment-cleanup/helm-chart/templates/_helpers.tpl b/ci-deployment-cleanup/helm-chart/templates/_helpers.tpl new file mode 100644 index 0000000..f949709 --- /dev/null +++ b/ci-deployment-cleanup/helm-chart/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "renku-ci-cleanup.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "renku-ci-cleanup.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "renku-ci-cleanup.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "renku-ci-cleanup.labels" -}} +helm.sh/chart: {{ include "renku-ci-cleanup.chart" . }} +{{ include "renku-ci-cleanup.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "renku-ci-cleanup.selectorLabels" -}} +app.kubernetes.io/name: {{ include "renku-ci-cleanup.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "renku-ci-cleanup.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "renku-ci-cleanup.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/ci-deployment-cleanup/helm-chart/templates/configmap.yaml b/ci-deployment-cleanup/helm-chart/templates/configmap.yaml new file mode 100644 index 0000000..dcc1474 --- /dev/null +++ b/ci-deployment-cleanup/helm-chart/templates/configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "renku-ci-cleanup.fullname" . }}-script + labels: + {{- include "renku-ci-cleanup.labels" . | nindent 4 }} +data: +{{ (.Files.Glob "cleanup.sh").AsConfig | indent 2 }} diff --git a/ci-deployment-cleanup/helm-chart/templates/cronjob.yaml b/ci-deployment-cleanup/helm-chart/templates/cronjob.yaml new file mode 100644 index 0000000..9b2c00c --- /dev/null +++ b/ci-deployment-cleanup/helm-chart/templates/cronjob.yaml @@ -0,0 +1,101 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "renku-ci-cleanup.fullname" . }} + labels: + {{- include "renku-ci-cleanup.labels" . | nindent 4 }} +spec: + schedule: {{ .Values.cronJob.schedule | quote }} + concurrencyPolicy: {{ .Values.cronJob.concurrencyPolicy }} + failedJobsHistoryLimit: {{ .Values.cronJob.failedJobsHistoryLimit }} + successfulJobsHistoryLimit: {{ .Values.cronJob.successfulJobsHistoryLimit }} + jobTemplate: + spec: + template: + metadata: + labels: + {{- include "renku-ci-cleanup.selectorLabels" . | nindent 12 }} + spec: + restartPolicy: {{ .Values.cronJob.restartPolicy }} + serviceAccountName: {{ include "renku-ci-cleanup.serviceAccountName" . }} + containers: + - name: cleanup + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - /bin/bash + - /scripts/cleanup.sh + env: + - name: MAX_AGE_HOURS + value: {{ .Values.cleanup.maxAge | quote }} + - name: EXEMPTION_LABEL + value: {{ .Values.cleanup.exemptionLabel | quote }} + - name: ENFORCE_NAME_PATTERNS + value: {{ .Values.cleanup.enforceNamePatterns | quote }} + {{- if .Values.cleanup.enforceNamePatterns }} + - name: NAMESPACE_PATTERNS + value: {{ join " " .Values.cleanup.namespacePatterns | quote }} + {{- end }} + - name: PR_CLEANUP_ENABLED + value: {{ .Values.cleanup.prCleanup.enabled | quote }} + {{- if .Values.cleanup.prCleanup.enabled }} + - name: PR_REPOSITORIES + value: "{{- range $i, $repo := .Values.cleanup.prCleanup.repositories }}{{- if $i }} {{ end }}{{ $repo.namespacePattern }}:{{ $repo.repo }}{{- end }}" + - name: GITHUB_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "renku-ci-cleanup.fullname" . }}-github-token + key: token + optional: true + {{- end }} + {{- if .Values.cleanup.dryRun }} + - name: DRY_RUN + value: "true" + {{- end }} + {{- if .Values.debug }} + {{- if .Values.debug.enabled }} + - name: DEBUG_MODE + value: "true" + {{- end }} + {{- end }} + volumeMounts: + - name: cleanup-script + mountPath: /scripts + readOnly: true + - name: service-account-token + mountPath: /var/run/secrets/kubernetes.io/serviceaccount + readOnly: true + resources: + {{- toYaml .Values.resources | nindent 14 }} + volumes: + - name: cleanup-script + configMap: + name: {{ include "renku-ci-cleanup.fullname" . }}-script + defaultMode: 0755 + - name: service-account-token + projected: + sources: + - serviceAccountToken: + path: token + - configMap: + name: kube-root-ca.crt + items: + - key: ca.crt + path: ca.crt + - downwardAPI: + items: + - path: namespace + fieldRef: + fieldPath: metadata.namespace + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 12 }} + {{- end }} \ No newline at end of file diff --git a/ci-deployment-cleanup/helm-chart/templates/rbac.yaml b/ci-deployment-cleanup/helm-chart/templates/rbac.yaml new file mode 100644 index 0000000..6aa0f67 --- /dev/null +++ b/ci-deployment-cleanup/helm-chart/templates/rbac.yaml @@ -0,0 +1,45 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "renku-ci-cleanup.fullname" . }} + labels: + {{- include "renku-ci-cleanup.labels" . | nindent 4 }} +rules: +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "delete", "watch"] +- apiGroups: [""] + resources: ["pods", "services", "configmaps", "secrets", "persistentvolumeclaims"] + verbs: ["get", "list", "delete", "deletecollection"] +- apiGroups: ["apps"] + resources: ["deployments", "replicasets", "statefulsets"] + verbs: ["get", "list", "delete"] +- apiGroups: ["batch"] + resources: ["jobs", "cronjobs"] + verbs: ["get", "list", "delete", "deletecollection"] +- apiGroups: ["extensions", "networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "delete"] +- apiGroups: ["rbac.authorization.k8s.io"] + resources: ["roles", "rolebindings"] + verbs: ["get", "list", "delete"] +- apiGroups: ["amalthea.dev"] + resources: ["amaltheasessions", "jupyterservers"] + verbs: ["get", "list", "delete", "deletecollection", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "renku-ci-cleanup.fullname" . }} + labels: + {{- include "renku-ci-cleanup.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "renku-ci-cleanup.fullname" . }} +subjects: +- kind: ServiceAccount + name: {{ include "renku-ci-cleanup.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} \ No newline at end of file diff --git a/ci-deployment-cleanup/helm-chart/templates/secret.yaml b/ci-deployment-cleanup/helm-chart/templates/secret.yaml new file mode 100644 index 0000000..1143cd4 --- /dev/null +++ b/ci-deployment-cleanup/helm-chart/templates/secret.yaml @@ -0,0 +1,13 @@ +{{- if .Values.cleanup.prCleanup.enabled }} +{{- if .Values.cleanup.prCleanup.githubToken }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "renku-ci-cleanup.fullname" . }}-github-token + labels: + {{- include "renku-ci-cleanup.labels" . | nindent 4 }} +type: Opaque +data: + token: {{ .Values.cleanup.prCleanup.githubToken | b64enc }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/ci-deployment-cleanup/helm-chart/templates/serviceaccount.yaml b/ci-deployment-cleanup/helm-chart/templates/serviceaccount.yaml new file mode 100644 index 0000000..1378a1f --- /dev/null +++ b/ci-deployment-cleanup/helm-chart/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "renku-ci-cleanup.serviceAccountName" . }} + labels: + {{- include "renku-ci-cleanup.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/ci-deployment-cleanup/helm-chart/values.yaml b/ci-deployment-cleanup/helm-chart/values.yaml new file mode 100644 index 0000000..d27649b --- /dev/null +++ b/ci-deployment-cleanup/helm-chart/values.yaml @@ -0,0 +1,109 @@ +# Default values for renku-ci-cleanup +# This is a YAML-formatted file + +# Container image configuration +image: + repository: ghcr.io/swissdatasciencecenter/renku-ci-cleanup + pullPolicy: IfNotPresent + # tag defaults to appVersion from Chart.yaml if not specified + tag: "" + +# CronJob configuration +cronJob: + # Cron schedule (every 6 hours by default) + schedule: "0 */6 * * *" + + # Concurrency policy for the cronjob + concurrencyPolicy: Forbid + + # Number of failed jobs to keep + failedJobsHistoryLimit: 3 + + # Number of successful jobs to keep + successfulJobsHistoryLimit: 1 + + # Restart policy for the job pods + restartPolicy: OnFailure + +# Cleanup configuration +cleanup: + # Maximum age in hours for CI deployments before cleanup + maxAge: 720 + + # Label used to exempt namespaces from cleanup + # Namespaces with this label will be skipped regardless of age + exemptionLabel: "renku.io/cleanup-exempt=true" + + # Namespace name patterns to match (regex patterns) + # Only namespaces matching these patterns will be considered for cleanup + namespacePatterns: + - "^ci-renku-.*" + - "^renku-blog-ci-.*" + - "^renku-ci-.*" + + # Enable strict name pattern matching (default: true) + # When true, namespaces must match at least one pattern to be cleaned up + enforceNamePatterns: true + + # Dry run mode - set to true to only log what would be deleted + dryRun: false + + # Debug mode + debug: + enabled: false + + # GitHub PR-based cleanup configuration + # Maps namespace patterns to GitHub repositories for PR status checking + # Format: namespace regex pattern -> {repo: "owner/repo", suffixPattern: "regex"} + prCleanup: + enabled: false + # GitHub API token for accessing PR status (required if prCleanup.enabled is true) + # Should be provided via secret or environment variable + githubToken: "" + # Repository mappings + repositories: + - namespacePattern: "^ci-renku-(.+)$" + repo: "SwissDataScienceCenter/renku" + suffixPattern: "(.+)" + - namespacePattern: "^renku-blog-ci-(.+)$" + repo: "SwissDataScienceCenter/renku-blog" + suffixPattern: "(.+)" + # Example: if namespace is "ci-renku-1234", it maps to PR #1234 in SwissDataScienceCenter/renku + +# Service account configuration +serviceAccount: + # Specifies whether a service account should be created + create: true + + # Annotations to add to the service account + annotations: {} + + # The name of the service account to use + name: "" + +# RBAC configuration +rbac: + # Specifies whether RBAC resources should be created + create: true + +# Resource limits and requests +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + +# Node selector for pod assignment +nodeSelector: {} + +# Tolerations for pod assignment +tolerations: [] + +# Affinity for pod assignment +affinity: {} + +# Name overrides +nameOverride: "" +fullnameOverride: ""