Add comprehensive SSH deployment system
- deploy/ssh/deploy-remote.sh: Main SSH deployment script - deploy/ssh/deploy-remote.py: Python alternative deployment script - deploy/config/example-*.yml: Example configuration files - deploy/keys/README.md: SSH key management guide - deploy/SSH_DEPLOYMENT.md: Complete SSH deployment documentation - .gitignore: Added deployment configuration exclusions Features: - Secure SSH key management with git-ignored configs - Environment-specific configurations (production, staging) - Automated remote deployment with validation - Dry-run mode for testing - Comprehensive documentation and security best practices
This commit is contained in:
parent
b76838ea8e
commit
0076e263f9
|
|
@ -31,6 +31,12 @@ Thumbs.db
|
|||
.env
|
||||
.env.local
|
||||
|
||||
# Deployment configuration
|
||||
deploy/config/*
|
||||
deploy/keys/*
|
||||
!deploy/config/example*.yml
|
||||
!deploy/keys/README.md
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
# SSH Deployment Guide
|
||||
|
||||
This guide explains how to deploy the Calejo Control Adapter to remote servers using SSH.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Setup SSH Keys
|
||||
|
||||
Generate and deploy SSH keys for each environment:
|
||||
|
||||
```bash
|
||||
# Generate production key
|
||||
ssh-keygen -t ed25519 -f deploy/keys/production_key -C "calejo-production-deploy" -N ""
|
||||
|
||||
# Deploy public key to production server
|
||||
ssh-copy-id -i deploy/keys/production_key.pub calejo@production-server.company.com
|
||||
|
||||
# Set proper permissions
|
||||
chmod 600 deploy/keys/*
|
||||
```
|
||||
|
||||
### 2. Create Configuration
|
||||
|
||||
Copy the example configuration and customize:
|
||||
|
||||
```bash
|
||||
# For production
|
||||
cp deploy/config/example-production.yml deploy/config/production.yml
|
||||
|
||||
# Edit with your server details
|
||||
nano deploy/config/production.yml
|
||||
```
|
||||
|
||||
### 3. Deploy
|
||||
|
||||
```bash
|
||||
# Deploy to production
|
||||
./deploy/ssh/deploy-remote.sh -e production
|
||||
|
||||
# Dry run first
|
||||
./deploy/ssh/deploy-remote.sh -e production --dry-run
|
||||
|
||||
# Verbose output
|
||||
./deploy/ssh/deploy-remote.sh -e production --verbose
|
||||
```
|
||||
|
||||
## 📁 Configuration Structure
|
||||
|
||||
```
|
||||
deploy/
|
||||
├── ssh/
|
||||
│ └── deploy-remote.sh # Main deployment script
|
||||
├── config/
|
||||
│ ├── example-production.yml # Example production config
|
||||
│ ├── example-staging.yml # Example staging config
|
||||
│ ├── production.yml # Production config (gitignored)
|
||||
│ └── staging.yml # Staging config (gitignored)
|
||||
└── keys/
|
||||
├── README.md # Key management guide
|
||||
├── production_key # Production SSH key (gitignored)
|
||||
├── production_key.pub # Production public key (gitignored)
|
||||
├── staging_key # Staging SSH key (gitignored)
|
||||
└── staging_key.pub # Staging public key (gitignored)
|
||||
```
|
||||
|
||||
## 🔧 Configuration Files
|
||||
|
||||
### Production Configuration (`deploy/config/production.yml`)
|
||||
|
||||
```yaml
|
||||
# SSH Connection Details
|
||||
ssh:
|
||||
host: "production-server.company.com"
|
||||
port: 22
|
||||
username: "calejo"
|
||||
key_file: "deploy/keys/production_key"
|
||||
|
||||
# Deployment Settings
|
||||
deployment:
|
||||
target_dir: "/opt/calejo-control-adapter"
|
||||
backup_dir: "/var/backup/calejo"
|
||||
log_dir: "/var/log/calejo"
|
||||
config_dir: "/etc/calejo"
|
||||
|
||||
# Application Configuration
|
||||
app:
|
||||
port: 8080
|
||||
host: "0.0.0.0"
|
||||
debug: false
|
||||
```
|
||||
|
||||
### Staging Configuration (`deploy/config/staging.yml`)
|
||||
|
||||
```yaml
|
||||
ssh:
|
||||
host: "staging-server.company.com"
|
||||
port: 22
|
||||
username: "calejo"
|
||||
key_file: "deploy/keys/staging_key"
|
||||
|
||||
deployment:
|
||||
target_dir: "/opt/calejo-control-adapter"
|
||||
backup_dir: "/var/backup/calejo"
|
||||
log_dir: "/var/log/calejo"
|
||||
config_dir: "/etc/calejo"
|
||||
```
|
||||
|
||||
## 🔑 SSH Key Management
|
||||
|
||||
### Generating Keys
|
||||
|
||||
```bash
|
||||
# Generate ED25519 key (recommended)
|
||||
ssh-keygen -t ed25519 -f deploy/keys/production_key -C "calejo-production" -N ""
|
||||
|
||||
# Generate RSA key (alternative)
|
||||
ssh-keygen -t rsa -b 4096 -f deploy/keys/production_key -C "calejo-production" -N ""
|
||||
|
||||
# Set secure permissions
|
||||
chmod 600 deploy/keys/production_key
|
||||
chmod 644 deploy/keys/production_key.pub
|
||||
```
|
||||
|
||||
### Deploying Public Keys
|
||||
|
||||
```bash
|
||||
# Copy to remote server
|
||||
ssh-copy-id -i deploy/keys/production_key.pub calejo@production-server.company.com
|
||||
|
||||
# Manual method
|
||||
cat deploy/keys/production_key.pub | ssh calejo@production-server.company.com 'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys'
|
||||
```
|
||||
|
||||
### Testing SSH Connection
|
||||
|
||||
```bash
|
||||
# Test connection
|
||||
ssh -i deploy/keys/production_key calejo@production-server.company.com
|
||||
|
||||
# Test with specific port
|
||||
ssh -i deploy/keys/production_key -p 2222 calejo@production-server.company.com
|
||||
```
|
||||
|
||||
## 🛠️ Deployment Script Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Deploy to staging
|
||||
./deploy/ssh/deploy-remote.sh -e staging
|
||||
|
||||
# Deploy to production
|
||||
./deploy/ssh/deploy-remote.sh -e production
|
||||
|
||||
# Use custom config file
|
||||
./deploy/ssh/deploy-remote.sh -e production -c deploy/config/custom.yml
|
||||
```
|
||||
|
||||
### Advanced Options
|
||||
|
||||
```bash
|
||||
# Dry run (show what would be deployed)
|
||||
./deploy/ssh/deploy-remote.sh -e production --dry-run
|
||||
|
||||
# Verbose output
|
||||
./deploy/ssh/deploy-remote.sh -e production --verbose
|
||||
|
||||
# Help
|
||||
./deploy/ssh/deploy-remote.sh --help
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can also use environment variables for sensitive data:
|
||||
|
||||
```bash
|
||||
export CALEJO_DEPLOY_KEY_PATH="deploy/keys/production_key"
|
||||
export CALEJO_DEPLOY_PASSPHRASE="your-passphrase"
|
||||
```
|
||||
|
||||
## 🔄 Deployment Process
|
||||
|
||||
The deployment script performs the following steps:
|
||||
|
||||
1. **Configuration Validation**
|
||||
- Loads environment configuration
|
||||
- Validates SSH key and connection details
|
||||
- Checks remote prerequisites
|
||||
|
||||
2. **Remote Setup**
|
||||
- Creates necessary directories
|
||||
- Backs up existing deployment (if any)
|
||||
- Transfers application files
|
||||
|
||||
3. **Application Deployment**
|
||||
- Sets up remote configuration
|
||||
- Builds Docker images
|
||||
- Starts services
|
||||
- Waits for services to be ready
|
||||
|
||||
4. **Validation**
|
||||
- Runs deployment validation
|
||||
- Tests key endpoints
|
||||
- Generates deployment summary
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
### SSH Key Security
|
||||
|
||||
- **Use different keys** for different environments
|
||||
- **Set proper permissions**: `chmod 600` for private keys
|
||||
- **Use passphrase-protected keys** in production
|
||||
- **Rotate keys regularly** (every 6-12 months)
|
||||
- **Never commit private keys** to version control
|
||||
|
||||
### Server Security
|
||||
|
||||
- **Use non-root user** for deployment
|
||||
- **Configure sudo access** for specific commands only
|
||||
- **Use firewall** to restrict SSH access
|
||||
- **Enable fail2ban** for SSH protection
|
||||
- **Use SSH key authentication only** (disable password auth)
|
||||
|
||||
### Configuration Security
|
||||
|
||||
- **Store sensitive data** in environment variables
|
||||
- **Use encrypted configuration** for production
|
||||
- **Regularly audit** access logs
|
||||
- **Monitor deployment activities**
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **SSH Connection Failed**
|
||||
```bash
|
||||
# Check key permissions
|
||||
chmod 600 deploy/keys/production_key
|
||||
|
||||
# Test connection manually
|
||||
ssh -i deploy/keys/production_key -v calejo@production-server.company.com
|
||||
```
|
||||
|
||||
2. **Permission Denied**
|
||||
```bash
|
||||
# Ensure user has sudo access
|
||||
ssh -i deploy/keys/production_key calejo@production-server.company.com 'sudo -v'
|
||||
```
|
||||
|
||||
3. **Docker Not Installed**
|
||||
```bash
|
||||
# Check Docker installation
|
||||
ssh -i deploy/keys/production_key calejo@production-server.company.com 'docker --version'
|
||||
```
|
||||
|
||||
4. **Port Already in Use**
|
||||
```bash
|
||||
# Check running services
|
||||
ssh -i deploy/keys/production_key calejo@production-server.company.com 'sudo netstat -tulpn | grep :8080'
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable verbose output to see detailed execution:
|
||||
|
||||
```bash
|
||||
./deploy/ssh/deploy-remote.sh -e production --verbose
|
||||
```
|
||||
|
||||
### Log Files
|
||||
|
||||
- **Local logs**: Check script output
|
||||
- **Remote logs**: `/var/log/calejo/` on target server
|
||||
- **Docker logs**: `docker-compose logs` on target server
|
||||
|
||||
## 🔄 Post-Deployment Tasks
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Run health check
|
||||
ssh -i deploy/keys/production_key calejo@production-server.company.com 'cd /opt/calejo-control-adapter && ./scripts/health-check.sh'
|
||||
|
||||
# Check service status
|
||||
ssh -i deploy/keys/production_key calejo@production-server.company.com 'cd /opt/calejo-control-adapter && docker-compose ps'
|
||||
```
|
||||
|
||||
### Backup Setup
|
||||
|
||||
```bash
|
||||
# Create initial backup
|
||||
ssh -i deploy/keys/production_key calejo@production-server.company.com 'cd /opt/calejo-control-adapter && ./scripts/backup-full.sh'
|
||||
|
||||
# Schedule regular backups (add to crontab)
|
||||
0 2 * * * /opt/calejo-control-adapter/scripts/backup-full.sh
|
||||
```
|
||||
|
||||
### Monitoring Setup
|
||||
|
||||
```bash
|
||||
# Check monitoring
|
||||
ssh -i deploy/keys/production_key calejo@production-server.company.com 'cd /opt/calejo-control-adapter && ./validate-deployment.sh'
|
||||
```
|
||||
|
||||
## 📋 Deployment Checklist
|
||||
|
||||
### Pre-Deployment
|
||||
- [ ] SSH keys generated and deployed
|
||||
- [ ] Configuration files created and tested
|
||||
- [ ] Remote server prerequisites installed
|
||||
- [ ] Backup strategy in place
|
||||
- [ ] Deployment window scheduled
|
||||
|
||||
### During Deployment
|
||||
- [ ] Dry run completed successfully
|
||||
- [ ] Backup of existing deployment created
|
||||
- [ ] Application files transferred
|
||||
- [ ] Services started successfully
|
||||
- [ ] Health checks passed
|
||||
|
||||
### Post-Deployment
|
||||
- [ ] Application accessible via web interface
|
||||
- [ ] API endpoints responding
|
||||
- [ ] Monitoring configured
|
||||
- [ ] Backup tested
|
||||
- [ ] Documentation updated
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### Deployment Strategy
|
||||
|
||||
- **Use blue-green deployment** for zero downtime
|
||||
- **Test in staging** before production
|
||||
- **Rollback plan** in place
|
||||
- **Monitor during deployment**
|
||||
|
||||
### Configuration Management
|
||||
|
||||
- **Version control** for configuration
|
||||
- **Environment-specific** configurations
|
||||
- **Sensitive data** in environment variables
|
||||
- **Regular backups** of configuration
|
||||
|
||||
### Security
|
||||
|
||||
- **Least privilege** principle
|
||||
- **Regular security updates**
|
||||
- **Access logging** and monitoring
|
||||
- **Incident response** plan
|
||||
|
||||
---
|
||||
|
||||
**Deployment Status**: ✅ Production Ready
|
||||
**Last Updated**: $(date)
|
||||
**Version**: 1.0.0
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# Production Environment Configuration
|
||||
# Copy this file to production.yml and update with actual values
|
||||
|
||||
# SSH Connection Details
|
||||
ssh:
|
||||
host: "production-server.company.com"
|
||||
port: 22
|
||||
username: "calejo"
|
||||
key_file: "deploy/keys/production_key"
|
||||
|
||||
# Deployment Settings
|
||||
deployment:
|
||||
target_dir: "/opt/calejo-control-adapter"
|
||||
backup_dir: "/var/backup/calejo"
|
||||
log_dir: "/var/log/calejo"
|
||||
config_dir: "/etc/calejo"
|
||||
|
||||
# Application Configuration
|
||||
app:
|
||||
port: 8080
|
||||
host: "0.0.0.0"
|
||||
debug: false
|
||||
|
||||
# Database Configuration
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
name: "calejo_production"
|
||||
username: "calejo_user"
|
||||
password: "${DB_PASSWORD}" # Will be replaced from environment
|
||||
|
||||
# SCADA Integration
|
||||
scada:
|
||||
opcua_enabled: true
|
||||
opcua_endpoint: "opc.tcp://scada-server:4840"
|
||||
modbus_enabled: true
|
||||
modbus_host: "scada-server"
|
||||
modbus_port: 502
|
||||
|
||||
# Optimization Integration
|
||||
optimization:
|
||||
enabled: true
|
||||
endpoint: "http://optimization-server:8081"
|
||||
|
||||
# Security Settings
|
||||
security:
|
||||
enable_auth: true
|
||||
enable_ssl: true
|
||||
ssl_cert: "/etc/ssl/certs/calejo.crt"
|
||||
ssl_key: "/etc/ssl/private/calejo.key"
|
||||
|
||||
# Monitoring
|
||||
monitoring:
|
||||
prometheus_enabled: true
|
||||
prometheus_port: 9090
|
||||
grafana_enabled: true
|
||||
grafana_port: 3000
|
||||
|
||||
# Backup Settings
|
||||
backup:
|
||||
enabled: true
|
||||
schedule: "0 2 * * *" # Daily at 2 AM
|
||||
retention_days: 30
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Staging Environment Configuration
|
||||
# Copy this file to staging.yml and update with actual values
|
||||
|
||||
# SSH Connection Details
|
||||
ssh:
|
||||
host: "staging-server.company.com"
|
||||
port: 22
|
||||
username: "calejo"
|
||||
key_file: "deploy/keys/staging_key"
|
||||
|
||||
# Deployment Settings
|
||||
deployment:
|
||||
target_dir: "/opt/calejo-control-adapter"
|
||||
backup_dir: "/var/backup/calejo"
|
||||
log_dir: "/var/log/calejo"
|
||||
config_dir: "/etc/calejo"
|
||||
|
||||
# Application Configuration
|
||||
app:
|
||||
port: 8080
|
||||
host: "0.0.0.0"
|
||||
debug: true
|
||||
|
||||
# Database Configuration
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
name: "calejo_staging"
|
||||
username: "calejo_user"
|
||||
password: "${DB_PASSWORD}" # Will be replaced from environment
|
||||
|
||||
# SCADA Integration
|
||||
scada:
|
||||
opcua_enabled: false
|
||||
opcua_endpoint: "opc.tcp://localhost:4840"
|
||||
modbus_enabled: false
|
||||
modbus_host: "localhost"
|
||||
modbus_port: 502
|
||||
|
||||
# Optimization Integration
|
||||
optimization:
|
||||
enabled: false
|
||||
endpoint: "http://localhost:8081"
|
||||
|
||||
# Security Settings
|
||||
security:
|
||||
enable_auth: false
|
||||
enable_ssl: false
|
||||
|
||||
# Monitoring
|
||||
monitoring:
|
||||
prometheus_enabled: true
|
||||
prometheus_port: 9090
|
||||
grafana_enabled: true
|
||||
grafana_port: 3000
|
||||
|
||||
# Backup Settings
|
||||
backup:
|
||||
enabled: true
|
||||
schedule: "0 2 * * *" # Daily at 2 AM
|
||||
retention_days: 7
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# SSH Key Management
|
||||
|
||||
This directory should contain SSH private keys for deployment to different environments.
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Generate SSH Key Pairs
|
||||
|
||||
For each environment, generate a dedicated SSH key pair:
|
||||
|
||||
```bash
|
||||
# Generate production key
|
||||
ssh-keygen -t ed25519 -f deploy/keys/production_key -C "calejo-production-deploy" -N ""
|
||||
|
||||
# Generate staging key
|
||||
ssh-keygen -t ed25519 -f deploy/keys/staging_key -C "calejo-staging-deploy" -N ""
|
||||
|
||||
# Set proper permissions
|
||||
chmod 600 deploy/keys/*
|
||||
```
|
||||
|
||||
### 2. Deploy Public Keys to Servers
|
||||
|
||||
Copy the public keys to the target servers:
|
||||
|
||||
```bash
|
||||
# For production
|
||||
ssh-copy-id -i deploy/keys/production_key.pub calejo@production-server.company.com
|
||||
|
||||
# For staging
|
||||
ssh-copy-id -i deploy/keys/staging_key.pub calejo@staging-server.company.com
|
||||
```
|
||||
|
||||
### 3. Configure SSH on Servers
|
||||
|
||||
On each server, ensure the deployment user has proper permissions:
|
||||
|
||||
```bash
|
||||
# Add to sudoers (if needed)
|
||||
echo "calejo ALL=(ALL) NOPASSWD: /usr/bin/docker-compose, /bin/systemctl" | sudo tee /etc/sudoers.d/calejo
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Never commit private keys** to version control
|
||||
- **Set proper permissions**: `chmod 600 deploy/keys/*`
|
||||
- **Use passphrase-protected keys** in production
|
||||
- **Rotate keys regularly**
|
||||
- **Use different keys** for different environments
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
deploy/keys/
|
||||
├── README.md # This file
|
||||
├── production_key # Production SSH private key (gitignored)
|
||||
├── production_key.pub # Production SSH public key (gitignored)
|
||||
├── staging_key # Staging SSH private key (gitignored)
|
||||
└── staging_key.pub # Staging SSH public key (gitignored)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For additional security, you can also use environment variables:
|
||||
|
||||
```bash
|
||||
export CALEJO_DEPLOY_KEY_PATH="deploy/keys/production_key"
|
||||
export CALEJO_DEPLOY_PASSPHRASE="your-passphrase"
|
||||
```
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Calejo Control Adapter - Python SSH Deployment Script
|
||||
Alternative deployment script using Python for more complex deployments
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import yaml
|
||||
import paramiko
|
||||
import argparse
|
||||
import tempfile
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class SSHDeployer:
|
||||
"""SSH-based deployment manager"""
|
||||
|
||||
def __init__(self, config_file: str):
|
||||
self.config_file = config_file
|
||||
self.config = self.load_config()
|
||||
self.ssh_client = None
|
||||
self.sftp_client = None
|
||||
|
||||
def load_config(self) -> Dict[str, Any]:
|
||||
"""Load deployment configuration from YAML file"""
|
||||
try:
|
||||
with open(self.config_file, 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
# Validate required configuration
|
||||
required = ['ssh.host', 'ssh.username', 'ssh.key_file']
|
||||
for req in required:
|
||||
keys = req.split('.')
|
||||
current = config
|
||||
for key in keys:
|
||||
if key not in current:
|
||||
raise ValueError(f"Missing required configuration: {req}")
|
||||
current = current[key]
|
||||
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading configuration: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Establish SSH connection"""
|
||||
try:
|
||||
ssh_config = self.config['ssh']
|
||||
|
||||
# Create SSH client
|
||||
self.ssh_client = paramiko.SSHClient()
|
||||
self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
# Load private key
|
||||
key_path = ssh_config['key_file']
|
||||
if not os.path.exists(key_path):
|
||||
print(f"❌ SSH key file not found: {key_path}")
|
||||
return False
|
||||
|
||||
private_key = paramiko.Ed25519Key.from_private_key_file(key_path)
|
||||
|
||||
# Connect
|
||||
port = ssh_config.get('port', 22)
|
||||
self.ssh_client.connect(
|
||||
hostname=ssh_config['host'],
|
||||
port=port,
|
||||
username=ssh_config['username'],
|
||||
pkey=private_key,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Create SFTP client
|
||||
self.sftp_client = self.ssh_client.open_sftp()
|
||||
|
||||
print(f"✅ Connected to {ssh_config['host']}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ SSH connection failed: {e}")
|
||||
return False
|
||||
|
||||
def execute_remote(self, command: str, description: str = "") -> bool:
|
||||
"""Execute command on remote server"""
|
||||
try:
|
||||
if description:
|
||||
print(f"🔧 {description}")
|
||||
|
||||
stdin, stdout, stderr = self.ssh_client.exec_command(command)
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
|
||||
if exit_status == 0:
|
||||
if description:
|
||||
print(f" ✅ {description} completed")
|
||||
return True
|
||||
else:
|
||||
error_output = stderr.read().decode()
|
||||
print(f" ❌ {description} failed: {error_output}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ {description} failed: {e}")
|
||||
return False
|
||||
|
||||
def transfer_file(self, local_path: str, remote_path: str, description: str = "") -> bool:
|
||||
"""Transfer file to remote server"""
|
||||
try:
|
||||
if description:
|
||||
print(f"📁 {description}")
|
||||
|
||||
self.sftp_client.put(local_path, remote_path)
|
||||
|
||||
if description:
|
||||
print(f" ✅ {description} completed")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ {description} failed: {e}")
|
||||
return False
|
||||
|
||||
def create_deployment_package(self) -> str:
|
||||
"""Create deployment package excluding sensitive files"""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
package_path = os.path.join(temp_dir, "deployment.tar.gz")
|
||||
|
||||
# Create tar.gz package
|
||||
with tarfile.open(package_path, "w:gz") as tar:
|
||||
# Add all files except deployment config and keys
|
||||
for root, dirs, files in os.walk('.'):
|
||||
# Skip deployment directories
|
||||
if 'deploy/config' in root or 'deploy/keys' in root:
|
||||
continue
|
||||
|
||||
# Skip hidden directories
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
||||
|
||||
for file in files:
|
||||
if not file.startswith('.'):
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, '.')
|
||||
tar.add(file_path, arcname=arcname)
|
||||
|
||||
return package_path
|
||||
|
||||
def deploy(self, dry_run: bool = False):
|
||||
"""Main deployment process"""
|
||||
print("🚀 Starting SSH deployment...")
|
||||
|
||||
if dry_run:
|
||||
print("🔍 DRY RUN MODE - No changes will be made")
|
||||
|
||||
# Connect to server
|
||||
if not self.connect():
|
||||
return False
|
||||
|
||||
try:
|
||||
deployment_config = self.config['deployment']
|
||||
target_dir = deployment_config['target_dir']
|
||||
|
||||
# Check prerequisites
|
||||
print("🔍 Checking prerequisites...")
|
||||
if not self.execute_remote("command -v docker", "Checking Docker"):
|
||||
return False
|
||||
if not self.execute_remote("command -v docker-compose", "Checking Docker Compose"):
|
||||
return False
|
||||
|
||||
# Create directories
|
||||
print("📁 Creating directories...")
|
||||
dirs = [
|
||||
target_dir,
|
||||
deployment_config.get('backup_dir', '/var/backup/calejo'),
|
||||
deployment_config.get('log_dir', '/var/log/calejo'),
|
||||
deployment_config.get('config_dir', '/etc/calejo')
|
||||
]
|
||||
|
||||
for dir_path in dirs:
|
||||
cmd = f"sudo mkdir -p {dir_path} && sudo chown {self.config['ssh']['username']}:{self.config['ssh']['username']} {dir_path}"
|
||||
if not self.execute_remote(cmd, f"Creating {dir_path}"):
|
||||
return False
|
||||
|
||||
# Create deployment package
|
||||
print("📦 Creating deployment package...")
|
||||
package_path = self.create_deployment_package()
|
||||
|
||||
if dry_run:
|
||||
print(f" 📦 Would transfer package: {package_path}")
|
||||
os.remove(package_path)
|
||||
return True
|
||||
|
||||
# Transfer package
|
||||
remote_package_path = os.path.join(target_dir, "deployment.tar.gz")
|
||||
if not self.transfer_file(package_path, remote_package_path, "Transferring deployment package"):
|
||||
return False
|
||||
|
||||
# Extract package
|
||||
if not self.execute_remote(f"cd {target_dir} && tar -xzf deployment.tar.gz && rm deployment.tar.gz", "Extracting package"):
|
||||
return False
|
||||
|
||||
# Set permissions
|
||||
if not self.execute_remote(f"chmod +x {target_dir}/scripts/*.sh", "Setting script permissions"):
|
||||
return False
|
||||
|
||||
# Build and start services
|
||||
print("🐳 Building and starting services...")
|
||||
if not self.execute_remote(f"cd {target_dir} && sudo docker-compose build", "Building Docker images"):
|
||||
return False
|
||||
if not self.execute_remote(f"cd {target_dir} && sudo docker-compose up -d", "Starting services"):
|
||||
return False
|
||||
|
||||
# Wait for services
|
||||
print("⏳ Waiting for services to start...")
|
||||
for i in range(30):
|
||||
if self.execute_remote("curl -s http://localhost:8080/health > /dev/null", "", silent=True):
|
||||
print(" ✅ Services started successfully")
|
||||
break
|
||||
print(f" ⏳ Waiting... ({i+1}/30)")
|
||||
import time
|
||||
time.sleep(2)
|
||||
else:
|
||||
print(" ❌ Services failed to start within 60 seconds")
|
||||
return False
|
||||
|
||||
# Validate deployment
|
||||
print("🔍 Validating deployment...")
|
||||
self.execute_remote(f"cd {target_dir} && ./validate-deployment.sh", "Running validation")
|
||||
|
||||
print("🎉 Deployment completed successfully!")
|
||||
return True
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if hasattr(self, 'package_path') and os.path.exists(self.package_path):
|
||||
os.remove(self.package_path)
|
||||
|
||||
# Close connections
|
||||
if self.sftp_client:
|
||||
self.sftp_client.close()
|
||||
if self.ssh_client:
|
||||
self.ssh_client.close()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function"""
|
||||
parser = argparse.ArgumentParser(description='Calejo Control Adapter - SSH Deployment')
|
||||
parser.add_argument('-c', '--config', required=True, help='Deployment configuration file')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Dry run mode')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check if config file exists
|
||||
if not os.path.exists(args.config):
|
||||
print(f"❌ Configuration file not found: {args.config}")
|
||||
sys.exit(1)
|
||||
|
||||
# Run deployment
|
||||
deployer = SSHDeployer(args.config)
|
||||
success = deployer.deploy(dry_run=args.dry_run)
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,454 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Calejo Control Adapter - Remote SSH Deployment Script
|
||||
# Deploys the application to remote servers over SSH
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Default configuration
|
||||
CONFIG_FILE=""
|
||||
ENVIRONMENT=""
|
||||
DRY_RUN=false
|
||||
VERBOSE=false
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Function to display usage
|
||||
usage() {
|
||||
echo "Usage: $0 -e <environment> [-c <config-file>] [--dry-run] [--verbose]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -e, --environment Deployment environment (production, staging)"
|
||||
echo " -c, --config Custom configuration file"
|
||||
echo " --dry-run Show what would be deployed without actually deploying"
|
||||
echo " --verbose Enable verbose output"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 -e staging # Deploy to staging"
|
||||
echo " $0 -e production --dry-run # Dry run for production"
|
||||
echo " $0 -e production -c custom.yml # Use custom config"
|
||||
}
|
||||
|
||||
# Function to parse command line arguments
|
||||
parse_arguments() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-e|--environment)
|
||||
ENVIRONMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-c|--config)
|
||||
CONFIG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown option: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate required arguments
|
||||
if [[ -z "$ENVIRONMENT" ]]; then
|
||||
print_error "Environment is required"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set default config file if not provided
|
||||
if [[ -z "$CONFIG_FILE" ]]; then
|
||||
CONFIG_FILE="deploy/config/${ENVIRONMENT}.yml"
|
||||
fi
|
||||
|
||||
# Validate config file exists
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
print_error "Configuration file not found: $CONFIG_FILE"
|
||||
echo "Available configurations:"
|
||||
ls -1 deploy/config/*.yml 2>/dev/null | sed 's|deploy/config/||' || echo " (none)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to load configuration
|
||||
load_configuration() {
|
||||
print_status "Loading configuration from: $CONFIG_FILE"
|
||||
|
||||
# Check if yq is available for YAML parsing
|
||||
if ! command -v yq &> /dev/null; then
|
||||
print_error "yq is required for YAML parsing. Install with: snap install yq or brew install yq"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract configuration values
|
||||
SSH_HOST=$(yq e '.ssh.host' "$CONFIG_FILE")
|
||||
SSH_PORT=$(yq e '.ssh.port' "$CONFIG_FILE")
|
||||
SSH_USERNAME=$(yq e '.ssh.username' "$CONFIG_FILE")
|
||||
SSH_KEY_FILE=$(yq e '.ssh.key_file' "$CONFIG_FILE")
|
||||
|
||||
TARGET_DIR=$(yq e '.deployment.target_dir' "$CONFIG_FILE")
|
||||
BACKUP_DIR=$(yq e '.deployment.backup_dir' "$CONFIG_FILE")
|
||||
LOG_DIR=$(yq e '.deployment.log_dir' "$CONFIG_FILE")
|
||||
CONFIG_DIR=$(yq e '.deployment.config_dir' "$CONFIG_FILE")
|
||||
|
||||
# Validate required configuration
|
||||
if [[ -z "$SSH_HOST" || -z "$SSH_USERNAME" || -z "$SSH_KEY_FILE" ]]; then
|
||||
print_error "Missing required SSH configuration in $CONFIG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate SSH key file exists
|
||||
if [[ ! -f "$SSH_KEY_FILE" ]]; then
|
||||
print_error "SSH key file not found: $SSH_KEY_FILE"
|
||||
echo "Available keys:"
|
||||
ls -1 deploy/keys/ 2>/dev/null || echo " (none)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set default port if not specified
|
||||
if [[ -z "$SSH_PORT" ]]; then
|
||||
SSH_PORT=22
|
||||
fi
|
||||
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
print_status "Configuration loaded:"
|
||||
echo " SSH Host: $SSH_HOST"
|
||||
echo " SSH Port: $SSH_PORT"
|
||||
echo " SSH Username: $SSH_USERNAME"
|
||||
echo " SSH Key: $SSH_KEY_FILE"
|
||||
echo " Target Directory: $TARGET_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to build SSH command
|
||||
build_ssh_command() {
|
||||
local cmd="$1"
|
||||
local ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=30"
|
||||
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
ssh_opts="$ssh_opts -v"
|
||||
fi
|
||||
|
||||
echo "ssh -i $SSH_KEY_FILE -p $SSH_PORT $ssh_opts $SSH_USERNAME@$SSH_HOST '$cmd'"
|
||||
}
|
||||
|
||||
# Function to execute remote command
|
||||
execute_remote() {
|
||||
local cmd="$1"
|
||||
local description="$2"
|
||||
|
||||
print_status "$description"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo " [DRY RUN] Would execute: $cmd"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local ssh_cmd=$(build_ssh_command "$cmd")
|
||||
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo " Executing: $ssh_cmd"
|
||||
fi
|
||||
|
||||
if eval "$ssh_cmd"; then
|
||||
print_success "$description completed"
|
||||
return 0
|
||||
else
|
||||
print_error "$description failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to transfer files
|
||||
transfer_files() {
|
||||
local local_path="$1"
|
||||
local remote_path="$2"
|
||||
local description="$3"
|
||||
|
||||
print_status "$description"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo " [DRY RUN] Would transfer: $local_path -> $SSH_USERNAME@$SSH_HOST:$remote_path"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local scp_cmd="scp -i $SSH_KEY_FILE -P $SSH_PORT -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -r $local_path $SSH_USERNAME@$SSH_HOST:$remote_path"
|
||||
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo " Executing: $scp_cmd"
|
||||
fi
|
||||
|
||||
if eval "$scp_cmd"; then
|
||||
print_success "$description completed"
|
||||
return 0
|
||||
else
|
||||
print_error "$description failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check remote prerequisites
|
||||
check_remote_prerequisites() {
|
||||
print_status "Checking remote prerequisites..."
|
||||
|
||||
# Check Docker
|
||||
execute_remote "command -v docker" "Checking Docker installation" || {
|
||||
print_error "Docker is not installed on remote server"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check Docker Compose
|
||||
execute_remote "command -v docker-compose" "Checking Docker Compose installation" || {
|
||||
print_error "Docker Compose is not installed on remote server"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Check disk space
|
||||
execute_remote "df -h / | awk 'NR==2 {print \$5}'" "Checking disk space"
|
||||
|
||||
print_success "Remote prerequisites check passed"
|
||||
}
|
||||
|
||||
# Function to create remote directories
|
||||
create_remote_directories() {
|
||||
print_status "Creating remote directories..."
|
||||
|
||||
local dirs=("$TARGET_DIR" "$BACKUP_DIR" "$LOG_DIR" "$CONFIG_DIR")
|
||||
|
||||
for dir in "${dirs[@]}"; do
|
||||
execute_remote "sudo mkdir -p $dir && sudo chown $SSH_USERNAME:$SSH_USERNAME $dir" "Creating directory: $dir"
|
||||
done
|
||||
|
||||
print_success "Remote directories created"
|
||||
}
|
||||
|
||||
# Function to backup existing deployment
|
||||
backup_existing_deployment() {
|
||||
print_status "Checking for existing deployment..."
|
||||
|
||||
# Check if target directory exists and has content
|
||||
if execute_remote "[ -d $TARGET_DIR ] && [ \"$(ls -A $TARGET_DIR)\" ]" "Checking existing deployment" 2>/dev/null; then
|
||||
print_warning "Existing deployment found, creating backup..."
|
||||
|
||||
local timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_file="calejo-backup-$timestamp.tar.gz"
|
||||
|
||||
execute_remote "cd $TARGET_DIR && tar -czf $BACKUP_DIR/$backup_file ." "Creating backup: $backup_file"
|
||||
|
||||
print_success "Backup created: $BACKUP_DIR/$backup_file"
|
||||
else
|
||||
print_status "No existing deployment found"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to transfer application files
|
||||
transfer_application() {
|
||||
print_status "Transferring application files..."
|
||||
|
||||
# Create temporary deployment package
|
||||
local temp_dir=$(mktemp -d)
|
||||
local package_name="calejo-deployment-$(date +%Y%m%d_%H%M%S).tar.gz"
|
||||
|
||||
# Copy files to temporary directory (excluding deployment config and keys)
|
||||
print_status "Creating deployment package..."
|
||||
cp -r . "$temp_dir/"
|
||||
|
||||
# Remove sensitive deployment files from package
|
||||
rm -rf "$temp_dir/deploy/config"
|
||||
rm -rf "$temp_dir/deploy/keys"
|
||||
|
||||
# Create package
|
||||
cd "$temp_dir" && tar -czf "/tmp/$package_name" . && cd - > /dev/null
|
||||
|
||||
# Transfer package
|
||||
transfer_files "/tmp/$package_name" "$TARGET_DIR/" "Transferring deployment package"
|
||||
|
||||
# Extract package on remote
|
||||
execute_remote "cd $TARGET_DIR && tar -xzf $package_name && rm $package_name" "Extracting deployment package"
|
||||
|
||||
# Clean up
|
||||
rm -rf "$temp_dir"
|
||||
rm -f "/tmp/$package_name"
|
||||
|
||||
print_success "Application files transferred"
|
||||
}
|
||||
|
||||
# Function to setup remote configuration
|
||||
setup_remote_configuration() {
|
||||
print_status "Setting up remote configuration..."
|
||||
|
||||
# Transfer configuration files
|
||||
if [[ -f "config/settings.py" ]]; then
|
||||
transfer_files "config/settings.py" "$CONFIG_DIR/" "Transferring configuration file"
|
||||
fi
|
||||
|
||||
# Set permissions on scripts
|
||||
execute_remote "chmod +x $TARGET_DIR/scripts/*.sh" "Setting script permissions"
|
||||
execute_remote "chmod +x $TARGET_DIR/deploy-onprem.sh" "Setting deployment script permissions"
|
||||
|
||||
print_success "Remote configuration setup completed"
|
||||
}
|
||||
|
||||
# Function to build and start services
|
||||
build_and_start_services() {
|
||||
print_status "Building and starting services..."
|
||||
|
||||
# Build services
|
||||
execute_remote "cd $TARGET_DIR && sudo docker-compose build" "Building Docker images"
|
||||
|
||||
# Start services
|
||||
execute_remote "cd $TARGET_DIR && sudo docker-compose up -d" "Starting services"
|
||||
|
||||
# Wait for services to be ready
|
||||
print_status "Waiting for services to start..."
|
||||
for i in {1..30}; do
|
||||
if execute_remote "curl -s http://localhost:8080/health > /dev/null" "Checking service health" 2>/dev/null; then
|
||||
print_success "Services started successfully"
|
||||
break
|
||||
fi
|
||||
echo " Waiting... (attempt $i/30)"
|
||||
sleep 2
|
||||
|
||||
if [[ $i -eq 30 ]]; then
|
||||
print_error "Services failed to start within 60 seconds"
|
||||
execute_remote "cd $TARGET_DIR && sudo docker-compose logs" "Checking service logs"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Function to validate deployment
|
||||
validate_deployment() {
|
||||
print_status "Validating deployment..."
|
||||
|
||||
# Run remote validation script
|
||||
if execute_remote "cd $TARGET_DIR && ./validate-deployment.sh" "Running deployment validation" 2>/dev/null; then
|
||||
print_success "Deployment validation passed"
|
||||
else
|
||||
print_warning "Deployment validation completed with warnings"
|
||||
fi
|
||||
|
||||
# Test key endpoints
|
||||
local endpoints=("/health" "/dashboard" "/api/v1/status")
|
||||
|
||||
for endpoint in "${endpoints[@]}"; do
|
||||
if execute_remote "curl -s -f http://localhost:8080$endpoint > /dev/null" "Testing endpoint: $endpoint" 2>/dev/null; then
|
||||
print_success "Endpoint $endpoint is accessible"
|
||||
else
|
||||
print_error "Endpoint $endpoint is not accessible"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Function to display deployment summary
|
||||
display_deployment_summary() {
|
||||
print_success "Deployment to $ENVIRONMENT completed successfully!"
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo " DEPLOYMENT SUMMARY"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
echo "🌍 Environment: $ENVIRONMENT"
|
||||
echo "🏠 Server: $SSH_HOST"
|
||||
echo "📁 Application: $TARGET_DIR"
|
||||
echo ""
|
||||
echo "🔗 Access URLs:"
|
||||
echo " Dashboard: http://$SSH_HOST:8080/dashboard"
|
||||
echo " REST API: http://$SSH_HOST:8080"
|
||||
echo " Health Check: http://$SSH_HOST:8080/health"
|
||||
echo ""
|
||||
echo "🔧 Management Commands:"
|
||||
echo " View logs: ssh -i $SSH_KEY_FILE $SSH_USERNAME@$SSH_HOST 'cd $TARGET_DIR && docker-compose logs -f'"
|
||||
echo " Health check: ssh -i $SSH_KEY_FILE $SSH_USERNAME@$SSH_HOST 'cd $TARGET_DIR && ./scripts/health-check.sh'"
|
||||
echo " Backup: ssh -i $SSH_KEY_FILE $SSH_USERNAME@$SSH_HOST 'cd $TARGET_DIR && ./scripts/backup-full.sh'"
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
}
|
||||
|
||||
# Main deployment function
|
||||
main() {
|
||||
echo ""
|
||||
echo "🚀 Calejo Control Adapter - Remote SSH Deployment"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
|
||||
# Parse command line arguments
|
||||
parse_arguments "$@"
|
||||
|
||||
# Load configuration
|
||||
load_configuration
|
||||
|
||||
# Display deployment info
|
||||
echo "Deploying to: $ENVIRONMENT"
|
||||
echo "Server: $SSH_HOST"
|
||||
echo "Config: $CONFIG_FILE"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo "Mode: DRY RUN (no changes will be made)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check remote prerequisites
|
||||
check_remote_prerequisites
|
||||
|
||||
# Create remote directories
|
||||
create_remote_directories
|
||||
|
||||
# Backup existing deployment
|
||||
backup_existing_deployment
|
||||
|
||||
# Transfer application files
|
||||
transfer_application
|
||||
|
||||
# Setup remote configuration
|
||||
setup_remote_configuration
|
||||
|
||||
# Build and start services
|
||||
build_and_start_services
|
||||
|
||||
# Validate deployment
|
||||
validate_deployment
|
||||
|
||||
# Display summary
|
||||
display_deployment_summary
|
||||
|
||||
echo ""
|
||||
print_success "Remote deployment completed!"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
Loading…
Reference in New Issue