Execution Repository Compression Rollback for Downgrades to Versions Incompatible with Compression
Problem Description
Spinnaker Orca Pull Request #4620 introduced improvements for handling
large payloads by storing compressed bodies in the database. Unfortunately, in the case of a rollback to a version
which does not support compression - Spinnaker will fail to render executions, because the body would effectively be
empty, as it would be stored in a corresponding entry as compressed_body in a table with _compressed_executions
appended.
Workaround
Below you will find a set of scripts which would help decompress the compressed_body into the corresponding body
field matching the id of the record.
Backup script
#!/bin/bash
# Copyright 2024 Harness, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -euo pipefail
# Logging function
log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') $1"
}
# Check if required tools are installed
check_tool() {
    if ! command -v "$1" &>/dev/null; then
        log "Error: $1 is not installed, please install it to proceed."
        exit 1
    fi
}
check_tool "mysql"
check_tool "mysqldump"
# Variables
DB_HOST=""
DB_PORT=""
DB_USER=""
DB_PASS=""
DB_NAME=""
BACKUP_DIR="backup"
THRESHOLD_MB=100
# Usage function
usage() {
    echo "Usage: $0 --db-host <host> --db-port <port> --db-user <user> --db-pass <password> --db-name <name> --backup-dir <dir> [--threshold <mb>]"
    exit 1
}
# Parse arguments
while [[ $# -gt 0 ]]; do
    case "$1" in
        --db-host) DB_HOST="$2"; shift ;;
        --db-port) DB_PORT="$2"; shift ;;
        --db-user) DB_USER="$2"; shift ;;
        --db-pass) DB_PASS="$2"; shift ;;
        --db-name) DB_NAME="$2"; shift ;;
        --backup-dir) BACKUP_DIR="$2"; shift ;;
        --threshold) THRESHOLD_MB="$2"; shift ;;
        *) usage ;;
    esac
    shift
done
# Ensure required parameters are set
if [[ -z $DB_HOST || -z $DB_PORT || -z $DB_USER || -z $DB_PASS || -z $DB_NAME || -z $BACKUP_DIR ]]; then
    usage
fi
tables=("pipelines" "pipeline_stages" "pipelines_compressed_executions" "pipeline_stages_compressed_executions" \
        "orchestrations" "orchestration_stages" "orchestrations_compressed_executions" "orchestration_stages_compressed_executions")
total_size=0
log "Starting backup process..."
mkdir -p "$BACKUP_DIR" || { log "Error: Could not create backup directory."; exit 1; }
for table in "${tables[@]}"; do
    # Check if the table exists
    table_exists=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" -D "$DB_NAME" -e \
        "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = '$DB_NAME' AND TABLE_NAME = '$table';" | tail -n 1)
    if [[ "$table_exists" -eq 0 ]]; then
        log "Warning: Table $table does not exist. Skipping..."
        continue
    fi
    # Calculate table size
    size=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" -D "$DB_NAME" -e \
        "SELECT ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS size_mb FROM information_schema.TABLES WHERE TABLE_NAME = '$table';" | tail -n 1 | tr -d '[:space:]')
    total_size=$(echo "$total_size + ${size:-0}" | bc)
    # Backup table
    log "Backing up table: $table (Size: ${size}MB)"
    # Prompt user if the backup exceeds the threshold
    if (( $(echo "$size > $THRESHOLD_MB" | bc -l) )); then
        read -p "Backup exceeds ${THRESHOLD_MB}MB. Continue? (y/n): " confirm
        if [[ "$confirm" != "y" ]]; then
            log "Warning: Backup of $table has been skipped..."
            continue
        fi
    fi
    mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" --skip-lock-tables --skip-add-drop-table --skip-add-locks --no-create-db --no-create-info --no-tablespaces "$DB_NAME" "$table" > "$BACKUP_DIR/${table}_backup.sql" || {
        log "Error: Failed to back up table $table."
        exit 1
    }
