#!/usr/bin/env bash # ----------------------------------------------------------------------------- # rotate-secrets-bundle.sh # Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration) # Task: OKS-006 - Add bundle rotation/upgrade workflow # Description: Safely rotate/upgrade secrets rule bundle with backup and rollback # ----------------------------------------------------------------------------- # Usage: ./rotate-secrets-bundle.sh [install-path] # Example: ./rotate-secrets-bundle.sh /mnt/offline-kit/rules/secrets/2026.02 set -euo pipefail # Configuration NEW_BUNDLE_PATH="${1:?New bundle path required (e.g., /mnt/offline-kit/rules/secrets/2026.02)}" INSTALL_PATH="${2:-/opt/stellaops/plugins/scanner/analyzers/secrets}" BACKUP_BASE="${BACKUP_BASE:-/opt/stellaops/backups/secrets-bundles}" BUNDLE_ID="${BUNDLE_ID:-secrets.ruleset}" ATTESTOR_MIRROR="${ATTESTOR_MIRROR:-}" RESTART_WORKERS="${RESTART_WORKERS:-true}" KUBERNETES_NAMESPACE="${KUBERNETES_NAMESPACE:-stellaops}" KUBERNETES_DEPLOYMENT="${KUBERNETES_DEPLOYMENT:-scanner-worker}" MAX_BACKUPS="${MAX_BACKUPS:-5}" # Script directory for calling install script SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Color output helpers if [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' NC='\033[0m' else RED='' GREEN='' YELLOW='' BLUE='' NC='' fi log_info() { echo -e "${GREEN}==>${NC} $*"; } log_warn() { echo -e "${YELLOW}WARN:${NC} $*" >&2; } log_error() { echo -e "${RED}ERROR:${NC} $*" >&2; } log_step() { echo -e "${BLUE}--->${NC} $*"; } # Error handler cleanup_on_error() { log_error "Rotation failed! Attempting rollback..." if [[ -n "${BACKUP_DIR:-}" && -d "${BACKUP_DIR}" ]]; then perform_rollback "${BACKUP_DIR}" fi } perform_rollback() { local backup_dir="$1" log_info "Rolling back to backup: ${backup_dir}" if [[ ! -d "${backup_dir}" ]]; then log_error "Backup directory not found: ${backup_dir}" return 1 fi # Restore files cp -a "${backup_dir}"/* "${INSTALL_PATH}/" 2>/dev/null || { log_error "Failed to restore files from backup" return 1 } log_info "Rollback completed" # Restart workers after rollback if [[ "${RESTART_WORKERS}" == "true" ]]; then restart_workers "rollback" fi return 0 } restart_workers() { local reason="${1:-upgrade}" log_info "Restarting scanner workers (${reason})..." # Try Kubernetes first if command -v kubectl &>/dev/null; then if kubectl get deployment "${KUBERNETES_DEPLOYMENT}" -n "${KUBERNETES_NAMESPACE}" &>/dev/null; then log_step "Performing Kubernetes rolling restart..." kubectl rollout restart deployment/"${KUBERNETES_DEPLOYMENT}" -n "${KUBERNETES_NAMESPACE}" log_step "Waiting for rollout to complete..." kubectl rollout status deployment/"${KUBERNETES_DEPLOYMENT}" -n "${KUBERNETES_NAMESPACE}" --timeout=300s || { log_warn "Rollout status check timed out (workers may still be restarting)" } return 0 fi fi # Try systemd if command -v systemctl &>/dev/null; then if systemctl is-active stellaops-scanner-worker &>/dev/null 2>&1; then log_step "Restarting systemd service..." systemctl restart stellaops-scanner-worker return 0 fi fi log_warn "Could not auto-restart workers (no Kubernetes or systemd found)" log_warn "Please restart scanner workers manually" } cleanup_old_backups() { log_info "Cleaning up old backups (keeping last ${MAX_BACKUPS})..." if [[ ! -d "${BACKUP_BASE}" ]]; then return 0 fi # List backups sorted by name (which includes timestamp) local backups backups=$(find "${BACKUP_BASE}" -maxdepth 1 -type d -name "20*" | sort -r) local count=0 for backup in ${backups}; do count=$((count + 1)) if [[ ${count} -gt ${MAX_BACKUPS} ]]; then log_step "Removing old backup: ${backup}" rm -rf "${backup}" fi done } # Main rotation logic main() { echo "" log_info "Secrets Bundle Rotation" echo "========================================" echo "" # Validate new bundle log_info "Step 1/6: Validating new bundle..." if [[ ! -d "${NEW_BUNDLE_PATH}" ]]; then log_error "New bundle directory not found: ${NEW_BUNDLE_PATH}" exit 1 fi NEW_MANIFEST="${NEW_BUNDLE_PATH}/${BUNDLE_ID}.manifest.json" if [[ ! -f "${NEW_MANIFEST}" ]]; then log_error "New bundle manifest not found: ${NEW_MANIFEST}" exit 1 fi NEW_VERSION=$(jq -r '.version // "unknown"' "${NEW_MANIFEST}" 2>/dev/null || echo "unknown") NEW_RULE_COUNT=$(jq -r '.ruleCount // 0' "${NEW_MANIFEST}" 2>/dev/null || echo "0") log_step "New version: ${NEW_VERSION} (${NEW_RULE_COUNT} rules)" # Check current installation log_info "Step 2/6: Checking current installation..." CURRENT_VERSION="(none)" CURRENT_RULE_COUNT="0" if [[ -f "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" ]]; then CURRENT_VERSION=$(jq -r '.version // "unknown"' "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" 2>/dev/null || echo "unknown") CURRENT_RULE_COUNT=$(jq -r '.ruleCount // 0' "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" 2>/dev/null || echo "0") log_step "Current version: ${CURRENT_VERSION} (${CURRENT_RULE_COUNT} rules)" else log_step "No current installation found" fi # Version comparison if [[ "${CURRENT_VERSION}" != "(none)" ]]; then if [[ "${CURRENT_VERSION}" == "${NEW_VERSION}" ]]; then log_warn "New version (${NEW_VERSION}) is the same as current" if [[ "${FORCE_ROTATION:-false}" != "true" ]]; then log_warn "Use FORCE_ROTATION=true to reinstall" exit 0 fi elif [[ "${CURRENT_VERSION}" > "${NEW_VERSION}" ]]; then log_warn "New version (${NEW_VERSION}) is older than current (${CURRENT_VERSION})" if [[ "${FORCE_ROTATION:-false}" != "true" ]]; then log_warn "Use FORCE_ROTATION=true to downgrade" exit 1 fi fi fi echo "" log_info "Upgrade: ${CURRENT_VERSION} -> ${NEW_VERSION}" echo "" # Backup current installation log_info "Step 3/6: Creating backup..." BACKUP_DIR="${BACKUP_BASE}/$(date +%Y%m%d_%H%M%S)_${CURRENT_VERSION}" if [[ -d "${INSTALL_PATH}" && -f "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" ]]; then mkdir -p "${BACKUP_DIR}" cp -a "${INSTALL_PATH}"/* "${BACKUP_DIR}/" 2>/dev/null || { log_error "Failed to create backup" exit 1 } log_step "Backup created: ${BACKUP_DIR}" # Create backup metadata cat > "${BACKUP_DIR}/.backup-metadata.json" </dev/null || hostname)" } EOF else log_step "No existing installation to backup" BACKUP_DIR="" fi # Set up error handler for rollback trap cleanup_on_error ERR # Install new bundle log_info "Step 4/6: Installing new bundle..." export FORCE_INSTALL=true export REQUIRE_SIGNATURE="${REQUIRE_SIGNATURE:-true}" if [[ -n "${ATTESTOR_MIRROR}" ]]; then "${SCRIPT_DIR}/install-secrets-bundle.sh" "${NEW_BUNDLE_PATH}" "${INSTALL_PATH}" "${ATTESTOR_MIRROR}" else "${SCRIPT_DIR}/install-secrets-bundle.sh" "${NEW_BUNDLE_PATH}" "${INSTALL_PATH}" fi # Verify installation log_info "Step 5/6: Verifying installation..." INSTALLED_VERSION=$(jq -r '.version' "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" 2>/dev/null || echo "unknown") if [[ "${INSTALLED_VERSION}" != "${NEW_VERSION}" ]]; then log_error "Installation verification failed" log_error "Expected version: ${NEW_VERSION}" log_error "Installed version: ${INSTALLED_VERSION}" exit 1 fi log_step "Installation verified: ${INSTALLED_VERSION}" # Remove error trap since installation succeeded trap - ERR # Restart workers log_info "Step 6/6: Restarting workers..." if [[ "${RESTART_WORKERS}" == "true" ]]; then restart_workers "upgrade" else log_step "Worker restart skipped (RESTART_WORKERS=false)" fi # Cleanup old backups cleanup_old_backups # Generate rotation report REPORT_FILE="${INSTALL_PATH}/.rotation-report.json" cat > "${REPORT_FILE}" </dev/null || hostname)" } EOF echo "" echo "========================================" log_info "Rotation completed successfully!" echo "" echo "Summary:" echo " Previous version: ${CURRENT_VERSION} (${CURRENT_RULE_COUNT} rules)" echo " New version: ${NEW_VERSION} (${NEW_RULE_COUNT} rules)" if [[ -n "${BACKUP_DIR}" ]]; then echo " Backup path: ${BACKUP_DIR}" fi echo " Report: ${REPORT_FILE}" echo "" echo "To verify the upgrade:" echo " kubectl logs -l app=scanner-worker --tail=100 | grep SecretsAnalyzerHost" echo "" echo "To rollback if needed:" echo " $0 --rollback ${BACKUP_DIR:-/path/to/backup}" } # Handle rollback command if [[ "${1:-}" == "--rollback" ]]; then ROLLBACK_BACKUP="${2:?Backup directory required for rollback}" perform_rollback "${ROLLBACK_BACKUP}" if [[ "${RESTART_WORKERS}" == "true" ]]; then restart_workers "rollback" fi exit 0 fi # Run main main "$@"