Skip to content

Commit 2e7b390

Browse files
committed
feat(scripts): Phase 5 β€” add 10 new production scripts with BATS tests
New scripts: - scripts/backup/db-backup.sh β€” MySQL/PostgreSQL dump + rotation - scripts/ci-cd/terraform-plan-notify.sh β€” terraform plan + Slack summary - scripts/devops/docker-image-prune.sh β€” prune dangling/unused Docker images - scripts/devops/git-cleanup-merged.sh β€” delete merged local/remote branches - scripts/kubernetes/k8s-pod-logs-export.sh β€” export pod logs to files - scripts/kubernetes/service-discovery.sh β€” list k8s services + endpoints - scripts/monitoring/aws-cost-alert.sh β€” AWS Cost Explorer threshold alert - scripts/monitoring/cert-auto-renew.sh β€” certbot renew + web server reload - scripts/notifications/log-aggregator.sh β€” tail multiple logs β†’ single file - scripts/utils/secret-rotation.sh β€” rotate AWS Secrets Manager / Vault secrets Each script follows the established conventions: set -euo pipefail, source lib/utils.sh, usage() without embedded exit, --dry-run support, --help, --quiet-friendly, exit 0/1/2 codes. Tests: 10 BATS test files (265/266 passing; 1 pre-existing /tmp sandbox skip)
1 parent 0cfa1b6 commit 2e7b390

20 files changed

Lines changed: 1704 additions & 0 deletions

