feat: Implement webhook-based deployment for k3s behind NAT

- Replace SSH/kubectl deployment with secure webhook-based approach
- Add comprehensive webhook handler with HMAC signature verification
- Support blue-green deployment strategy for production
- Implement auto-promotion pipeline: dev → staging → prod
- Add health checks using canonical Knative domains only
- Include complete deployment documentation and setup scripts

Changes:
- Updated deploy-dev.yml, deploy-staging.yml, deploy-prod.yml workflows
- Added webhook handler Python script with Flask API
- Created Kubernetes manifests for webhook system deployment
- Added ingress and service configuration for external access
- Created setup script for automated webhook system installation
- Documented complete webhook-based deployment guide

Perfect for k3s clusters behind NAT without direct API access.
This commit is contained in:
Greg
2025-06-30 23:41:53 -07:00
parent 78261efab2
commit 63b53dfc1b
9 changed files with 1509 additions and 382 deletions

View File

@@ -0,0 +1,125 @@
#!/bin/bash
set -e
# Webhook-based Deployment Setup Script for k8s-game-2048
echo "🚀 Setting up webhook-based deployment for k8s-game-2048..."
# Configuration
WEBHOOK_SECRET="${WEBHOOK_SECRET:-$(openssl rand -hex 32)}"
MANIFESTS_PATH="${MANIFESTS_PATH:-/home/administrator/k8s-game-2048/manifests}"
WEBHOOK_DOMAIN="${WEBHOOK_DOMAIN:-webhook.$(hostname -f)}"
echo "📋 Configuration:"
echo " Webhook Secret: ${WEBHOOK_SECRET:0:8}..."
echo " Manifests Path: $MANIFESTS_PATH"
echo " Webhook Domain: $WEBHOOK_DOMAIN"
# Step 1: Create webhook system namespace
echo ""
echo "📦 Creating webhook system namespace..."
kubectl create namespace webhook-system --dry-run=client -o yaml | kubectl apply -f -
# Step 2: Create webhook secret
echo "🔐 Creating webhook secret..."
kubectl create secret generic webhook-secret \
--from-literal=webhook-secret="$WEBHOOK_SECRET" \
-n webhook-system \
--dry-run=client -o yaml | kubectl apply -f -
# Step 3: Update webhook handler manifests with correct paths
echo "🔧 Updating webhook handler manifests..."
sed -i "s|/home/administrator/k8s-game-2048/manifests|$MANIFESTS_PATH|g" manifests/webhook/webhook-handler.yaml
sed -i "s|webhook.yourdomain.com|$WEBHOOK_DOMAIN|g" manifests/webhook/webhook-ingress.yaml
# Step 4: Deploy webhook handler script ConfigMap
echo "📜 Deploying webhook handler script..."
kubectl apply -f manifests/webhook/webhook-script-configmap.yaml
# Step 5: Deploy webhook handler
echo "🤖 Deploying webhook handler..."
kubectl apply -f manifests/webhook/webhook-handler.yaml
# Step 6: Deploy ingress (optional)
if [ "$DEPLOY_INGRESS" = "true" ]; then
echo "🌐 Deploying webhook ingress..."
kubectl apply -f manifests/webhook/webhook-ingress.yaml
else
echo "⏭️ Skipping ingress deployment (set DEPLOY_INGRESS=true to enable)"
fi
# Step 7: Wait for deployment to be ready
echo "⏳ Waiting for webhook handler to be ready..."
kubectl wait --for=condition=available deployment/webhook-handler -n webhook-system --timeout=300s
# Step 8: Get service information
echo ""
echo "📊 Webhook handler status:"
kubectl get pods -n webhook-system -l app=webhook-handler
echo ""
echo "🌐 Service endpoints:"
kubectl get svc -n webhook-system
# Step 9: Test webhook handler
echo ""
echo "🧪 Testing webhook handler..."
WEBHOOK_POD=$(kubectl get pods -n webhook-system -l app=webhook-handler -o jsonpath='{.items[0].metadata.name}')
if [ -n "$WEBHOOK_POD" ]; then
echo "Testing health endpoint..."
kubectl port-forward -n webhook-system pod/$WEBHOOK_POD 8080:8080 &
KUBECTL_PID=$!
sleep 5
if curl -s http://localhost:8080/health | grep -q "healthy"; then
echo "✅ Webhook handler health check passed!"
else
echo "⚠️ Webhook handler health check failed"
fi
kill $KUBECTL_PID 2>/dev/null || true
fi
# Step 10: Display setup information
echo ""
echo "🎉 Webhook-based deployment setup completed!"
echo ""
echo "📝 Next steps:"
echo "1. Configure GitHub repository secrets:"
echo " - WEBHOOK_SECRET: $WEBHOOK_SECRET"
echo " - DEV_WEBHOOK_URL: https://$WEBHOOK_DOMAIN/webhook/deploy"
echo " - STAGING_WEBHOOK_URL: https://$WEBHOOK_DOMAIN/webhook/deploy"
echo " - PROD_WEBHOOK_URL: https://$WEBHOOK_DOMAIN/webhook/deploy"
echo " - KNATIVE_DOMAIN: your-knative-domain.com"
echo ""
echo "2. Expose webhook handler externally:"
if [ "$DEPLOY_INGRESS" != "true" ]; then
echo " # Option A: Use port-forward for testing"
echo " kubectl port-forward -n webhook-system svc/webhook-handler-external 8080:80"
echo ""
echo " # Option B: Get LoadBalancer IP (if available)"
echo " kubectl get svc webhook-handler-external -n webhook-system"
echo ""
echo " # Option C: Deploy ingress with your domain"
echo " DEPLOY_INGRESS=true WEBHOOK_DOMAIN=your-domain.com ./scripts/setup-webhook-deployment.sh"
fi
echo ""
echo "3. Test webhook endpoint:"
echo " curl -X POST https://$WEBHOOK_DOMAIN/webhook/deploy \\"
echo " -H 'Content-Type: application/json' \\"
echo " -H 'X-Signature-SHA256: sha256=SIGNATURE' \\"
echo " -d '{\"environment\":\"dev\",\"image\":\"nginx:latest\",\"namespace\":\"default\",\"service_name\":\"test\"}'"
echo ""
echo "4. Push code changes to trigger automated deployment!"
# Output webhook secret for GitHub configuration
echo ""
echo "🔑 GitHub Secrets Configuration:"
echo "===============================|"
echo "SECRET NAME | SECRET VALUE"
echo "===============================|"
echo "WEBHOOK_SECRET | $WEBHOOK_SECRET"
echo "DEV_WEBHOOK_URL | https://$WEBHOOK_DOMAIN/webhook/deploy"
echo "STAGING_WEBHOOK_URL | https://$WEBHOOK_DOMAIN/webhook/deploy"
echo "PROD_WEBHOOK_URL | https://$WEBHOOK_DOMAIN/webhook/deploy"
echo "KNATIVE_DOMAIN | your-knative-domain.com"
echo "===============================|"