done
log "Backup completed successfully. Total size: ${total_size}MB."
Rollback script
#!/bin/bash
# Copyright 2024 Harness, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -euo pipefail
# Logging function
log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') $1"
}
# Check if required tools are installed
check_tool() {
    if ! command -v "$1" &>/dev/null; then
        log "Error: $1 is not installed, please install it to proceed."
        exit 1
    fi
}
check_tool "gunzip"
check_tool "mysql"
check_tool "xxd"
check_tool "zlib-flate" # apk add qpdf
# Variables
DB_HOST=""
DB_PORT=""
DB_USER=""
DB_PASS=""
DB_NAME=""
DRY_RUN=false
# Usage function
usage() {
    echo "Usage: $0 --db-host <host> --db-port <port> --db-user <user> --db-pass <password> --db-name <name> [--dry-run]"
    exit 1
}
# Parse arguments
while [[ $# -gt 0 ]]; do
    case "$1" in
        --db-host) DB_HOST="$2"; shift ;;
        --db-port) DB_PORT="$2"; shift ;;
        --db-user) DB_USER="$2"; shift ;;
        --db-pass) DB_PASS="$2"; shift ;;
        --db-name) DB_NAME="$2"; shift ;;
        --dry-run) DRY_RUN=true ;;
        *) usage ;;
    esac
    shift
done
# Ensure required parameters are set
if [[ -z $DB_HOST || -z $DB_PORT || -z $DB_USER || -z $DB_PASS || -z $DB_NAME ]]; then
    usage
fi
# Check if local_infile is enabled
local_infile_status=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" -sse "SHOW GLOBAL VARIABLES LIKE 'local_infile';" | awk '{print $2}')
if [[ "$local_infile_status" != "ON" ]]; then
    log "Error: MySQL 'local_infile' is not enabled. Please enable it and try again."
    exit 1
fi
log "MySQL 'local_infile' is enabled. Proceeding with the script."
# Rollback logic
tables=("pipeline_stages_compressed_executions" "pipelines_compressed_executions"
        "orchestration_stages_compressed_executions" "orchestrations_compressed_executions")