β€Žscripts/backup/db-backup.shβ€Ž

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/bin/bash
2+
# db-backup.sh β€” Dump a MySQL or PostgreSQL database and rotate old backups
3+
set -euo pipefail
4+
5+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+
# shellcheck source=../lib/utils.sh
7+
source "${SCRIPT_DIR}/../lib/utils.sh"
8+
9+
DB_TYPE=""
10+
DB_HOST="localhost"
11+
DB_PORT=""
12+
DB_NAME=""
13+
DB_USER=""
14+
DB_PASS=""
15+
OUTPUT_DIR="./db-backups"
16+
KEEP_DAYS=7
17+
DRY_RUN=false
18+
19+
usage() {
20+
cat <<EOF
21+
Usage: $(basename "$0") [options]
22+
23+
Dump a MySQL or PostgreSQL database to a compressed file and rotate backups
24+
older than --keep-days days.
25+
26+
Options:
27+
--type TYPE Database type: mysql or postgres (required)
28+
--host HOST Database host (default: localhost)
29+
--port PORT Database port (default: 3306/5432)
30+
--database NAME Database name to dump (required)
31+
--user USER Database username (env: DB_USER)
32+
--password PASS Database password (env: DB_PASS)
33+
--output-dir DIR Directory for backup files (default: ./db-backups)
34+
--keep-days N Rotate backups older than N days (default: 7)
35+
--dry-run Show what would happen, make no changes
36+
-h, --help Show this help message
37+
38+
Examples:
39+
$(basename "$0") --type mysql --database myapp --user root
40+
$(basename "$0") --type postgres --host db.example.com --database prod --user admin
41+
$(basename "$0") --type mysql --database myapp --keep-days 14 --dry-run
42+
EOF
43+
}
44+
45+
while [[ $# -gt 0 ]]; do
46+
case "$1" in
47+
--type) DB_TYPE="$2"; shift 2 ;;
48+
--host) DB_HOST="$2"; shift 2 ;;
49+
--port) DB_PORT="$2"; shift 2 ;;
50+
--database) DB_NAME="$2"; shift 2 ;;
51+
--user) DB_USER="$2"; shift 2 ;;
52+
--password) DB_PASS="$2"; shift 2 ;;
53+
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
54+
--keep-days) KEEP_DAYS="$2"; shift 2 ;;
55+
--dry-run) DRY_RUN=true; shift ;;
56+
-h|--help) usage; exit 0 ;;
57+
*) log_error "Unknown option: $1"; usage; exit 1 ;;
58+
esac
59+
done
60+
61+
# Fall back to environment variables
62+
DB_USER="${DB_USER:-${DB_USER:-}}"
63+
DB_PASS="${DB_PASS:-${DB_PASS:-}}"
64+
65+
if [[ -z "$DB_TYPE" ]]; then
66+
log_error "A database type (--type mysql|postgres) is required."
67+
usage
68+
exit 1
69+
fi
70+
71+
if [[ "$DB_TYPE" != "mysql" && "$DB_TYPE" != "postgres" ]]; then
72+
log_error "Invalid --type '${DB_TYPE}'. Must be 'mysql' or 'postgres'."
73+
exit 1
74+
fi
75+
76+
if [[ -z "$DB_NAME" ]]; then
77+
log_error "A database name (--database) is required."
78+
usage
79+
exit 1
80+
fi
81+
82+
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
83+
OUTFILE="${OUTPUT_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"
84+
85+
if [[ "$DRY_RUN" == "true" ]]; then
86+
log_info "[dry-run] Would dump ${DB_TYPE} database '${DB_NAME}' β†’ ${OUTFILE}"
87+
log_info "[dry-run] Would rotate backups older than ${KEEP_DAYS} days in '${OUTPUT_DIR}'."
88+
exit 0
89+
fi
90+
91+
mkdir -p "$OUTPUT_DIR"
92+
93+
case "$DB_TYPE" in
94+
mysql)
95+
check_dependency mysqldump
96+
[[ -z "$DB_PORT" ]] && DB_PORT="3306"
97+
DUMP_CMD=(mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER")
98+
[[ -n "$DB_PASS" ]] && DUMP_CMD+=(-p"${DB_PASS}")
99+
DUMP_CMD+=("$DB_NAME")
100+
log_info "Dumping MySQL database '${DB_NAME}'..."
101+
"${DUMP_CMD[@]}" | gzip > "$OUTFILE"
102+
;;
103+
postgres)
104+
check_dependency pg_dump
105+
[[ -z "$DB_PORT" ]] && DB_PORT="5432"
106+
export PGPASSWORD="${DB_PASS}"
107+
log_info "Dumping PostgreSQL database '${DB_NAME}'..."
108+
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" "$DB_NAME" | gzip > "$OUTFILE"
109+
unset PGPASSWORD
110+
;;
111+
esac
112+
113+
log_info "Backup written to: ${OUTFILE}"
114+
115+
# Rotate
116+
log_info "Rotating backups older than ${KEEP_DAYS} day(s)..."
117+
find "$OUTPUT_DIR" -name "${DB_NAME}_*.sql.gz" -mtime +"${KEEP_DAYS}" -print -delete || true
118+
119+
log_info "Database backup complete."
120+
exit 0
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/bin/bash
2+
# terraform-plan-notify.sh β€” Run terraform plan and post a summary to Slack
3+
set -euo pipefail
4+
5+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+
# shellcheck source=../lib/utils.sh
7+
source "${SCRIPT_DIR}/../lib/utils.sh"
8+
9+
WEBHOOK_URL="${TF_SLACK_WEBHOOK:-}"
10+
WORKING_DIR="."
11+
WORKSPACE=""
12+
VAR_FILE=""
13+
DRY_RUN=false
14+
15+
usage() {
16+
cat <<EOF
17+
Usage: $(basename "$0") [options]
18+
19+
Run 'terraform plan' in the specified directory and post a concise summary
20+
(added/changed/destroyed counts) to a Slack webhook.
21+
22+
Options:
23+
--webhook URL Slack incoming webhook URL (env: TF_SLACK_WEBHOOK, required)
24+
--dir PATH Terraform working directory (default: .)
25+
--workspace NAME Terraform workspace to select before planning
26+
--var-file FILE Pass a .tfvars file to terraform plan
27+
--dry-run Run terraform plan but do not post to Slack
28+
-h, --help Show this help message
29+
30+
Examples:
31+
$(basename "$0") --webhook https://hooks.slack.com/... --dir infra/
32+
$(basename "$0") --webhook https://hooks.slack.com/... --workspace staging
33+
$(basename "$0") --dir infra/ --dry-run
34+
EOF
35+
}
36+
37+
while [[ $# -gt 0 ]]; do
38+
case "$1" in
39+
--webhook) WEBHOOK_URL="$2"; shift 2 ;;
40+
--dir) WORKING_DIR="$2"; shift 2 ;;
41+
--workspace) WORKSPACE="$2"; shift 2 ;;
42+
--var-file) VAR_FILE="$2"; shift 2 ;;
43+
--dry-run) DRY_RUN=true; shift ;;
44+
-h|--help) usage; exit 0 ;;
45+
*) log_error "Unknown option: $1"; usage; exit 1 ;;
46+
esac
47+
done
48+
49+
if [[ -z "$WEBHOOK_URL" ]] && [[ "$DRY_RUN" == "false" ]]; then
50+
log_error "A Slack webhook URL is required (--webhook or TF_SLACK_WEBHOOK)."
51+
usage
52+
exit 1
53+
fi
54+
55+
check_dependency terraform
56+
check_dependency jq
57+
58+
if [[ ! -d "$WORKING_DIR" ]]; then
59+
log_error "Working directory not found: ${WORKING_DIR}"
60+
exit 2
61+
fi
62+
63+
cd "$WORKING_DIR"
64+
65+
if [[ -n "$WORKSPACE" ]]; then
66+
log_info "Selecting workspace: ${WORKSPACE}"
67+
terraform workspace select "$WORKSPACE" || terraform workspace new "$WORKSPACE"
68+
fi
69+
70+
# Build plan command as an array β€” always β‰₯2 elements (safe under bash 3.2 set -u)
71+
PLAN_CMD=(terraform plan -no-color)
72+
[[ -n "$VAR_FILE" ]] && PLAN_CMD+=(-var-file="$VAR_FILE")
73+
74+
log_info "Running terraform plan..."
75+
PLAN_OUTPUT=$("${PLAN_CMD[@]}" 2>&1) || {
76+
log_error "terraform plan failed."
77+
echo "$PLAN_OUTPUT" >&2
78+
exit 2
79+
}
80+
81+
# Extract the summary line (e.g. "Plan: 2 to add, 1 to change, 0 to destroy.")
82+
SUMMARY=$(echo "$PLAN_OUTPUT" | grep -E '^Plan:|No changes\.' | tail -1 || echo "Plan complete.")
83+
84+
log_info "Plan summary: ${SUMMARY}"
85+
86+
if [[ "$DRY_RUN" == "true" ]]; then
87+
log_info "[dry-run] Would post to Slack: ${SUMMARY}"
88+
exit 0
89+
fi
90+
91+
HOST=$(hostname)
92+
PAYLOAD=$(jq -n \
93+
--arg summary "$SUMMARY" \
94+
--arg host "$HOST" \
95+
--arg dir "$WORKING_DIR" \
96+
'{text: ("*Terraform Plan* on `" + $host + "` β€” `" + $dir + "`\n" + $summary)}')
97+
98+
curl -fsSL -X POST -H 'Content-type: application/json' \
99+
--data "$PAYLOAD" "$WEBHOOK_URL" || {
100+
log_error "Failed to post Slack notification."
101+
exit 2
102+
}
103+
104+
log_info "Slack notification sent."
105+
exit 0
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/bin/bash
2+
# docker-image-prune.sh β€” Remove dangling and/or aged Docker images
3+
set -euo pipefail
4+
5+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+
# shellcheck source=../lib/utils.sh
7+
source "${SCRIPT_DIR}/../lib/utils.sh"
8+
9+
OLDER_THAN=""
10+
FILTER_LABEL=""
11+
DRY_RUN=false
12+
ALL_UNUSED=false
13+
14+
usage() {
15+
cat <<EOF
16+
Usage: $(basename "$0") [options]
17+
18+
Remove dangling Docker images (not tagged, not referenced by a container).
19+
Optionally also remove unused images older than a given duration.
20+
21+
Options:
22+
--older-than DURATION Remove unused images older than this (e.g. 72h, 30d)
23+
--label KEY=VALUE Only remove images matching this label
24+
--all-unused Remove ALL unused images (not just dangling)
25+
--dry-run Show what would be removed, make no changes
26+
-h, --help Show this help message
27+
28+
Examples:
29+
$(basename "$0") --dry-run
30+
$(basename "$0") --older-than 72h
31+
$(basename "$0") --all-unused --older-than 30d
32+
$(basename "$0") --label env=staging --dry-run
33+
EOF
34+
}
35+
36+
while [[ $# -gt 0 ]]; do
37+
case "$1" in
38+
--older-than) OLDER_THAN="$2"; shift 2 ;;
39+
--label) FILTER_LABEL="$2"; shift 2 ;;
40+
--all-unused) ALL_UNUSED=true; shift ;;
41+
--dry-run) DRY_RUN=true; shift ;;
42+
-h|--help) usage; exit 0 ;;
43+
*) log_error "Unknown option: $1"; usage; exit 1 ;;
44+
esac
45+
done
46+
47+
check_dependency docker
48+
49+
# Build filter args
50+
FILTERS=()
51+
if [[ "$ALL_UNUSED" == "true" ]]; then
52+
FILTERS+=(--filter "dangling=false")
53+
else
54+
FILTERS+=(--filter "dangling=true")
55+
fi
56+
[[ -n "$OLDER_THAN" ]] && FILTERS+=(--filter "until=${OLDER_THAN}")
57+
[[ -n "$FILTER_LABEL" ]] && FILTERS+=(--filter "label=${FILTER_LABEL}")
58+
59+
log_info "Listing images to remove..."
60+
IMAGES=$(docker images -q "${FILTERS[@]}" 2>/dev/null | sort -u) || true
61+
62+
if [[ -z "$IMAGES" ]]; then
63+
log_info "No images match the removal criteria."
64+
exit 0
65+
fi
66+
67+
IMAGE_COUNT=$(echo "$IMAGES" | wc -l | tr -d ' ')
68+
69+
if [[ "$DRY_RUN" == "true" ]]; then
70+
log_info "[dry-run] Would remove ${IMAGE_COUNT} image(s):"
71+
# Show human-readable info for each image
72+
while IFS= read -r img_id; do
73+
docker image inspect --format " {{.ID}} {{.RepoTags}}" "$img_id" 2>/dev/null || echo " ${img_id}"
74+
done <<< "$IMAGES"
75+
exit 0
76+
fi
77+
78+
log_info "Removing ${IMAGE_COUNT} image(s)..."
79+
while IFS= read -r img_id; do
80+
docker rmi "$img_id" && log_info "Removed: ${img_id}" || log_warn "Could not remove: ${img_id}"
81+
done <<< "$IMAGES"
82+
83+
log_info "Docker image prune complete."
84+
exit 0

0 commit comments

Comments
Β (0)