Initial commit: BlackRoad Deploy - Railway alternative
Complete self-hosted deployment platform with: - Deployment API (Node.js + TypeScript + Docker) - CLI tool (blackroad command) - Cloudflare Tunnel integration - PostgreSQL database - Docker Compose setup - Raspberry Pi support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Database
|
||||||
|
DB_PASSWORD=your_secure_password_here
|
||||||
|
|
||||||
|
# Cloudflare
|
||||||
|
CF_API_TOKEN=your_cloudflare_api_token
|
||||||
|
CF_ZONE_ID=848cf0b18d51e0170e0d1537aec3505a
|
||||||
|
|
||||||
|
# Tunnel
|
||||||
|
TUNNEL_ID=72f1d60c-dcf2-4499-b02d-d7a063018b33
|
||||||
118
ARCHITECTURE.md
Normal file
118
ARCHITECTURE.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# BlackRoad Deploy Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Self-hosted deployment platform to replace Railway.
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
### Deployment Server (DigitalOcean: 159.65.43.12)
|
||||||
|
- **Docker Engine**: Run application containers
|
||||||
|
- **Deployment API**: REST API for managing deployments
|
||||||
|
- **PostgreSQL**: Store deployment metadata, logs, configs
|
||||||
|
- **Caddy**: Reverse proxy for deployed services
|
||||||
|
- **Health Monitor**: Check service status
|
||||||
|
|
||||||
|
### Cloudflare Integration
|
||||||
|
- **Tunnel**: Secure access to deployment server (no exposed ports)
|
||||||
|
- **DNS**: Automatic subdomain creation (*.blackroad.systems)
|
||||||
|
- **KV**: Store deployment configs, environment variables
|
||||||
|
- **D1**: Alternative database option
|
||||||
|
- **Pages**: Host the web dashboard
|
||||||
|
|
||||||
|
### GitHub Integration
|
||||||
|
- **Webhooks**: Auto-deploy on push
|
||||||
|
- **Actions**: Optional CI/CD pipelines
|
||||||
|
- **Container Registry**: Store Docker images (ghcr.io)
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Deployment API (`blackroad-api`)
|
||||||
|
- **Tech**: Node.js + Express + TypeScript
|
||||||
|
- **Features**:
|
||||||
|
- Create/delete/restart deployments
|
||||||
|
- Stream logs
|
||||||
|
- Manage environment variables
|
||||||
|
- Health checks
|
||||||
|
- PostgreSQL for persistence
|
||||||
|
|
||||||
|
### 2. CLI Tool (`blackroad-cli`)
|
||||||
|
```bash
|
||||||
|
blackroad init # Initialize project
|
||||||
|
blackroad deploy # Deploy current directory
|
||||||
|
blackroad logs <app> # Stream logs
|
||||||
|
blackroad env set KEY=val
|
||||||
|
blackroad restart <app>
|
||||||
|
blackroad delete <app>
|
||||||
|
blackroad status # List all deployments
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Web Dashboard
|
||||||
|
- **Tech**: React + Vite on Cloudflare Pages
|
||||||
|
- **Features**:
|
||||||
|
- View all deployments
|
||||||
|
- Real-time logs
|
||||||
|
- Environment variable management
|
||||||
|
- Deployment history
|
||||||
|
- Resource usage metrics
|
||||||
|
|
||||||
|
## Deployment Flow
|
||||||
|
|
||||||
|
1. **Developer**: `blackroad deploy`
|
||||||
|
2. **CLI**:
|
||||||
|
- Builds Docker image
|
||||||
|
- Pushes to ghcr.io
|
||||||
|
- Calls Deployment API
|
||||||
|
3. **API**:
|
||||||
|
- Pulls image
|
||||||
|
- Starts container with env vars
|
||||||
|
- Configures Caddy reverse proxy
|
||||||
|
- Updates DNS via Cloudflare API
|
||||||
|
4. **Result**: App live at `<app-name>.blackroad.systems`
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
### PostgreSQL Schema
|
||||||
|
```sql
|
||||||
|
-- deployments
|
||||||
|
id, name, image, status, created_at, updated_at
|
||||||
|
|
||||||
|
-- environment_variables
|
||||||
|
deployment_id, key, value (encrypted)
|
||||||
|
|
||||||
|
-- deployment_logs
|
||||||
|
deployment_id, timestamp, level, message
|
||||||
|
|
||||||
|
-- domains
|
||||||
|
deployment_id, domain, status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cloudflare KV
|
||||||
|
- Deployment configs
|
||||||
|
- API keys/tokens
|
||||||
|
- Backup for environment variables
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **API Authentication**: Bearer tokens (stored in Cloudflare KV)
|
||||||
|
- **Cloudflare Tunnel**: No exposed ports on deployment server
|
||||||
|
- **Environment Variables**: Encrypted at rest
|
||||||
|
- **Container Isolation**: Docker network isolation
|
||||||
|
- **HTTPS**: Automatic via Cloudflare
|
||||||
|
|
||||||
|
## Advantages Over Railway
|
||||||
|
|
||||||
|
✅ **Full Control**: Own your infrastructure
|
||||||
|
✅ **Cost**: ~$12/month (DigitalOcean) vs $20-100+ on Railway
|
||||||
|
✅ **Unlimited Projects**: No artificial limits
|
||||||
|
✅ **Custom Domains**: Free via Cloudflare
|
||||||
|
✅ **Data Ownership**: Everything on your server
|
||||||
|
✅ **No Vendor Lock-in**: Can migrate anywhere
|
||||||
|
✅ **Integration**: Deep Cloudflare + GitHub integration
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Set up deployment server
|
||||||
|
2. Build API service
|
||||||
|
3. Create CLI tool
|
||||||
|
4. Deploy dashboard
|
||||||
|
5. Migrate Railway projects
|
||||||
25
Caddyfile
Normal file
25
Caddyfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# BlackRoad Deploy - Caddy Configuration
|
||||||
|
# Reverse proxy for deployed applications
|
||||||
|
{
|
||||||
|
# Disable automatic HTTPS (handled by Cloudflare)
|
||||||
|
auto_https off
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default catch-all for deployed apps
|
||||||
|
:8080 {
|
||||||
|
# Extract app name from Host header
|
||||||
|
# Format: app-name.blackroad.systems
|
||||||
|
|
||||||
|
@api host deploy-api.blackroad.systems
|
||||||
|
handle @api {
|
||||||
|
reverse_proxy api:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Route to deployed containers
|
||||||
|
# This will be dynamically configured by the API
|
||||||
|
handle {
|
||||||
|
respond "BlackRoad Deploy - No deployment found" 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
QUICKSTART.md
Normal file
176
QUICKSTART.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# BlackRoad Deploy - Quick Start Guide
|
||||||
|
|
||||||
|
Get your own Railway alternative running in 15 minutes.
|
||||||
|
|
||||||
|
## Step 1: Server Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH to your DigitalOcean droplet
|
||||||
|
ssh root@159.65.43.12
|
||||||
|
|
||||||
|
# Install Docker
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sh get-docker.sh
|
||||||
|
|
||||||
|
# Install Docker Compose
|
||||||
|
apt-get install docker-compose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Deploy BlackRoad Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
cd /opt
|
||||||
|
git clone https://github.com/blackroad-os/blackroad-deploy
|
||||||
|
cd blackroad-deploy
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # Add your Cloudflare credentials
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
docker-compose ps
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Set Up Cloudflare Tunnel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tunnel setup script
|
||||||
|
cd scripts
|
||||||
|
chmod +x setup-tunnel.sh
|
||||||
|
./setup-tunnel.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Install cloudflared
|
||||||
|
- Configure DNS for deploy-api.blackroad.systems
|
||||||
|
- Start tunnel service
|
||||||
|
|
||||||
|
## Step 4: Create First User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register via API
|
||||||
|
curl -X POST https://deploy-api.blackroad.systems/api/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "amundsonalexa@gmail.com",
|
||||||
|
"password": "your_secure_password"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Save the API key returned
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Install CLI Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On your local machine
|
||||||
|
cd ~/blackroad-deploy/cli
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm link # Makes 'blackroad' command available globally
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Login
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blackroad login
|
||||||
|
# Enter your email and password
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Deploy Your First App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Go to any Node.js project
|
||||||
|
cd ~/my-app
|
||||||
|
|
||||||
|
# Initialize
|
||||||
|
blackroad init
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
blackroad deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Your app is now live at `https://my-app.blackroad.systems`!
|
||||||
|
|
||||||
|
## Verify Everything Works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check API health
|
||||||
|
curl https://deploy-api.blackroad.systems/health
|
||||||
|
|
||||||
|
# List deployments
|
||||||
|
blackroad list
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
blackroad logs my-app
|
||||||
|
|
||||||
|
# Set environment variable
|
||||||
|
blackroad env set my-app NODE_ENV=production
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
blackroad restart my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add Raspberry Pi (Optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH to Pi
|
||||||
|
ssh alice@192.168.4.49
|
||||||
|
|
||||||
|
# Run Pi setup
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/blackroad-os/blackroad-deploy/main/scripts/setup-pi-tunnel.sh | bash
|
||||||
|
|
||||||
|
# Follow tunnel setup instructions
|
||||||
|
cloudflared tunnel login
|
||||||
|
cloudflared tunnel create blackroad-pi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tunnel not connecting
|
||||||
|
```bash
|
||||||
|
# Check tunnel status
|
||||||
|
systemctl status cloudflared
|
||||||
|
journalctl -u cloudflared -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### API not responding
|
||||||
|
```bash
|
||||||
|
# Check containers
|
||||||
|
docker-compose ps
|
||||||
|
docker-compose logs api
|
||||||
|
|
||||||
|
# Restart services
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database issues
|
||||||
|
```bash
|
||||||
|
# Check database
|
||||||
|
docker-compose exec postgres psql -U postgres -d blackroad_deploy
|
||||||
|
|
||||||
|
# View tables
|
||||||
|
\dt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Set up monitoring (Prometheus/Grafana)
|
||||||
|
- Configure backups
|
||||||
|
- Add custom domains
|
||||||
|
- Build web dashboard
|
||||||
|
- Set up CI/CD with GitHub Actions
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- Full docs: [README.md](./README.md)
|
||||||
|
- Architecture: [ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||||
|
- Tunnel setup: [TUNNEL_SETUP.md](./TUNNEL_SETUP.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Need help? Open an issue: https://github.com/blackroad-os/blackroad-deploy/issues
|
||||||
413
README.md
Normal file
413
README.md
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
# BlackRoad Deploy
|
||||||
|
|
||||||
|
**Self-hosted deployment platform** - Your own Railway/Heroku alternative.
|
||||||
|
|
||||||
|
Deploy apps to your infrastructure (DigitalOcean + Raspberry Pi) with Docker, Cloudflare Tunnel, and full control.
|
||||||
|
|
||||||
|
## Why BlackRoad Deploy?
|
||||||
|
|
||||||
|
✅ **Full Control** - Own your infrastructure, no vendor lock-in
|
||||||
|
✅ **Cost Effective** - ~$12/month vs $20-100+ on Railway
|
||||||
|
✅ **Unlimited Projects** - No artificial limits
|
||||||
|
✅ **Free Custom Domains** - Via Cloudflare
|
||||||
|
✅ **Simple CLI** - `blackroad deploy` and you're live
|
||||||
|
✅ **Works Anywhere** - Cloudflare Tunnel works behind NAT/firewall
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Developer │
|
||||||
|
│ ├─ blackroad deploy │
|
||||||
|
│ └─ Pushes Docker image to ghcr.io │
|
||||||
|
└────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Cloudflare Tunnel │
|
||||||
|
│ ├─ deploy-api.blackroad.systems (API) │
|
||||||
|
│ └─ *.blackroad.systems (Apps) │
|
||||||
|
└────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Deployment Server (DigitalOcean/Pi) │
|
||||||
|
│ ├─ API: Node.js + PostgreSQL │
|
||||||
|
│ ├─ Docker: Runs containers │
|
||||||
|
│ └─ Caddy: Reverse proxy │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Install CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g blackroad-cli
|
||||||
|
# or
|
||||||
|
yarn global add blackroad-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Login
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blackroad login
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialize Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd my-app
|
||||||
|
blackroad init
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `blackroad.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"buildCommand": "npm run build",
|
||||||
|
"startCommand": "npm start",
|
||||||
|
"port": 3000,
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
blackroad deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Your app will be live at `https://my-app.blackroad.systems`
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize project
|
||||||
|
blackroad init
|
||||||
|
|
||||||
|
# Login
|
||||||
|
blackroad login
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
blackroad deploy
|
||||||
|
blackroad deploy -n my-app -i ghcr.io/user/image:tag
|
||||||
|
|
||||||
|
# List deployments
|
||||||
|
blackroad list
|
||||||
|
blackroad ls
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
blackroad logs my-app
|
||||||
|
blackroad logs my-app -f # Follow logs
|
||||||
|
blackroad logs my-app -n 500 # Last 500 lines
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
blackroad env set my-app DATABASE_URL=postgres://...
|
||||||
|
blackroad env get my-app
|
||||||
|
blackroad env delete my-app DATABASE_URL
|
||||||
|
|
||||||
|
# Restart deployment
|
||||||
|
blackroad restart my-app
|
||||||
|
|
||||||
|
# Delete deployment
|
||||||
|
blackroad delete my-app
|
||||||
|
blackroad rm my-app -f # Skip confirmation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Infrastructure Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- DigitalOcean droplet (or any VPS)
|
||||||
|
- Cloudflare account with domain
|
||||||
|
- Docker installed on server
|
||||||
|
|
||||||
|
### 1. Set Up Deployment Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH to your server
|
||||||
|
ssh root@159.65.43.12
|
||||||
|
|
||||||
|
# Clone repo
|
||||||
|
git clone https://github.com/blackroad-os/blackroad-deploy
|
||||||
|
cd blackroad-deploy
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
cd deployment-api
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Set up PostgreSQL
|
||||||
|
sudo apt-get install postgresql
|
||||||
|
sudo -u postgres createdb blackroad_deploy
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # Add your credentials
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
npm run db:migrate
|
||||||
|
|
||||||
|
# Start API
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Up Cloudflare Tunnel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run setup script
|
||||||
|
cd ../scripts
|
||||||
|
chmod +x setup-tunnel.sh
|
||||||
|
./setup-tunnel.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Install cloudflared
|
||||||
|
- Create tunnel
|
||||||
|
- Configure DNS
|
||||||
|
- Start tunnel service
|
||||||
|
|
||||||
|
See [TUNNEL_SETUP.md](./TUNNEL_SETUP.md) for detailed instructions.
|
||||||
|
|
||||||
|
### 3. Set Up Raspberry Pi (Optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH to Pi
|
||||||
|
ssh alice@192.168.4.49
|
||||||
|
|
||||||
|
# Copy and run setup script
|
||||||
|
scp scripts/setup-pi-tunnel.sh alice@192.168.4.49:~/
|
||||||
|
ssh alice@192.168.4.49
|
||||||
|
chmod +x setup-pi-tunnel.sh
|
||||||
|
./setup-pi-tunnel.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Deployment API (.env)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=blackroad_deploy
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
CF_API_TOKEN=your_cloudflare_token
|
||||||
|
CF_ZONE_ID=your_zone_id
|
||||||
|
|
||||||
|
DOCKER_HOST=unix:///var/run/docker.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cloudflare Tunnel (config.yml)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tunnel: <TUNNEL-ID>
|
||||||
|
credentials-file: /root/.cloudflared/<TUNNEL-ID>.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
- hostname: deploy-api.blackroad.systems
|
||||||
|
service: http://localhost:3000
|
||||||
|
|
||||||
|
- hostname: "*.blackroad.systems"
|
||||||
|
service: http://localhost:8080
|
||||||
|
|
||||||
|
- service: http_status:404
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
All API endpoints (except `/health` and `/api/auth/*`) require authentication:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <API_KEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
#### `POST /api/auth/register`
|
||||||
|
Register new user and get API key.
|
||||||
|
|
||||||
|
#### `POST /api/auth/login`
|
||||||
|
Login and get API key.
|
||||||
|
|
||||||
|
#### `GET /api/deployments`
|
||||||
|
List all deployments.
|
||||||
|
|
||||||
|
#### `POST /api/deployments`
|
||||||
|
Create new deployment.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"image": "ghcr.io/user/my-app:latest",
|
||||||
|
"env": {
|
||||||
|
"DATABASE_URL": "postgres://..."
|
||||||
|
},
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/deployments/:name`
|
||||||
|
Get deployment details.
|
||||||
|
|
||||||
|
#### `DELETE /api/deployments/:name`
|
||||||
|
Delete deployment.
|
||||||
|
|
||||||
|
#### `POST /api/deployments/:name/restart`
|
||||||
|
Restart deployment.
|
||||||
|
|
||||||
|
#### `GET /api/logs/:name`
|
||||||
|
Get container logs.
|
||||||
|
|
||||||
|
#### `GET /api/env/:name`
|
||||||
|
Get environment variables.
|
||||||
|
|
||||||
|
#### `POST /api/env/:name`
|
||||||
|
Set environment variable.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "DATABASE_URL",
|
||||||
|
"value": "postgres://..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Images
|
||||||
|
|
||||||
|
Your apps should be containerized. Example `Dockerfile`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and push to GitHub Container Registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t ghcr.io/your-username/my-app:latest .
|
||||||
|
docker push ghcr.io/your-username/my-app:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or let `blackroad deploy` handle it automatically.
|
||||||
|
|
||||||
|
## Cost Breakdown
|
||||||
|
|
||||||
|
### DigitalOcean Droplet
|
||||||
|
- **Basic Droplet**: $6/month (1GB RAM, 1 vCPU)
|
||||||
|
- **Better**: $12/month (2GB RAM, 2 vCPU)
|
||||||
|
- **Production**: $24/month (4GB RAM, 2 vCPU)
|
||||||
|
|
||||||
|
### Cloudflare
|
||||||
|
- **Free**: Tunnel, DNS, SSL, DDoS protection
|
||||||
|
- **Optional**: Additional features
|
||||||
|
|
||||||
|
### Total: ~$6-24/month
|
||||||
|
vs Railway: $20-100+/month (limited projects)
|
||||||
|
|
||||||
|
## Raspberry Pi Support
|
||||||
|
|
||||||
|
Run deployments on Raspberry Pi for even lower costs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run setup on Pi
|
||||||
|
./scripts/setup-pi-tunnel.sh
|
||||||
|
|
||||||
|
# Deploy to Pi
|
||||||
|
blackroad deploy -n pi-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Your Pi can host:
|
||||||
|
- Development/staging environments
|
||||||
|
- Personal projects
|
||||||
|
- Internal tools
|
||||||
|
- IoT services
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tunnel Not Working
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check tunnel status
|
||||||
|
cloudflared tunnel info blackroad-deploy
|
||||||
|
|
||||||
|
# Check service
|
||||||
|
systemctl status cloudflared
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
journalctl -u cloudflared -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Docker
|
||||||
|
docker ps
|
||||||
|
docker logs <container-id>
|
||||||
|
|
||||||
|
# Restart Docker
|
||||||
|
systemctl restart docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test PostgreSQL
|
||||||
|
psql -U postgres -d blackroad_deploy
|
||||||
|
|
||||||
|
# Check running containers
|
||||||
|
docker ps | grep postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- ✅ API key authentication
|
||||||
|
- ✅ Cloudflare Tunnel (no open ports)
|
||||||
|
- ✅ HTTPS everywhere
|
||||||
|
- ✅ Environment variables encrypted at rest
|
||||||
|
- ✅ Container isolation
|
||||||
|
- ✅ DDoS protection via Cloudflare
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] Web dashboard (Cloudflare Pages)
|
||||||
|
- [ ] GitHub Actions integration
|
||||||
|
- [ ] Auto-scaling
|
||||||
|
- [ ] Multi-region deployments
|
||||||
|
- [ ] Database provisioning
|
||||||
|
- [ ] Metrics and monitoring
|
||||||
|
- [ ] Automatic SSL for custom domains
|
||||||
|
- [ ] Deploy from Git (auto-deploy on push)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Pull requests welcome! See [ARCHITECTURE.md](./ARCHITECTURE.md) for technical details.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- GitHub Issues: https://github.com/blackroad-os/blackroad-deploy/issues
|
||||||
|
- Email: blackroad.systems@gmail.com
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ❤️ by BlackRoad Systems
|
||||||
184
TUNNEL_SETUP.md
Normal file
184
TUNNEL_SETUP.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Cloudflare Tunnel Setup Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Use Cloudflare Tunnel to expose your deployment infrastructure (DigitalOcean + Raspberry Pi) without opening ports.
|
||||||
|
|
||||||
|
## Setup on DigitalOcean Droplet (159.65.43.12)
|
||||||
|
|
||||||
|
### 1. Install cloudflared
|
||||||
|
```bash
|
||||||
|
ssh root@159.65.43.12
|
||||||
|
|
||||||
|
# Install cloudflared
|
||||||
|
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared
|
||||||
|
chmod +x /usr/local/bin/cloudflared
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Authenticate with Cloudflare
|
||||||
|
```bash
|
||||||
|
# This will open browser for auth
|
||||||
|
cloudflared tunnel login
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Tunnel
|
||||||
|
```bash
|
||||||
|
# Create tunnel
|
||||||
|
cloudflared tunnel create blackroad-deploy
|
||||||
|
|
||||||
|
# This creates:
|
||||||
|
# - Tunnel credentials in ~/.cloudflared/<TUNNEL-ID>.json
|
||||||
|
# - Tunnel registered in your Cloudflare account
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configure Tunnel
|
||||||
|
Create `/root/.cloudflared/config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tunnel: <TUNNEL-ID>
|
||||||
|
credentials-file: /root/.cloudflared/<TUNNEL-ID>.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
# Deployment API
|
||||||
|
- hostname: deploy-api.blackroad.systems
|
||||||
|
service: http://localhost:3000
|
||||||
|
|
||||||
|
# Deployed apps (dynamic routing via Caddy)
|
||||||
|
- hostname: "*.blackroad.systems"
|
||||||
|
service: http://localhost:8080
|
||||||
|
|
||||||
|
# Catch-all
|
||||||
|
- service: http_status:404
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Route DNS
|
||||||
|
```bash
|
||||||
|
# Route DNS to tunnel
|
||||||
|
cloudflared tunnel route dns blackroad-deploy deploy-api.blackroad.systems
|
||||||
|
cloudflared tunnel route dns blackroad-deploy *.blackroad.systems
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Run Tunnel as Service
|
||||||
|
```bash
|
||||||
|
# Install as systemd service
|
||||||
|
cloudflared service install
|
||||||
|
|
||||||
|
# Start tunnel
|
||||||
|
systemctl start cloudflared
|
||||||
|
systemctl enable cloudflared
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
systemctl status cloudflared
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup on Raspberry Pi (192.168.4.49)
|
||||||
|
|
||||||
|
### 1. Install cloudflared
|
||||||
|
```bash
|
||||||
|
ssh alice@192.168.4.49 # or pi@192.168.4.49
|
||||||
|
|
||||||
|
# For ARM64
|
||||||
|
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64 -o ~/cloudflared
|
||||||
|
chmod +x ~/cloudflared
|
||||||
|
sudo mv ~/cloudflared /usr/local/bin/
|
||||||
|
|
||||||
|
# For ARM32
|
||||||
|
# curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm -o ~/cloudflared
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Pi Tunnel
|
||||||
|
```bash
|
||||||
|
cloudflared tunnel login
|
||||||
|
cloudflared tunnel create blackroad-pi
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure Pi Tunnel
|
||||||
|
Create `~/.cloudflared/config.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tunnel: <PI-TUNNEL-ID>
|
||||||
|
credentials-file: /home/alice/.cloudflared/<PI-TUNNEL-ID>.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
# Pi-hosted services
|
||||||
|
- hostname: pi.blackroad.systems
|
||||||
|
service: http://localhost:3000
|
||||||
|
|
||||||
|
- hostname: "*.pi.blackroad.systems"
|
||||||
|
service: http://localhost:8080
|
||||||
|
|
||||||
|
- service: http_status:404
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Route DNS
|
||||||
|
```bash
|
||||||
|
cloudflared tunnel route dns blackroad-pi pi.blackroad.systems
|
||||||
|
cloudflared tunnel route dns blackroad-pi *.pi.blackroad.systems
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Run as Service
|
||||||
|
```bash
|
||||||
|
cloudflared service install
|
||||||
|
sudo systemctl start cloudflared
|
||||||
|
sudo systemctl enable cloudflared
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Your Credentials
|
||||||
|
|
||||||
|
Your Cloudflare credentials from CLAUDE.md:
|
||||||
|
```bash
|
||||||
|
CF_TOKEN='yP5h0HvsXX0BpHLs01tLmgtTbQurIKPL4YnQfIwy'
|
||||||
|
CF_ZONE='848cf0b18d51e0170e0d1537aec3505a'
|
||||||
|
TUNNEL_ID='72f1d60c-dcf2-4499-b02d-d7a063018b33'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automated Script
|
||||||
|
|
||||||
|
Create `setup-tunnel.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
CF_TOKEN='yP5h0HvsXX0BpHLs01tLmgtTbQurIKPL4YnQfIwy'
|
||||||
|
CF_ZONE='848cf0b18d51e0170e0d1537aec3505a'
|
||||||
|
TUNNEL_ID='72f1d60c-dcf2-4499-b02d-d7a063018b33'
|
||||||
|
|
||||||
|
# Add DNS record
|
||||||
|
echo "☁️ Adding DNS record for deploy-api.blackroad.systems"
|
||||||
|
cloudflared tunnel route dns $TUNNEL_ID deploy-api.blackroad.systems
|
||||||
|
|
||||||
|
# Start tunnel
|
||||||
|
echo "🚀 Starting tunnel..."
|
||||||
|
cloudflared service install
|
||||||
|
systemctl start cloudflared
|
||||||
|
systemctl status cloudflared
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check tunnel status
|
||||||
|
cloudflared tunnel info blackroad-deploy
|
||||||
|
|
||||||
|
# List all tunnels
|
||||||
|
cloudflared tunnel list
|
||||||
|
|
||||||
|
# Test connectivity
|
||||||
|
curl https://deploy-api.blackroad.systems/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
✅ **No Port Forwarding**: Works behind NAT/firewall
|
||||||
|
✅ **Automatic HTTPS**: Cloudflare handles SSL
|
||||||
|
✅ **DDoS Protection**: Cloudflare's network
|
||||||
|
✅ **Zero Trust**: Can add access policies
|
||||||
|
✅ **Works Anywhere**: Even on cellular/mobile networks
|
||||||
|
✅ **Free**: No cost for tunnels
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Set up tunnel on DigitalOcean droplet
|
||||||
|
2. Set up tunnel on Raspberry Pi
|
||||||
|
3. Configure Caddy for reverse proxy
|
||||||
|
4. Deploy API service
|
||||||
|
5. Test end-to-end deployment
|
||||||
43
cli/package.json
Normal file
43
cli/package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "blackroad-cli",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "BlackRoad Deploy CLI - Deploy applications to your own infrastructure",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"bin": {
|
||||||
|
"blackroad": "./dist/index.js",
|
||||||
|
"br": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"build": "tsc && chmod +x dist/index.js",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^11.1.0",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"ora": "^5.4.1",
|
||||||
|
"inquirer": "^8.2.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"fs-extra": "^11.2.0",
|
||||||
|
"tar": "^6.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.5",
|
||||||
|
"@types/inquirer": "^8.2.10",
|
||||||
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/tar": "^6.1.11",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"deploy",
|
||||||
|
"deployment",
|
||||||
|
"paas",
|
||||||
|
"hosting",
|
||||||
|
"docker",
|
||||||
|
"cloudflare"
|
||||||
|
],
|
||||||
|
"author": "BlackRoad Systems",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
24
cli/src/api.ts
Normal file
24
cli/src/api.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import { loadConfig } from './config';
|
||||||
|
|
||||||
|
let apiClient: AxiosInstance | null = null;
|
||||||
|
|
||||||
|
export async function getApiClient(): Promise<AxiosInstance> {
|
||||||
|
if (apiClient) return apiClient;
|
||||||
|
|
||||||
|
const config = await loadConfig();
|
||||||
|
|
||||||
|
apiClient = axios.create({
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(config.apiKey && { Authorization: `Bearer ${config.apiKey}` }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetApiClient() {
|
||||||
|
apiClient = null;
|
||||||
|
}
|
||||||
46
cli/src/commands/delete.ts
Normal file
46
cli/src/commands/delete.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
|
import ora from 'ora';
|
||||||
|
import { getApiClient } from '../api';
|
||||||
|
|
||||||
|
export const deleteCommand = new Command('delete')
|
||||||
|
.alias('rm')
|
||||||
|
.description('Delete a deployment')
|
||||||
|
.argument('<name>', 'Deployment name')
|
||||||
|
.option('-f, --force', 'Skip confirmation')
|
||||||
|
.action(async (name, options) => {
|
||||||
|
try {
|
||||||
|
if (!options.force) {
|
||||||
|
const { confirm } = await inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'confirm',
|
||||||
|
message: `Delete deployment '${name}'? This cannot be undone.`,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!confirm) {
|
||||||
|
console.log(chalk.dim('Cancelled'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = await getApiClient();
|
||||||
|
const spinner = ora('Deleting deployment...').start();
|
||||||
|
|
||||||
|
await api.delete(`/api/deployments/${name}`);
|
||||||
|
|
||||||
|
spinner.succeed(chalk.green(`Deployment '${name}' deleted successfully`));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
console.error(chalk.red(`Deployment '${name}' not found`));
|
||||||
|
} else if (err.response?.data?.error) {
|
||||||
|
console.error(chalk.red(err.response.data.error));
|
||||||
|
} else {
|
||||||
|
console.error(chalk.red('Error:'), err.message);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
107
cli/src/commands/deploy.ts
Normal file
107
cli/src/commands/deploy.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import ora from 'ora';
|
||||||
|
import { getApiClient } from '../api';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export const deployCommand = new Command('deploy')
|
||||||
|
.description('Deploy your application')
|
||||||
|
.option('-n, --name <name>', 'Deployment name')
|
||||||
|
.option('-i, --image <image>', 'Docker image to deploy')
|
||||||
|
.action(async (options) => {
|
||||||
|
try {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const configPath = path.join(cwd, 'blackroad.json');
|
||||||
|
|
||||||
|
let config: any = {};
|
||||||
|
|
||||||
|
// Load config if exists
|
||||||
|
if (await fs.pathExists(configPath)) {
|
||||||
|
config = await fs.readJson(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = options.name || config.name;
|
||||||
|
let image = options.image;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
console.error(chalk.red('Error: Deployment name required'));
|
||||||
|
console.log(chalk.dim('Run: blackroad init'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = await getApiClient();
|
||||||
|
const spinner = ora();
|
||||||
|
|
||||||
|
// If no image provided, build Docker image
|
||||||
|
if (!image) {
|
||||||
|
const dockerfilePath = path.join(cwd, 'Dockerfile');
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(dockerfilePath))) {
|
||||||
|
console.error(chalk.red('Error: No Dockerfile found'));
|
||||||
|
console.log(chalk.dim('Create a Dockerfile or provide --image'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
image = `ghcr.io/blackroad/${name}:latest`;
|
||||||
|
|
||||||
|
spinner.start('Building Docker image...');
|
||||||
|
try {
|
||||||
|
await execAsync(`docker build -t ${image} .`);
|
||||||
|
spinner.succeed('Docker image built');
|
||||||
|
} catch (err: any) {
|
||||||
|
spinner.fail('Docker build failed');
|
||||||
|
console.error(chalk.red(err.message));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner.start('Pushing image to registry...');
|
||||||
|
try {
|
||||||
|
await execAsync(`docker push ${image}`);
|
||||||
|
spinner.succeed('Image pushed');
|
||||||
|
} catch (err: any) {
|
||||||
|
spinner.fail('Docker push failed');
|
||||||
|
console.error(chalk.red(err.message));
|
||||||
|
console.log(chalk.dim('Make sure you are logged in: docker login ghcr.io'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner.start('Deploying...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/deployments', {
|
||||||
|
name,
|
||||||
|
image,
|
||||||
|
env: config.env || {},
|
||||||
|
port: config.port || 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
spinner.succeed(chalk.green('Deployment successful!'));
|
||||||
|
console.log(chalk.bold('\nDeployment Details:'));
|
||||||
|
console.log(chalk.dim('Name:'), response.data.deployment.name);
|
||||||
|
console.log(chalk.dim('Image:'), response.data.deployment.image);
|
||||||
|
console.log(chalk.dim('Status:'), response.data.deployment.status);
|
||||||
|
console.log(chalk.dim('URL:'), chalk.cyan(`https://${response.data.domain}`));
|
||||||
|
|
||||||
|
console.log(chalk.dim('\nNext steps:'));
|
||||||
|
console.log(chalk.dim(' • View logs: blackroad logs ' + name));
|
||||||
|
console.log(chalk.dim(' • Set env vars: blackroad env set ' + name + ' KEY=value'));
|
||||||
|
} catch (err: any) {
|
||||||
|
spinner.fail(chalk.red('Deployment failed'));
|
||||||
|
if (err.response?.data?.error) {
|
||||||
|
console.error(chalk.red(err.response.data.error));
|
||||||
|
} else {
|
||||||
|
console.error(chalk.red(err.message));
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(chalk.red('Error:'), err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
110
cli/src/commands/env.ts
Normal file
110
cli/src/commands/env.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
|
import { getApiClient } from '../api';
|
||||||
|
|
||||||
|
const envSetCommand = new Command('set')
|
||||||
|
.description('Set environment variable')
|
||||||
|
.argument('<name>', 'Deployment name')
|
||||||
|
.argument('<keyvalue>', 'KEY=value')
|
||||||
|
.action(async (name, keyvalue) => {
|
||||||
|
try {
|
||||||
|
const [key, ...valueParts] = keyvalue.split('=');
|
||||||
|
const value = valueParts.join('=');
|
||||||
|
|
||||||
|
if (!key || value === undefined) {
|
||||||
|
console.error(chalk.red('Invalid format. Use: KEY=value'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = await getApiClient();
|
||||||
|
await api.post(`/api/env/${name}`, { key, value });
|
||||||
|
|
||||||
|
console.log(chalk.green(`✓ Set ${key} for ${name}`));
|
||||||
|
console.log(chalk.dim('Restart deployment to apply changes: blackroad restart ' + name));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
console.error(chalk.red(`Deployment '${name}' not found`));
|
||||||
|
} else if (err.response?.data?.error) {
|
||||||
|
console.error(chalk.red(err.response.data.error));
|
||||||
|
} else {
|
||||||
|
console.error(chalk.red('Error:'), err.message);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const envGetCommand = new Command('get')
|
||||||
|
.description('Get environment variables')
|
||||||
|
.argument('<name>', 'Deployment name')
|
||||||
|
.action(async (name) => {
|
||||||
|
try {
|
||||||
|
const api = await getApiClient();
|
||||||
|
const response = await api.get(`/api/env/${name}`);
|
||||||
|
|
||||||
|
const env = response.data.env;
|
||||||
|
const keys = Object.keys(env);
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
console.log(chalk.dim('No environment variables set'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.bold(`\nEnvironment variables for ${name}:\n`));
|
||||||
|
for (const key of keys) {
|
||||||
|
console.log(chalk.dim(key + ':'), env[key]);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
console.error(chalk.red(`Deployment '${name}' not found`));
|
||||||
|
} else if (err.response?.data?.error) {
|
||||||
|
console.error(chalk.red(err.response.data.error));
|
||||||
|
} else {
|
||||||
|
console.error(chalk.red('Error:'), err.message);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const envDeleteCommand = new Command('delete')
|
||||||
|
.alias('rm')
|
||||||
|
.description('Delete environment variable')
|
||||||
|
.argument('<name>', 'Deployment name')
|
||||||
|
.argument('<key>', 'Variable key')
|
||||||
|
.action(async (name, key) => {
|
||||||
|
try {
|
||||||
|
const { confirm } = await inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'confirm',
|
||||||
|
message: `Delete ${key} from ${name}?`,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!confirm) {
|
||||||
|
console.log(chalk.dim('Cancelled'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = await getApiClient();
|
||||||
|
await api.delete(`/api/env/${name}/${key}`);
|
||||||
|
|
||||||
|
console.log(chalk.green(`✓ Deleted ${key} from ${name}`));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
console.error(chalk.red(`Deployment '${name}' or variable '${key}' not found`));
|
||||||
|
} else if (err.response?.data?.error) {
|
||||||
|
console.error(chalk.red(err.response.data.error));
|
||||||
|
} else {
|
||||||
|
console.error(chalk.red('Error:'), err.message);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const envCommand = new Command('env')
|
||||||
|
.description('Manage environment variables')
|
||||||
|
.addCommand(envSetCommand)
|
||||||
|
.addCommand(envGetCommand)
|
||||||
|
.addCommand(envDeleteCommand);
|
||||||
86
cli/src/commands/init.ts
Normal file
86
cli/src/commands/init.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
|
|
||||||
|
export const initCommand = new Command('init')
|
||||||
|
.description('Initialize a new BlackRoad Deploy project')
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const configPath = path.join(cwd, 'blackroad.json');
|
||||||
|
|
||||||
|
if (await fs.pathExists(configPath)) {
|
||||||
|
console.log(chalk.yellow('blackroad.json already exists'));
|
||||||
|
const { overwrite } = await inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'overwrite',
|
||||||
|
message: 'Overwrite existing config?',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!overwrite) {
|
||||||
|
console.log(chalk.dim('Cancelled'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(cwd, 'package.json');
|
||||||
|
let defaultName = path.basename(cwd);
|
||||||
|
|
||||||
|
if (await fs.pathExists(packageJsonPath)) {
|
||||||
|
const packageJson = await fs.readJson(packageJsonPath);
|
||||||
|
defaultName = packageJson.name || defaultName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = await inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'name',
|
||||||
|
message: 'Deployment name:',
|
||||||
|
default: defaultName,
|
||||||
|
validate: (input) =>
|
||||||
|
/^[a-z0-9-]+$/.test(input) || 'Name must be lowercase alphanumeric with dashes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'buildCommand',
|
||||||
|
message: 'Build command:',
|
||||||
|
default: 'npm run build',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'startCommand',
|
||||||
|
message: 'Start command:',
|
||||||
|
default: 'npm start',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
name: 'port',
|
||||||
|
message: 'Port:',
|
||||||
|
default: 3000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
name: answers.name,
|
||||||
|
buildCommand: answers.buildCommand,
|
||||||
|
startCommand: answers.startCommand,
|
||||||
|
port: answers.port,
|
||||||
|
env: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.writeJson(configPath, config, { spaces: 2 });
|
||||||
|
|
||||||
|
console.log(chalk.green('✓ Created blackroad.json'));
|
||||||
|
console.log(chalk.dim('\nNext steps:'));
|
||||||
|
console.log(chalk.dim(' 1. Run: blackroad login'));
|
||||||
|
console.log(chalk.dim(' 2. Run: blackroad deploy'));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(chalk.red('Error:'), err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
44
cli/src/commands/list.ts
Normal file
44
cli/src/commands/list.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { getApiClient } from '../api';
|
||||||
|
|
||||||
|
export const listCommand = new Command('list')
|
||||||
|
.alias('ls')
|
||||||
|
.description('List all deployments')
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
const api = await getApiClient();
|
||||||
|
const response = await api.get('/api/deployments');
|
||||||
|
|
||||||
|
const deployments = response.data.deployments;
|
||||||
|
|
||||||
|
if (deployments.length === 0) {
|
||||||
|
console.log(chalk.dim('No deployments found'));
|
||||||
|
console.log(chalk.dim('\nRun: blackroad deploy'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(chalk.bold(`\n${deployments.length} deployment${deployments.length === 1 ? '' : 's'}:\n`));
|
||||||
|
|
||||||
|
for (const dep of deployments) {
|
||||||
|
const statusColor =
|
||||||
|
dep.status === 'running' ? chalk.green :
|
||||||
|
dep.status === 'stopped' ? chalk.yellow :
|
||||||
|
chalk.red;
|
||||||
|
|
||||||
|
console.log(chalk.bold(dep.name));
|
||||||
|
console.log(chalk.dim(' Status:'), statusColor(dep.status));
|
||||||
|
console.log(chalk.dim(' Image:'), dep.image);
|
||||||
|
console.log(chalk.dim(' Port:'), dep.port || 'N/A');
|
||||||
|
console.log(chalk.dim(' Created:'), new Date(dep.created_at).toLocaleString());
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.data?.error) {
|
||||||
|
console.error(chalk.red(err.response.data.error));
|
||||||
|
} else {
|
||||||
|
console.error(chalk.red('Error:'), err.message);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
58
cli/src/commands/login.ts
Normal file
58
cli/src/commands/login.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import inquirer from 'inquirer';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import ora from 'ora';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { loadConfig, saveConfig } from '../config';
|
||||||
|
import { resetApiClient } from '../api';
|
||||||
|
|
||||||
|
export const loginCommand = new Command('login')
|
||||||
|
.description('Login to BlackRoad Deploy')
|
||||||
|
.action(async () => {
|
||||||
|
try {
|
||||||
|
const config = await loadConfig();
|
||||||
|
|
||||||
|
const answers = await inquirer.prompt([
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
name: 'email',
|
||||||
|
message: 'Email:',
|
||||||
|
validate: (input) => input.includes('@') || 'Please enter a valid email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'password',
|
||||||
|
name: 'password',
|
||||||
|
message: 'Password:',
|
||||||
|
mask: '*',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const spinner = ora('Logging in...').start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${config.apiUrl}/api/auth/login`, {
|
||||||
|
email: answers.email,
|
||||||
|
password: answers.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
config.apiKey = response.data.apiKey;
|
||||||
|
config.email = answers.email;
|
||||||
|
await saveConfig(config);
|
||||||
|
resetApiClient();
|
||||||
|
|
||||||
|
spinner.succeed(chalk.green('Logged in successfully!'));
|
||||||
|
console.log(chalk.dim(`API Key: ${response.data.apiKey.substring(0, 20)}...`));
|
||||||
|
} catch (err: any) {
|
||||||
|
spinner.fail(chalk.red('Login failed'));
|
||||||
|
if (err.response?.data?.error) {
|
||||||
|
console.error(chalk.red(err.response.data.error));
|
||||||
|
} else {
|
||||||
|
console.error(chalk.red(err.message));
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(chalk.red('Error:'), err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
58
cli/src/commands/logs.ts
Normal file
58
cli/src/commands/logs.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { getApiClient } from '../api';
|
||||||
|
|
||||||
|
export const logsCommand = new Command('logs')
|
||||||
|
.description('View deployment logs')
|
||||||
|
.argument('<name>', 'Deployment name')
|
||||||
|
.option('-f, --follow', 'Follow log output')
|
||||||
|
.option('-n, --tail <lines>', 'Number of lines to show', '100')
|
||||||
|
.action(async (name, options) => {
|
||||||
|
try {
|
||||||
|
const api = await getApiClient();
|
||||||
|
const config = await api.defaults;
|
||||||
|
|
||||||
|
const url = `${config.baseURL}/api/logs/${name}?tail=${options.tail}&follow=${options.follow || false}`;
|
||||||
|
|
||||||
|
if (options.follow) {
|
||||||
|
console.log(chalk.dim(`Streaming logs for ${name}...`));
|
||||||
|
console.log(chalk.dim('Press Ctrl+C to stop\n'));
|
||||||
|
|
||||||
|
// For streaming, we need to use fetch or a streaming library
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: api.defaults.headers.Authorization as string,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error('No response body');
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const text = decoder.decode(value, { stream: true });
|
||||||
|
process.stdout.write(text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await api.get(`/api/logs/${name}?tail=${options.tail}`);
|
||||||
|
console.log(response.data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
console.error(chalk.red(`Deployment '${name}' not found`));
|
||||||
|
} else if (err.response?.data?.error) {
|
||||||
|
console.error(chalk.red(err.response.data.error));
|
||||||
|
} else {
|
||||||
|
console.error(chalk.red('Error:'), err.message);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
27
cli/src/commands/restart.ts
Normal file
27
cli/src/commands/restart.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import ora from 'ora';
|
||||||
|
import { getApiClient } from '../api';
|
||||||
|
|
||||||
|
export const restartCommand = new Command('restart')
|
||||||
|
.description('Restart a deployment')
|
||||||
|
.argument('<name>', 'Deployment name')
|
||||||
|
.action(async (name) => {
|
||||||
|
try {
|
||||||
|
const api = await getApiClient();
|
||||||
|
const spinner = ora('Restarting deployment...').start();
|
||||||
|
|
||||||
|
await api.post(`/api/deployments/${name}/restart`);
|
||||||
|
|
||||||
|
spinner.succeed(chalk.green(`Deployment '${name}' restarted successfully`));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
console.error(chalk.red(`Deployment '${name}' not found`));
|
||||||
|
} else if (err.response?.data?.error) {
|
||||||
|
console.error(chalk.red(err.response.data.error));
|
||||||
|
} else {
|
||||||
|
console.error(chalk.red('Error:'), err.message);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
51
cli/src/config.ts
Normal file
51
cli/src/config.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
const CONFIG_DIR = path.join(os.homedir(), '.blackroad');
|
||||||
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConfig(): Promise<Config> {
|
||||||
|
try {
|
||||||
|
await fs.ensureDir(CONFIG_DIR);
|
||||||
|
|
||||||
|
if (await fs.pathExists(CONFIG_FILE)) {
|
||||||
|
return await fs.readJson(CONFIG_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default config
|
||||||
|
const defaultConfig: Config = {
|
||||||
|
apiUrl: process.env.BLACKROAD_API_URL || 'https://deploy-api.blackroad.systems',
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.writeJson(CONFIG_FILE, defaultConfig, { spaces: 2 });
|
||||||
|
return defaultConfig;
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to load config: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveConfig(config: Config): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.ensureDir(CONFIG_DIR);
|
||||||
|
await fs.writeJson(CONFIG_FILE, config, { spaces: 2 });
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to save config: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApiKey(): Promise<string> {
|
||||||
|
const config = await loadConfig();
|
||||||
|
|
||||||
|
if (!config.apiKey) {
|
||||||
|
throw new Error('Not logged in. Run: blackroad login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.apiKey;
|
||||||
|
}
|
||||||
30
cli/src/index.ts
Normal file
30
cli/src/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { deployCommand } from './commands/deploy';
|
||||||
|
import { logsCommand } from './commands/logs';
|
||||||
|
import { listCommand } from './commands/list';
|
||||||
|
import { envCommand } from './commands/env';
|
||||||
|
import { deleteCommand } from './commands/delete';
|
||||||
|
import { restartCommand } from './commands/restart';
|
||||||
|
import { loginCommand } from './commands/login';
|
||||||
|
import { initCommand } from './commands/init';
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name('blackroad')
|
||||||
|
.description('BlackRoad Deploy - Self-hosted deployment platform')
|
||||||
|
.version('1.0.0');
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
program.addCommand(initCommand);
|
||||||
|
program.addCommand(loginCommand);
|
||||||
|
program.addCommand(deployCommand);
|
||||||
|
program.addCommand(logsCommand);
|
||||||
|
program.addCommand(listCommand);
|
||||||
|
program.addCommand(envCommand);
|
||||||
|
program.addCommand(restartCommand);
|
||||||
|
program.addCommand(deleteCommand);
|
||||||
|
|
||||||
|
program.parse();
|
||||||
19
cli/tsconfig.json
Normal file
19
cli/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
16
deployment-api/.env.example
Normal file
16
deployment-api/.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Database Configuration
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=blackroad_deploy
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=your_password_here
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Cloudflare Configuration (for DNS management)
|
||||||
|
CF_API_TOKEN=your_cloudflare_token
|
||||||
|
CF_ZONE_ID=848cf0b18d51e0170e0d1537aec3505a
|
||||||
|
|
||||||
|
# Docker Configuration
|
||||||
|
DOCKER_HOST=unix:///var/run/docker.sock
|
||||||
19
deployment-api/Dockerfile
Normal file
19
deployment-api/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
CMD ["npm", "start"]
|
||||||
35
deployment-api/package.json
Normal file
35
deployment-api/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "blackroad-deploy-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "BlackRoad Deploy - Self-hosted deployment platform API",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:migrate": "node dist/db/migrate.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"dockerode": "^4.0.2",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"ws": "^8.16.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.11.5",
|
||||||
|
"@types/pg": "^8.10.9",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
deployment-api/src/db/index.ts
Normal file
102
deployment-api/src/db/index.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Pool, QueryResult } from 'pg';
|
||||||
|
|
||||||
|
class Database {
|
||||||
|
private pool: Pool | null = null;
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
this.pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
database: process.env.DB_NAME || 'blackroad_deploy',
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
await this.pool.query('SELECT NOW()');
|
||||||
|
console.log('✅ Database connected');
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
await this.runMigrations();
|
||||||
|
}
|
||||||
|
|
||||||
|
async runMigrations() {
|
||||||
|
if (!this.pool) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
// Create deployments table
|
||||||
|
await this.pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS deployments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
image VARCHAR(512) NOT NULL,
|
||||||
|
container_id VARCHAR(255),
|
||||||
|
status VARCHAR(50) DEFAULT 'pending',
|
||||||
|
port INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create environment_variables table
|
||||||
|
await this.pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS environment_variables (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
deployment_id INTEGER REFERENCES deployments(id) ON DELETE CASCADE,
|
||||||
|
key VARCHAR(255) NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(deployment_id, key)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create deployment_logs table
|
||||||
|
await this.pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS deployment_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
deployment_id INTEGER REFERENCES deployments(id) ON DELETE CASCADE,
|
||||||
|
timestamp TIMESTAMP DEFAULT NOW(),
|
||||||
|
level VARCHAR(20) DEFAULT 'info',
|
||||||
|
message TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create domains table
|
||||||
|
await this.pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS domains (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
deployment_id INTEGER REFERENCES deployments(id) ON DELETE CASCADE,
|
||||||
|
domain VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create users table
|
||||||
|
await this.pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
api_key VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✅ Database migrations completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(text: string, params?: any[]): Promise<QueryResult> {
|
||||||
|
if (!this.pool) throw new Error('Database not initialized');
|
||||||
|
return this.pool.query(text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
if (this.pool) {
|
||||||
|
await this.pool.end();
|
||||||
|
console.log('Database connection closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = new Database();
|
||||||
88
deployment-api/src/index.ts
Normal file
88
deployment-api/src/index.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
import { deploymentRouter } from './routes/deployments';
|
||||||
|
import { logsRouter } from './routes/logs';
|
||||||
|
import { envRouter } from './routes/env';
|
||||||
|
import { authRouter } from './routes/auth';
|
||||||
|
import { domainsRouter } from './routes/domains';
|
||||||
|
import { db } from './db';
|
||||||
|
import { authenticate } from './middleware/auth';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = createServer(app);
|
||||||
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(morgan('combined'));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Health check (public)
|
||||||
|
app.get('/health', (req: Request, res: Response) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth routes (public)
|
||||||
|
app.use('/api/auth', authRouter);
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
app.use('/api/deployments', authenticate, deploymentRouter);
|
||||||
|
app.use('/api/logs', authenticate, logsRouter);
|
||||||
|
app.use('/api/env', authenticate, envRouter);
|
||||||
|
app.use('/api/domains', authenticate, domainsRouter);
|
||||||
|
|
||||||
|
// WebSocket for real-time logs
|
||||||
|
wss.on('connection', (ws, req) => {
|
||||||
|
console.log('WebSocket client connected');
|
||||||
|
|
||||||
|
ws.on('message', (message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.toString());
|
||||||
|
console.log('WebSocket message:', data);
|
||||||
|
// Handle log streaming subscriptions
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebSocket error:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log('WebSocket client disconnected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Initialize database and start server
|
||||||
|
db.initialize()
|
||||||
|
.then(() => {
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`🚀 BlackRoad Deploy API listening on port ${PORT}`);
|
||||||
|
console.log(`📊 Health: http://localhost:${PORT}/health`);
|
||||||
|
console.log(`🔌 WebSocket: ws://localhost:${PORT}/ws`);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to initialize database:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('SIGTERM received, shutting down...');
|
||||||
|
server.close(() => {
|
||||||
|
db.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
deployment-api/src/middleware/auth.ts
Normal file
41
deployment-api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { db } from '../db';
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
userId?: number;
|
||||||
|
userEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticate(
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Missing or invalid authorization header' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = authHeader.substring(7);
|
||||||
|
|
||||||
|
// Validate API key
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT id, email FROM users WHERE api_key = $1',
|
||||||
|
[apiKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(401).json({ error: 'Invalid API key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.userId = result.rows[0].id;
|
||||||
|
req.userEmail = result.rows[0].email;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Authentication error:', err);
|
||||||
|
res.status(500).json({ error: 'Authentication failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
124
deployment-api/src/routes/auth.ts
Normal file
124
deployment-api/src/routes/auth.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { db } from '../db';
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
// Register new user
|
||||||
|
authRouter.post('/register', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
// Generate API key
|
||||||
|
const apiKey = 'br_' + crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
// Insert user
|
||||||
|
const result = await db.query(
|
||||||
|
'INSERT INTO users (email, password_hash, api_key) VALUES ($1, $2, $3) RETURNING id, email, api_key',
|
||||||
|
[email, passwordHash, apiKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: result.rows[0].id,
|
||||||
|
email: result.rows[0].email,
|
||||||
|
},
|
||||||
|
apiKey: result.rows[0].api_key,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(400).json({ error: 'Email already registered' });
|
||||||
|
}
|
||||||
|
console.error('Registration error:', err);
|
||||||
|
res.status(500).json({ error: 'Registration failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
authRouter.post('/login', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT id, email, password_hash, api_key FROM users WHERE email = $1',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.rows[0];
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
apiKey: user.api_key,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
res.status(500).json({ error: 'Login failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regenerate API key
|
||||||
|
authRouter.post('/regenerate-key', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Email and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT id, password_hash FROM users WHERE email = $1',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, result.rows[0].password_hash);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new API key
|
||||||
|
const apiKey = 'br_' + crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
'UPDATE users SET api_key = $1 WHERE id = $2',
|
||||||
|
[apiKey, result.rows[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ apiKey });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Key regeneration error:', err);
|
||||||
|
res.status(500).json({ error: 'Key regeneration failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
239
deployment-api/src/routes/deployments.ts
Normal file
239
deployment-api/src/routes/deployments.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import Docker from 'dockerode';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { AuthRequest } from '../middleware/auth';
|
||||||
|
|
||||||
|
export const deploymentRouter = Router();
|
||||||
|
const docker = new Docker();
|
||||||
|
|
||||||
|
// List all deployments
|
||||||
|
deploymentRouter.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT id, name, image, status, port, created_at, updated_at FROM deployments ORDER BY created_at DESC'
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ deployments: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('List deployments error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to list deployments' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get deployment by name
|
||||||
|
deploymentRouter.get('/:name', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT * FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get environment variables
|
||||||
|
const envResult = await db.query(
|
||||||
|
'SELECT key, value FROM environment_variables WHERE deployment_id = $1',
|
||||||
|
[result.rows[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get domains
|
||||||
|
const domainsResult = await db.query(
|
||||||
|
'SELECT domain, status FROM domains WHERE deployment_id = $1',
|
||||||
|
[result.rows[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
deployment: result.rows[0],
|
||||||
|
env: envResult.rows,
|
||||||
|
domains: domainsResult.rows,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get deployment error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get deployment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create deployment
|
||||||
|
deploymentRouter.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, image, env = {}, port = 3000 } = req.body;
|
||||||
|
|
||||||
|
if (!name || !image) {
|
||||||
|
return res.status(400).json({ error: 'Name and image required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull image
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
docker.pull(image, (err: any, stream: any) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
docker.modem.followProgress(stream, (err: any) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create container
|
||||||
|
const container = await docker.createContainer({
|
||||||
|
name: `br-${name}`,
|
||||||
|
Image: image,
|
||||||
|
Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
|
||||||
|
ExposedPorts: {
|
||||||
|
[`${port}/tcp`]: {},
|
||||||
|
},
|
||||||
|
HostConfig: {
|
||||||
|
PortBindings: {
|
||||||
|
[`${port}/tcp`]: [{ HostPort: '0' }], // Random host port
|
||||||
|
},
|
||||||
|
RestartPolicy: {
|
||||||
|
Name: 'unless-stopped',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start container
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
// Get assigned port
|
||||||
|
const containerInfo = await container.inspect();
|
||||||
|
const hostPort = containerInfo.NetworkSettings.Ports[`${port}/tcp`]?.[0]?.HostPort;
|
||||||
|
|
||||||
|
// Save deployment
|
||||||
|
const result = await db.query(
|
||||||
|
'INSERT INTO deployments (name, image, container_id, status, port) VALUES ($1, $2, $3, $4, $5) RETURNING *',
|
||||||
|
[name, image, container.id, 'running', hostPort]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deploymentId = result.rows[0].id;
|
||||||
|
|
||||||
|
// Save environment variables
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO environment_variables (deployment_id, key, value) VALUES ($1, $2, $3)',
|
||||||
|
[deploymentId, key, value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default domain
|
||||||
|
const domain = `${name}.blackroad.systems`;
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO domains (deployment_id, domain, status) VALUES ($1, $2, $3)',
|
||||||
|
[deploymentId, domain, 'pending']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log deployment
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO deployment_logs (deployment_id, level, message) VALUES ($1, $2, $3)',
|
||||||
|
[deploymentId, 'info', 'Deployment created']
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
deployment: result.rows[0],
|
||||||
|
domain,
|
||||||
|
message: 'Deployment created successfully',
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Create deployment error:', err);
|
||||||
|
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(400).json({ error: 'Deployment name already exists' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to create deployment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart deployment
|
||||||
|
deploymentRouter.post('/:name/restart', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT container_id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = docker.getContainer(result.rows[0].container_id);
|
||||||
|
await container.restart();
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
'UPDATE deployments SET status = $1, updated_at = NOW() WHERE name = $2',
|
||||||
|
['running', name]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Deployment restarted successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Restart deployment error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to restart deployment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop deployment
|
||||||
|
deploymentRouter.post('/:name/stop', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT container_id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = docker.getContainer(result.rows[0].container_id);
|
||||||
|
await container.stop();
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
'UPDATE deployments SET status = $1, updated_at = NOW() WHERE name = $2',
|
||||||
|
['stopped', name]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Deployment stopped successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Stop deployment error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to stop deployment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete deployment
|
||||||
|
deploymentRouter.delete('/:name', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT id, container_id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop and remove container
|
||||||
|
const container = docker.getContainer(result.rows[0].container_id);
|
||||||
|
try {
|
||||||
|
await container.stop();
|
||||||
|
} catch (err) {
|
||||||
|
// Container might already be stopped
|
||||||
|
}
|
||||||
|
await container.remove();
|
||||||
|
|
||||||
|
// Delete from database (cascades to env vars, logs, domains)
|
||||||
|
await db.query('DELETE FROM deployments WHERE id = $1', [result.rows[0].id]);
|
||||||
|
|
||||||
|
res.json({ message: 'Deployment deleted successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete deployment error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete deployment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
91
deployment-api/src/routes/domains.ts
Normal file
91
deployment-api/src/routes/domains.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { AuthRequest } from '../middleware/auth';
|
||||||
|
|
||||||
|
export const domainsRouter = Router();
|
||||||
|
|
||||||
|
// Get domains for a deployment
|
||||||
|
domainsRouter.get('/:name', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
const deploymentResult = await db.query(
|
||||||
|
'SELECT id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deploymentResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT domain, status, created_at FROM domains WHERE deployment_id = $1',
|
||||||
|
[deploymentResult.rows[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ domains: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get domains error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get domains' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add custom domain
|
||||||
|
domainsRouter.post('/:name', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
const { domain } = req.body;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return res.status(400).json({ error: 'Domain required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deploymentResult = await db.query(
|
||||||
|
'SELECT id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deploymentResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO domains (deployment_id, domain, status) VALUES ($1, $2, $3)',
|
||||||
|
[deploymentResult.rows[0].id, domain, 'pending']
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Domain added successfully' });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(400).json({ error: 'Domain already exists' });
|
||||||
|
}
|
||||||
|
console.error('Add domain error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to add domain' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete domain
|
||||||
|
domainsRouter.delete('/:name/:domain', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, domain } = req.params;
|
||||||
|
|
||||||
|
const deploymentResult = await db.query(
|
||||||
|
'SELECT id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deploymentResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
'DELETE FROM domains WHERE deployment_id = $1 AND domain = $2',
|
||||||
|
[deploymentResult.rows[0].id, domain]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Domain deleted successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete domain error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete domain' });
|
||||||
|
}
|
||||||
|
});
|
||||||
96
deployment-api/src/routes/env.ts
Normal file
96
deployment-api/src/routes/env.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { AuthRequest } from '../middleware/auth';
|
||||||
|
|
||||||
|
export const envRouter = Router();
|
||||||
|
|
||||||
|
// Get environment variables for a deployment
|
||||||
|
envRouter.get('/:name', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
const deploymentResult = await db.query(
|
||||||
|
'SELECT id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deploymentResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT key, value FROM environment_variables WHERE deployment_id = $1',
|
||||||
|
[deploymentResult.rows[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
result.rows.forEach((row) => {
|
||||||
|
env[row.key] = row.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ env });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get env error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get environment variables' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set environment variable
|
||||||
|
envRouter.post('/:name', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
const { key, value } = req.body;
|
||||||
|
|
||||||
|
if (!key || value === undefined) {
|
||||||
|
return res.status(400).json({ error: 'Key and value required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deploymentResult = await db.query(
|
||||||
|
'SELECT id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deploymentResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO environment_variables (deployment_id, key, value)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (deployment_id, key)
|
||||||
|
DO UPDATE SET value = $3`,
|
||||||
|
[deploymentResult.rows[0].id, key, value]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Environment variable set successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Set env error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to set environment variable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete environment variable
|
||||||
|
envRouter.delete('/:name/:key', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, key } = req.params;
|
||||||
|
|
||||||
|
const deploymentResult = await db.query(
|
||||||
|
'SELECT id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deploymentResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
'DELETE FROM environment_variables WHERE deployment_id = $1 AND key = $2',
|
||||||
|
[deploymentResult.rows[0].id, key]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Environment variable deleted successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete env error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete environment variable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
91
deployment-api/src/routes/logs.ts
Normal file
91
deployment-api/src/routes/logs.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import Docker from 'dockerode';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { AuthRequest } from '../middleware/auth';
|
||||||
|
|
||||||
|
export const logsRouter = Router();
|
||||||
|
const docker = new Docker();
|
||||||
|
|
||||||
|
// Get logs for a deployment
|
||||||
|
logsRouter.get('/:name', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
const { tail = '100', follow = 'false' } = req.query;
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT container_id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = docker.getContainer(result.rows[0].container_id);
|
||||||
|
|
||||||
|
if (follow === 'true') {
|
||||||
|
// Stream logs
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.setHeader('Transfer-Encoding', 'chunked');
|
||||||
|
|
||||||
|
const stream = await container.logs({
|
||||||
|
follow: true,
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
tail: parseInt(tail as string),
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('data', (chunk) => {
|
||||||
|
res.write(chunk.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
stream.destroy();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Get static logs
|
||||||
|
const logs = await container.logs({
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
tail: parseInt(tail as string),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.send(logs.toString());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get logs error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get logs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get deployment logs from database
|
||||||
|
logsRouter.get('/:name/history', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
const { limit = '100', offset = '0' } = req.query;
|
||||||
|
|
||||||
|
const deploymentResult = await db.query(
|
||||||
|
'SELECT id FROM deployments WHERE name = $1',
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deploymentResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Deployment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT timestamp, level, message FROM deployment_logs WHERE deployment_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3',
|
||||||
|
[deploymentResult.rows[0].id, parseInt(limit as string), parseInt(offset as string)]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ logs: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get deployment logs error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get deployment logs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
19
deployment-api/tsconfig.json
Normal file
19
deployment-api/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
68
docker-compose.yml
Normal file
68
docker-compose.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: blackroad_deploy
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Deployment API
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./deployment-api
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: blackroad_deploy
|
||||||
|
DB_USER: postgres
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||||
|
PORT: 3000
|
||||||
|
CF_API_TOKEN: ${CF_API_TOKEN}
|
||||||
|
CF_ZONE_ID: ${CF_ZONE_ID}
|
||||||
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# Caddy Reverse Proxy
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
63
scripts/setup-pi-tunnel.sh
Normal file
63
scripts/setup-pi-tunnel.sh
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# BlackRoad Deploy - Raspberry Pi Tunnel Setup
|
||||||
|
# Run this on your Raspberry Pi (192.168.4.49)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🥧 BlackRoad Deploy - Raspberry Pi Setup"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# Detect architecture
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
echo "Architecture: $ARCH"
|
||||||
|
|
||||||
|
# Install cloudflared
|
||||||
|
if ! command -v cloudflared &> /dev/null; then
|
||||||
|
echo "📥 Installing cloudflared..."
|
||||||
|
|
||||||
|
if [ "$ARCH" = "aarch64" ]; then
|
||||||
|
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64 -o /tmp/cloudflared
|
||||||
|
elif [ "$ARCH" = "armv7l" ]; then
|
||||||
|
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm -o /tmp/cloudflared
|
||||||
|
else
|
||||||
|
echo "❌ Unsupported architecture: $ARCH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x /tmp/cloudflared
|
||||||
|
sudo mv /tmp/cloudflared /usr/local/bin/
|
||||||
|
echo "✅ cloudflared installed"
|
||||||
|
else
|
||||||
|
echo "✅ cloudflared already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Docker
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "🐳 Installing Docker..."
|
||||||
|
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
|
||||||
|
sudo sh /tmp/get-docker.sh
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
echo "✅ Docker installed"
|
||||||
|
else
|
||||||
|
echo "✅ Docker already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Docker Compose
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo "📦 Installing Docker Compose..."
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y docker-compose
|
||||||
|
echo "✅ Docker Compose installed"
|
||||||
|
else
|
||||||
|
echo "✅ Docker Compose already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Setup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Log in to Cloudflare: cloudflared tunnel login"
|
||||||
|
echo " 2. Create tunnel: cloudflared tunnel create blackroad-pi"
|
||||||
|
echo " 3. Configure tunnel (see TUNNEL_SETUP.md)"
|
||||||
|
echo " 4. Start tunnel: sudo systemctl start cloudflared"
|
||||||
53
scripts/setup-tunnel.sh
Normal file
53
scripts/setup-tunnel.sh
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# BlackRoad Deploy - Automated Tunnel Setup
|
||||||
|
# Uses your existing Cloudflare credentials
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CF_TOKEN='yP5h0HvsXX0BpHLs01tLmgtTbQurIKPL4YnQfIwy'
|
||||||
|
CF_ZONE='848cf0b18d51e0170e0d1537aec3505a'
|
||||||
|
TUNNEL_ID='72f1d60c-dcf2-4499-b02d-d7a063018b33'
|
||||||
|
|
||||||
|
echo "☁️ BlackRoad Deploy - Cloudflare Tunnel Setup"
|
||||||
|
echo "=============================================="
|
||||||
|
|
||||||
|
# Check if cloudflared is installed
|
||||||
|
if ! command -v cloudflared &> /dev/null; then
|
||||||
|
echo "📥 Installing cloudflared..."
|
||||||
|
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /tmp/cloudflared
|
||||||
|
chmod +x /tmp/cloudflared
|
||||||
|
sudo mv /tmp/cloudflared /usr/local/bin/
|
||||||
|
echo "✅ cloudflared installed"
|
||||||
|
else
|
||||||
|
echo "✅ cloudflared already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add DNS records
|
||||||
|
echo ""
|
||||||
|
echo "☁️ Adding DNS record for deploy-api.blackroad.systems"
|
||||||
|
cloudflared tunnel route dns $TUNNEL_ID deploy-api.blackroad.systems
|
||||||
|
|
||||||
|
echo "☁️ Adding DNS record for *.blackroad.systems"
|
||||||
|
cloudflared tunnel route dns $TUNNEL_ID "*.blackroad.systems"
|
||||||
|
|
||||||
|
# Install as service
|
||||||
|
echo ""
|
||||||
|
echo "🚀 Installing tunnel as systemd service..."
|
||||||
|
sudo cloudflared service install
|
||||||
|
|
||||||
|
# Start tunnel
|
||||||
|
echo "▶️ Starting tunnel..."
|
||||||
|
sudo systemctl start cloudflared
|
||||||
|
sudo systemctl enable cloudflared
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
echo ""
|
||||||
|
echo "📊 Tunnel status:"
|
||||||
|
sudo systemctl status cloudflared --no-pager
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Tunnel setup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "🔍 Verify with:"
|
||||||
|
echo " curl https://deploy-api.blackroad.systems/health"
|
||||||
Reference in New Issue
Block a user