281
scripts/webhook-handler.py Normal file
View File

@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
Webhook deployment handler for k8s-game-2048
Receives webhook requests from GitHub Actions and deploys to k3s cluster
"""
import hashlib
import hmac
import json
import logging
import os
import subprocess
import time
from datetime import datetime
from flask import Flask, request, jsonify
# Configuration
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', 'change-me-in-production')
MANIFESTS_PATH = os.environ.get('MANIFESTS_PATH', '/app/manifests')
def verify_signature(payload, signature):
"""Verify HMAC signature from GitHub webhook"""
if not signature:
return False
expected = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
def run_command(cmd, **kwargs):
"""Run shell command with logging"""
logger.info(f"Running command: {' '.join(cmd)}")
try:
result = subprocess.run(cmd, check=True, capture_output=True, text=True, **kwargs)
logger.info(f"Command output: {result.stdout}")
return result
except subprocess.CalledProcessError as e:
logger.error(f"Command failed: {e.stderr}")
raise
def pull_image(image):
"""Pull Docker image to ensure it's available"""
logger.info(f"Pulling image: {image}")
run_command(['docker', 'pull', image])
def apply_manifests(environment):
"""Apply Kubernetes manifests for environment"""
manifest_dir = f"{MANIFESTS_PATH}/{environment}"
logger.info(f"Applying manifests from: {manifest_dir}")
if not os.path.exists(manifest_dir):
raise FileNotFoundError(f"Manifest directory not found: {manifest_dir}")
run_command(['kubectl', 'apply', '-f', manifest_dir])
def update_service_image(service_name, namespace, image):
"""Update Knative service with new image"""
logger.info(f"Updating service {service_name} in namespace {namespace} with image {image}")
patch = {
"spec": {
"template": {
"spec": {
"containers": [{
"image": image,
"imagePullPolicy": "Always"
}]
}
}
}
}
run_command([
'kubectl', 'patch', 'ksvc', service_name,
'-n', namespace,
'--type', 'merge',
'-p', json.dumps(patch)
])
def wait_for_service_ready(service_name, namespace, timeout=300):
"""Wait for Knative service to be ready"""
logger.info(f"Waiting for service {service_name} to be ready...")
run_command([
'kubectl', 'wait', '--for=condition=Ready',
f'ksvc/{service_name}',
'-n', namespace,
f'--timeout={timeout}s'
])
def implement_blue_green_deployment(service_name, namespace, traffic_split):
"""Implement blue-green deployment with gradual traffic shifting"""
if not traffic_split:
return
logger.info("Starting blue-green deployment...")
# Get the latest revision
result = run_command([
'kubectl', 'get', 'ksvc', service_name,
'-n', namespace,
'-o', 'jsonpath={.status.latestReadyRevisionName}'
])
latest_revision = result.stdout.strip()
if not latest_revision:
logger.warning("No latest revision found, skipping traffic split")
return
# Phase 1: Initial traffic (e.g., 10%)
initial_percent = traffic_split.get('initial', 10)
logger.info(f"Phase 1: Routing {initial_percent}% traffic to new revision")
traffic_patch = {
"spec": {
"traffic": [
{"revisionName": latest_revision, "percent": initial_percent},
{"latestRevision": False, "percent": 100 - initial_percent}
]
}
}
run_command([
'kubectl', 'patch', 'ksvc', service_name,
'-n', namespace,
'--type', 'merge',
'-p', json.dumps(traffic_patch)
])
time.sleep(60) # Wait 1 minute
# Phase 2: Intermediate traffic (e.g., 50%)
intermediate_percent = traffic_split.get('intermediate', 50)
logger.info(f"Phase 2: Routing {intermediate_percent}% traffic to new revision")
traffic_patch["spec"]["traffic"] = [
{"revisionName": latest_revision, "percent": intermediate_percent},
{"latestRevision": False, "percent": 100 - intermediate_percent}
]
run_command([
'kubectl', 'patch', 'ksvc', service_name,
'-n', namespace,
'--type', 'merge',
'-p', json.dumps(traffic_patch)
])
time.sleep(60) # Wait 1 minute
# Phase 3: Full traffic (100%)
logger.info("Phase 3: Routing 100% traffic to new revision")
traffic_patch["spec"]["traffic"] = [
{"latestRevision": True, "percent": 100}
]
run_command([
'kubectl', 'patch', 'ksvc', service_name,
'-n', namespace,
'--type', 'merge',
'-p', json.dumps(traffic_patch)
])
@app.route('/webhook/deploy', methods=['POST'])
def deploy():
"""Main webhook endpoint for deployments"""
try:
# Verify signature
signature = request.headers.get('X-Signature-SHA256')
if not verify_signature(request.data, signature):
logger.warning("Invalid webhook signature")
return jsonify({"error": "Invalid signature"}), 401
# Parse payload
data = request.json
if not data:
return jsonify({"error": "No JSON payload"}), 400
# Extract deployment details
environment = data.get('environment')
image = data.get('image')
namespace = data.get('namespace')
service_name = data.get('service_name')
deployment_id = data.get('deployment_id')
deployment_strategy = data.get('deployment_strategy', 'rolling')
traffic_split = data.get('traffic_split')
# Validate required fields
required_fields = ['environment', 'image', 'namespace', 'service_name']
missing_fields = [field for field in required_fields if not data.get(field)]
if missing_fields:
return jsonify({"error": f"Missing required fields: {missing_fields}"}), 400
logger.info(f"Starting deployment {deployment_id}")
logger.info(f"Environment: {environment}")
logger.info(f"Image: {image}")
logger.info(f"Namespace: {namespace}")
logger.info(f"Service: {service_name}")
logger.info(f"Strategy: {deployment_strategy}")
# Step 1: Pull the Docker image
pull_image(image)
# Step 2: Apply manifests
apply_manifests(environment)
# Step 3: Update service image
update_service_image(service_name, namespace, image)
# Step 4: Wait for service to be ready
wait_for_service_ready(service_name, namespace)
# Step 5: Apply deployment strategy
if deployment_strategy == 'blue-green' and traffic_split:
implement_blue_green_deployment(service_name, namespace, traffic_split)
logger.info(f"Deployment {deployment_id} completed successfully")
return jsonify({
"status": "success",
"deployment_id": deployment_id,
"timestamp": datetime.utcnow().isoformat(),
"environment": environment,
"image": image,
"strategy": deployment_strategy
})
except FileNotFoundError as e:
logger.error(f"File not found: {e}")
return jsonify({"error": str(e)}), 404
except subprocess.CalledProcessError as e:
logger.error(f"Command failed: {e}")
return jsonify({"error": f"Command failed: {e.stderr}"}), 500
except Exception as e:
logger.error(f"Unexpected error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({
"status": "healthy",
"timestamp": datetime.utcnow().isoformat(),
"version": "1.0.0"
})
@app.route('/status', methods=['GET'])
def status():
"""Status endpoint with cluster information"""
try:
# Get cluster info
result = run_command(['kubectl', 'cluster-info'])
cluster_info = result.stdout
# Get webhook handler pod info
result = run_command(['kubectl', 'get', 'pods', '-n', 'webhook-system', '--selector=app=webhook-handler'])
pod_info = result.stdout
return jsonify({
"status": "operational",
"timestamp": datetime.utcnow().isoformat(),
"cluster_info": cluster_info,
"pod_info": pod_info
})
except Exception as e:
return jsonify({
"status": "error",
"timestamp": datetime.utcnow().isoformat(),
"error": str(e)
})
if __name__ == '__main__':
# Verify environment
logger.info("Starting webhook deployment handler...")
logger.info(f"Webhook secret configured: {'Yes' if WEBHOOK_SECRET != 'change-me-in-production' else 'No (using default)'}")
logger.info(f"Manifests path: {MANIFESTS_PATH}")
# Start the Flask app
app.run(host='0.0.0.0', port=8080, debug=False)