for compressed_table in "${tables[@]}"; do
    # Check if the table exists
    table_exists=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" -D "$DB_NAME" -sse "
        SELECT COUNT(*)
        FROM information_schema.tables
        WHERE table_schema = '$DB_NAME' AND table_name = '$compressed_table';
    ")
    if [[ $table_exists -eq 0 ]]; then
        log "Table $compressed_table does not exist. Skipping."
        continue
    fi
    target_table="${compressed_table%_compressed_executions}"
    compression_type=$(mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" -D "$DB_NAME" -sse "
        SELECT DISTINCT compression_type
        FROM $compressed_table;
    ")
    if [[ -z $compression_type ]]; then
        log "No compression type found for $compressed_table. Skipping."
        continue
    elif [[ $compression_type != "GZIP" && $compression_type != "ZLIB" ]]; then
        log "Unknown compression type $compression_type for $compressed_table. Skipping."
        continue
    fi
    log "Processing table $compressed_table with compression type $compression_type."
    # Fetch records
    mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" -D "$DB_NAME" -e "
        SELECT id, HEX(compressed_body)
        FROM $compressed_table;
    " | while read -r id hex_body; do
        # Skip header
        if [[ "$id" == "id" ]]; then
            continue
        fi
        # Convert hex to binary
        echo "$hex_body" | xxd -r -p > temp_compressed_body.bin
        # Decompress binary data
        if [[ $compression_type == "GZIP" ]]; then
            decompressed_body=$(gunzip -c < temp_compressed_body.bin 2>/dev/null)
        elif [[ $compression_type == "ZLIB" ]]; then
            decompressed_body=$(zlib-flate -uncompress < temp_compressed_body.bin 2>/dev/null)
        fi
        if [[ $? -ne 0 ]]; then
            log "Error: Decompressing data for ID $id in $compressed_table failed. Skipping."
            continue
        fi
        # Validate JSON
        echo "$decompressed_body" | jq . >/dev/null 2>&1
        if [[ $? -ne 0 ]]; then
            log "Error: Invalid JSON for ID $id in $compressed_table. Skipping."
            continue
        fi
        log "Successfully decompressed and validated JSON for ID $id."
        if $DRY_RUN; then
            log "Dry-run mode: Would update $target_table for ID $id."
            log "First 100 characters of decompressed JSON: ${decompressed_body:0:100}"
        else
            # Write Base64-encoded JSON to a temporary file without line wrapping (important)
            echo "$decompressed_body" | base64 -w 0 > temp_decompressed_body.b64
            # Because the decompressed body may be large, in order to avoid reaching ARG_MAX, do:
            # 1. Create a temporary table
            # 2. Load data into the temporary table
            # 3. Update the target table
            # 4. Drop the temporary table
            mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" -D "$DB_NAME" --local-infile=1 -e "
                CREATE TEMPORARY TABLE temp_table (decompressed_body LONGTEXT);
                CREATE INDEX idx_temp_table_body ON temp_table (decompressed_body(255));
                LOAD DATA LOCAL INFILE 'temp_decompressed_body.b64'
                INTO TABLE temp_table;
                UPDATE $target_table
                JOIN temp_table ON TRUE
                SET body = FROM_BASE64(temp_table.decompressed_body)
                WHERE id = '$id';
                DROP TEMPORARY TABLE temp_table;
            " || log "Error: Failed to update $target_table for ID $id."
        fi
    done
    # Cleanup temporary files
    rm -f temp_compressed_body.bin temp_decompressed_body.b64
done
log "Rollback process completed."
Restore backup script
#!/bin/bash
# Copyright 2024 Harness, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -euo pipefail
# Logging function
log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') $1"
}
# Check if required tools are installed
check_tool() {
    if ! command -v "$1" &>/dev/null; then
        log "Error: $1 is not installed, please install it to proceed."
        exit 1
    fi
}
check_tool "mysql"
# Variables
DB_HOST=""
DB_PORT=""
DB_USER=""
DB_PASS=""
DB_NAME=""
BACKUP_DIR="backup"
# Usage function
usage() {
    echo "Usage: $0 --db-host <host> --db-port <port> --db-user <user> --db-pass <password> --db-name <dbname> [--backup-dir <dir>]"
    exit 1
}
# Parse arguments
while [[ $# -gt 0 ]]; do
    case "$1" in
        --db-host) DB_HOST="$2"; shift ;;
        --db-port) DB_PORT="$2"; shift ;;
        --db-user) DB_USER="$2"; shift ;;
        --db-pass) DB_PASS="$2"; shift ;;
        --db-name) DB_NAME="$2"; shift ;;
        --backup-dir) BACKUP_DIR="$2"; shift ;;
        *) usage ;;
    esac
    shift
done
# Ensure required parameters are set
if [[ -z "$DB_HOST" || -z "$DB_PORT" || -z "$DB_USER" || -z "$DB_PASS" || -z "$DB_NAME" ]]; then
    usage
fi
tables=("pipelines" "pipeline_stages" "pipelines_compressed_executions" "pipeline_stages_compressed_executions" \
        "orchestrations" "orchestration_stages" "orchestrations_compressed_executions" "orchestration_stages_compressed_executions")
log "Starting restore process..."
for table in "${tables[@]}"; do
    backup_file="$BACKUP_DIR/$table.sql"
    if [[ -f "$backup_file" ]]; then
        log "Restoring table: $table"
        mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$backup_file"
    else
        log "Backup file not found for table: $table. Skipping."
    fi
done
log "Restore process completed successfully."
Example Usage
- Connect to an environment with access to the database
- Create the backup script and make a backup of the tables using it, if the option of restoring the data is needed
- Create the rollback script
- Run the script with the --dry-runoption to ensure that there are no errors, and that the decompressed JSONs look valid
- Execute the script without the --dry-runoption
- Validate that the executions are now visible in the UI