mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Merge pull request #1 from Dvorinka/feat/platform-overhaul
feat: platform overhaul — databases, cron, backups, previews, scaling, HA, security center
This commit is contained in:
738 files changed
+30583
-103390
No files matched your search
@@ -1,118 +0,0 @@
|
|||||||
# Canonical production environment template for:
|
|
||||||
# - self-hosted full stack via ./start-unified.sh prod
|
|
||||||
# - frontend deployment on Vercel (copy VITE_* and FRONTEND_URL/BACKEND_URL values)
|
|
||||||
# - backend deployment on Railway (copy backend/auth/database values)
|
|
||||||
|
|
||||||
ENVIRONMENT=production
|
|
||||||
|
|
||||||
# Public application URLs
|
|
||||||
FRONTEND_URL=https://containr-web.vercel.app
|
|
||||||
BACKEND_URL=https://containr-api.up.railway.app
|
|
||||||
VITE_API_URL=https://containr-api.up.railway.app
|
|
||||||
VITE_AUTH_URL=https://containr-api.up.railway.app/api/auth
|
|
||||||
BETTER_AUTH_URL=https://containr-api.up.railway.app
|
|
||||||
BETTER_AUTH_TRUSTED_ORIGINS=https://containr-web.vercel.app,https://containr-api.up.railway.app
|
|
||||||
CORS_ORIGINS=https://containr-web.vercel.app,https://containr-api.up.railway.app
|
|
||||||
CORS_CREDENTIALS=true
|
|
||||||
|
|
||||||
# Self-hosted domain settings
|
|
||||||
DOMAIN=containr.example.com
|
|
||||||
ACME_EMAIL=[email protected]
|
|
||||||
TRAEFIK_API_INSECURE=false
|
|
||||||
TRAEFIK_AUTH=admin:$$apr1$$replace_me$$replace_me
|
|
||||||
|
|
||||||
# Backend runtime
|
|
||||||
PORT=8080
|
|
||||||
HOST=0.0.0.0
|
|
||||||
AUTH_PORT=3001
|
|
||||||
BETTER_AUTH_ENABLED=true
|
|
||||||
BETTER_AUTH_ENTRYPOINT=auth/src/server.js
|
|
||||||
BETTER_AUTH_NODE_BINARY=node
|
|
||||||
BETTER_AUTH_STARTUP_TIMEOUT=20s
|
|
||||||
BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001
|
|
||||||
BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session
|
|
||||||
MAX_REQUEST_BODY_BYTES=10485760
|
|
||||||
READ_TIMEOUT=30s
|
|
||||||
WRITE_TIMEOUT=30s
|
|
||||||
IDLE_TIMEOUT=60s
|
|
||||||
SHUTDOWN_TIMEOUT=30s
|
|
||||||
AUTO_MIGRATE=true
|
|
||||||
MIGRATION_LOCK_TIMEOUT=5m
|
|
||||||
SEED_DATA_ON_START=false
|
|
||||||
TRUSTED_PROXY_CIDR=172.20.0.0/16
|
|
||||||
LOG_LEVEL=info
|
|
||||||
LOG_FORMAT=json
|
|
||||||
LOG_OUTPUT=stdout
|
|
||||||
DEBUG=false
|
|
||||||
|
|
||||||
# Secrets
|
|
||||||
JWT_SECRET=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG
|
|
||||||
BETTER_AUTH_SECRET=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG
|
|
||||||
BETTER_AUTH_INTERNAL_TOKEN=CHANGE_ME_INTERNAL_AUTH_TOKEN
|
|
||||||
CONTAINR_AGENT_AUTH_TOKEN=CHANGE_ME_AGENT_AUTH_TOKEN
|
|
||||||
# Optional rotating token list
|
|
||||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
|
||||||
|
|
||||||
# Cookies
|
|
||||||
COOKIE_SECURE=true
|
|
||||||
COOKIE_DOMAIN=
|
|
||||||
COOKIE_PATH=/
|
|
||||||
COOKIE_SAME_SITE=lax
|
|
||||||
|
|
||||||
# Self-hosted database defaults.
|
|
||||||
# Railway: replace DATABASE_URL and REDIS_URL with Railway-provided values.
|
|
||||||
POSTGRES_DB=containr
|
|
||||||
POSTGRES_USER=containr_user
|
|
||||||
POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
|
|
||||||
DATABASE_URL=postgres://containr_user:CHANGE_ME_POSTGRES_PASSWORD@postgres:5432/containr?sslmode=disable
|
|
||||||
MAX_CONNECTIONS=50
|
|
||||||
MAX_IDLE_CONNECTIONS=10
|
|
||||||
CONN_MAX_LIFETIME=10m
|
|
||||||
CONN_MAX_IDLE_TIME=5m
|
|
||||||
|
|
||||||
REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD
|
|
||||||
REDIS_URL=redis://:CHANGE_ME_REDIS_PASSWORD@redis:6379/0
|
|
||||||
|
|
||||||
# Explicit DB settings for the embedded Better Auth runtime.
|
|
||||||
DB_HOST=postgres
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_NAME=containr
|
|
||||||
DB_USER=containr_user
|
|
||||||
DB_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
|
|
||||||
|
|
||||||
# Optional OAuth providers
|
|
||||||
GITHUB_CLIENT_ID=
|
|
||||||
GITHUB_CLIENT_SECRET=
|
|
||||||
GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID
|
|
||||||
GITLAB_CLIENT_SECRET=PLACEHOLDER_GITLAB_CLIENT_SECRET
|
|
||||||
GITLAB_OAUTH_AUTHORIZE_URL=https://gitlab.com/oauth/authorize
|
|
||||||
GITLAB_OAUTH_TOKEN_URL=https://gitlab.com/oauth/token
|
|
||||||
GITLAB_OAUTH_USERINFO_URL=https://gitlab.com/api/v4/user
|
|
||||||
BITBUCKET_CLIENT_ID=PLACEHOLDER_BITBUCKET_CLIENT_ID
|
|
||||||
BITBUCKET_CLIENT_SECRET=PLACEHOLDER_BITBUCKET_CLIENT_SECRET
|
|
||||||
BITBUCKET_OAUTH_AUTHORIZE_URL=https://bitbucket.org/site/oauth2/authorize
|
|
||||||
BITBUCKET_OAUTH_TOKEN_URL=https://bitbucket.org/site/oauth2/access_token
|
|
||||||
BITBUCKET_OAUTH_USERINFO_URL=https://api.bitbucket.org/2.0/user
|
|
||||||
BITBUCKET_OAUTH_EMAILS_URL=https://api.bitbucket.org/2.0/user/emails
|
|
||||||
GITEA_CLIENT_ID=PLACEHOLDER_GITEA_CLIENT_ID
|
|
||||||
GITEA_CLIENT_SECRET=PLACEHOLDER_GITEA_CLIENT_SECRET
|
|
||||||
GITEA_OAUTH_AUTHORIZE_URL=https://gitea.example.com/login/oauth/authorize
|
|
||||||
GITEA_OAUTH_TOKEN_URL=https://gitea.example.com/login/oauth/access_token
|
|
||||||
GITEA_OAUTH_USERINFO_URL=https://gitea.example.com/api/v1/user
|
|
||||||
|
|
||||||
# Repo sync / SCM integrations
|
|
||||||
GITHUB_APP_ID=
|
|
||||||
GITHUB_APP_SLUG=
|
|
||||||
GITHUB_APP_PRIVATE_KEY=
|
|
||||||
GITHUB_APP_BASE_URL=https://api.github.com
|
|
||||||
GITLAB_API_URL=https://gitlab.com/api/v4
|
|
||||||
GITLAB_BASE_URL=https://gitlab.com
|
|
||||||
BITBUCKET_API_URL=https://api.bitbucket.org/2.0
|
|
||||||
BITBUCKET_BASE_URL=https://bitbucket.org
|
|
||||||
GITEA_BASE_URL=https://gitea.example.com
|
|
||||||
|
|
||||||
# Optional analytics / observability
|
|
||||||
UMAMI_BASE_URL=
|
|
||||||
UMAMI_API_KEY=
|
|
||||||
UMAMI_WEBSITE_ID=
|
|
||||||
CLOUDFLARED_TOKEN=
|
|
||||||
@@ -75,3 +75,72 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
smoke:
|
||||||
|
name: Compose boot smoke
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Boot stack
|
||||||
|
run: |
|
||||||
|
export BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
||||||
|
export BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32)
|
||||||
|
docker compose -f infra/docker-compose.yml up -d --build postgres redis backend frontend traefik
|
||||||
|
|
||||||
|
- name: Wait for backend health
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf http://localhost:8082/health; then exit 0; fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
docker compose -f infra/docker-compose.yml logs --tail 50 backend
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Check frontend responds
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf http://localhost:3000/ -o /dev/null; then exit 0; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
docker compose -f infra/docker-compose.yml logs --tail 50 frontend
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Teardown
|
||||||
|
if: always()
|
||||||
|
run: docker compose -f infra/docker-compose.yml down -v
|
||||||
|
|
||||||
|
images:
|
||||||
|
name: Publish images to GHCR
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [backend, frontend, smoke]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push backend
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: app/backend
|
||||||
|
push: true
|
||||||
|
tags: ghcr.io/${{ github.repository }}-backend:${{ github.ref_name }},ghcr.io/${{ github.repository }}-backend:latest
|
||||||
|
|
||||||
|
- name: Build and push frontend
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: app/frontend
|
||||||
|
push: true
|
||||||
|
tags: ghcr.io/${{ github.repository }}-frontend:${{ github.ref_name }},ghcr.io/${{ github.repository }}-frontend:latest
|
||||||
+14
-3
@@ -13,7 +13,7 @@ build/
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Keep internal build system code
|
# Keep internal build system code
|
||||||
!internal/build/
|
!**/internal/build/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
@@ -21,6 +21,11 @@ build/
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
.env.prod
|
||||||
|
.env.production
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
!.env.production.example
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
@@ -69,7 +74,7 @@ jspm_packages/
|
|||||||
|
|
||||||
# Gatsby files
|
# Gatsby files
|
||||||
.cache/
|
.cache/
|
||||||
public
|
/public/
|
||||||
|
|
||||||
# Storybook build outputs
|
# Storybook build outputs
|
||||||
.out
|
.out
|
||||||
@@ -109,4 +114,10 @@ test-results.xml
|
|||||||
.opencode
|
.opencode
|
||||||
.claude
|
.claude
|
||||||
.desloppify
|
.desloppify
|
||||||
bin
|
bin
|
||||||
|
# Tool session dirs
|
||||||
|
.jj/
|
||||||
|
.playwright-cli/
|
||||||
|
|
||||||
|
# Local reference checkouts (not project code)
|
||||||
|
ref/
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Containr Design System
|
|
||||||
|
|
||||||
## Design Context
|
|
||||||
|
|
||||||
### Users
|
|
||||||
- **Primary**: DevOps engineers, system administrators, and developers managing container infrastructure
|
|
||||||
- **Secondary**: Small team leads and solo developers looking for self-hosted container management
|
|
||||||
- **Context**: Users access the dashboard to deploy, monitor, and manage containers. They need quick access to critical information, real-time metrics, and intuitive workflows.
|
|
||||||
- **Job to be done**: "I want to deploy and manage containers without the complexity of Kubernetes or expensive cloud platforms."
|
|
||||||
|
|
||||||
### Brand Personality
|
|
||||||
- **Voice**: Professional yet approachable, technical but not intimidating
|
|
||||||
- **Three words**: Modern, reliable, empowering
|
|
||||||
- **Emotional goals**: Users should feel in control, confident, and efficient. The interface should reduce anxiety around infrastructure management.
|
|
||||||
|
|
||||||
### Aesthetic Direction
|
|
||||||
- **Visual tone**: Dark mode SaaS dashboard with subtle gradients and refined details
|
|
||||||
- **References**: Railway (primary inspiration) - clean, modern, professional; Vercel - polished developer experience; Linear - refined typography and spacing
|
|
||||||
- **Anti-references**: Avoid cluttered interfaces, overly bright colors, generic Bootstrap aesthetics
|
|
||||||
- **Theme**: Dark mode with accent colors for status indicators (emerald for success, amber for warning, rose for errors)
|
|
||||||
|
|
||||||
### Design Principles
|
|
||||||
1. **Clarity over density** - Every element serves a purpose, generous whitespace reduces cognitive load
|
|
||||||
2. **Progressive disclosure** - Surface critical information first, details available on interaction
|
|
||||||
3. **Consistent visual language** - Reusable patterns for cards, buttons, forms, and status indicators
|
|
||||||
4. **Performance perception** - Loading states and transitions that feel snappy and responsive
|
|
||||||
5. **Accessibility by default** - WCAG 2.1 AA compliance, readable contrast ratios, keyboard navigation
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
- **Headlines**: Geist (modern, technical, distinctive)
|
|
||||||
- **Body**: Inter (highly readable at all sizes)
|
|
||||||
- **Code/Mono**: JetBrains Mono (developer-friendly)
|
|
||||||
|
|
||||||
### Color System
|
|
||||||
- **Background**: #0a0a0a (near-black with subtle warmth)
|
|
||||||
- **Surface**: #141414 (elevated cards and containers)
|
|
||||||
- **Border**: #1f1f1f (subtle separation)
|
|
||||||
- **Primary accent**: #6366f1 (indigo - modern tech feel)
|
|
||||||
- **Success**: #10b981 (emerald)
|
|
||||||
- **Warning**: #f59e0b (amber)
|
|
||||||
- **Danger**: #f43f5e (rose)
|
|
||||||
|
|
||||||
### Spacing Scale
|
|
||||||
- Use 4px base unit
|
|
||||||
- Component padding: 16px-24px
|
|
||||||
- Section gaps: 32px-48px
|
|
||||||
- Page padding: 24px-48px (responsive)
|
|
||||||
-288
@@ -1,288 +0,0 @@
|
|||||||
# Autoscaling with Cloudflare Tunnel
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document explains how autoscaling works when using Cloudflare Tunnel with the Containr application.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Internet → Cloudflare Edge → Cloudflare Tunnel → Traefik → Backend Services
|
|
||||||
```
|
|
||||||
|
|
||||||
## Autoscaling Considerations
|
|
||||||
|
|
||||||
### 1. Cloudflare Tunnel Limitations
|
|
||||||
|
|
||||||
**Cloudflare Tunnel itself does NOT provide autoscaling.** It's a secure tunneling service that:
|
|
||||||
- Creates a persistent connection between your infrastructure and Cloudflare's edge
|
|
||||||
- Routes traffic through Cloudflare's global network
|
|
||||||
- Provides DDoS protection and CDN features
|
|
||||||
|
|
||||||
### 2. Where Autoscaling Happens
|
|
||||||
|
|
||||||
Autoscaling must be implemented at different layers:
|
|
||||||
|
|
||||||
#### A. Container Level (Docker Swarm/Kubernetes)
|
|
||||||
```yaml
|
|
||||||
# Example with Docker Swarm
|
|
||||||
backend:
|
|
||||||
image: containr-backend
|
|
||||||
deploy:
|
|
||||||
replicas: 3
|
|
||||||
update_config:
|
|
||||||
parallelism: 1
|
|
||||||
delay: 10s
|
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
```
|
|
||||||
|
|
||||||
#### B. Application Level (Load Balancing)
|
|
||||||
Traefik automatically load balances between multiple backend instances:
|
|
||||||
```yaml
|
|
||||||
# Multiple backend containers
|
|
||||||
backend-1:
|
|
||||||
# ... backend config
|
|
||||||
labels:
|
|
||||||
- "traefik.http.services.backend.loadbalancer.server.port=8080"
|
|
||||||
|
|
||||||
backend-2:
|
|
||||||
# ... backend config
|
|
||||||
labels:
|
|
||||||
- "traefik.http.services.backend.loadbalancer.server.port=8080"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### C. Cloud Level (Cloudflare Load Balancer - Paid Feature)
|
|
||||||
For true autoscaling, you'd need:
|
|
||||||
- Multiple deployments in different regions
|
|
||||||
- Cloudflare Load Balancer ($$$/month)
|
|
||||||
- Health checks and failover
|
|
||||||
|
|
||||||
## Implementation Options
|
|
||||||
|
|
||||||
### Option 1: Docker Swarm (Recommended for Single Host)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Initialize Docker Swarm
|
|
||||||
docker swarm init
|
|
||||||
|
|
||||||
# Deploy with autoscaling
|
|
||||||
docker stack deploy -c docker-compose.yml containr
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Kubernetes
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# deployment.yaml
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: backend
|
|
||||||
spec:
|
|
||||||
replicas: 3
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: backend
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: backend
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: backend
|
|
||||||
image: containr-backend
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
---
|
|
||||||
apiVersion: autoscaling/v2
|
|
||||||
kind: HorizontalPodAutoscaler
|
|
||||||
metadata:
|
|
||||||
name: backend-hpa
|
|
||||||
spec:
|
|
||||||
scaleTargetRef:
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
name: backend
|
|
||||||
minReplicas: 2
|
|
||||||
maxReplicas: 10
|
|
||||||
metrics:
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: 70
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 3: Manual Scaling with Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# scale-backend.sh
|
|
||||||
|
|
||||||
scale_up() {
|
|
||||||
local current=$(docker ps --filter "name=containr-backend" --format "table {{.Names}}" | wc -l)
|
|
||||||
local target=$((current + 1))
|
|
||||||
|
|
||||||
echo "Scaling backend to $target instances..."
|
|
||||||
|
|
||||||
for i in $(seq 1 $target); do
|
|
||||||
docker run -d \
|
|
||||||
--name containr-backend-$i \
|
|
||||||
--network containr_containr-network \
|
|
||||||
-e DATABASE_URL="..." \
|
|
||||||
-e REDIS_URL="..." \
|
|
||||||
containr-backend
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
scale_down() {
|
|
||||||
local current=$(docker ps --filter "name=containr-backend" --format "table {{.Names}}" | wc -l)
|
|
||||||
local target=$((current - 1))
|
|
||||||
|
|
||||||
if [ $target -lt 1 ]; then
|
|
||||||
echo "Cannot scale below 1 instance"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Scaling backend to $target instances..."
|
|
||||||
docker stop containr-backend-$target
|
|
||||||
docker rm containr-backend-$target
|
|
||||||
}
|
|
||||||
|
|
||||||
case "$1" in
|
|
||||||
up) scale_up ;;
|
|
||||||
down) scale_down ;;
|
|
||||||
*) echo "Usage: $0 [up|down]" ;;
|
|
||||||
esac
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring and Metrics
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
All services include health checks:
|
|
||||||
```yaml
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
```
|
|
||||||
|
|
||||||
### Metrics Collection
|
|
||||||
Traefik provides Prometheus metrics:
|
|
||||||
```yaml
|
|
||||||
# In docker-compose.yml
|
|
||||||
command:
|
|
||||||
- "--metrics.prometheus=true"
|
|
||||||
- "--metrics.prometheus.addentrypointslabels=true"
|
|
||||||
- "--metrics.prometheus.addserviceslabels=true"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scaling Triggers
|
|
||||||
Monitor these metrics for scaling decisions:
|
|
||||||
- CPU usage (> 70%)
|
|
||||||
- Memory usage (> 80%)
|
|
||||||
- Response time (> 500ms)
|
|
||||||
- Error rate (> 5%)
|
|
||||||
- Queue depth (if using message queues)
|
|
||||||
|
|
||||||
## Production Recommendations
|
|
||||||
|
|
||||||
### 1. Use Docker Swarm or Kubernetes
|
|
||||||
- Better orchestration
|
|
||||||
- Built-in load balancing
|
|
||||||
- Health management
|
|
||||||
- Rolling updates
|
|
||||||
|
|
||||||
### 2. Implement Horizontal Pod Autoscaler (HPA)
|
|
||||||
- Automatic scaling based on metrics
|
|
||||||
- Min/max replica limits
|
|
||||||
- Configurable thresholds
|
|
||||||
|
|
||||||
### 3. Use Cloudflare Load Balancer (if budget allows)
|
|
||||||
- Geographic distribution
|
|
||||||
- Advanced health checks
|
|
||||||
- Traffic steering
|
|
||||||
- DDoS protection
|
|
||||||
|
|
||||||
### 4. Monitoring and Alerting
|
|
||||||
- Prometheus + Grafana
|
|
||||||
- Alertmanager
|
|
||||||
- Log aggregation (ELK stack)
|
|
||||||
|
|
||||||
## Example: Complete Autoscaling Setup
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.autoscale.yml
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
traefik:
|
|
||||||
image: traefik:v3.2
|
|
||||||
command:
|
|
||||||
- "--api.dashboard=true"
|
|
||||||
- "--providers.docker=true"
|
|
||||||
- "--providers.docker.swarmMode=true"
|
|
||||||
- "--metrics.prometheus=true"
|
|
||||||
deploy:
|
|
||||||
replicas: 1
|
|
||||||
placement:
|
|
||||||
constraints:
|
|
||||||
- node.role == manager
|
|
||||||
|
|
||||||
backend:
|
|
||||||
image: containr-backend
|
|
||||||
deploy:
|
|
||||||
replicas: 3
|
|
||||||
update_config:
|
|
||||||
parallelism: 1
|
|
||||||
delay: 10s
|
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
labels:
|
|
||||||
- "traefik.http.services.backend.loadbalancer.server.port=8080"
|
|
||||||
- "traefik.http.routers.backend.rule=Host(`api.${DOMAIN}`)"
|
|
||||||
- "traefik.enable=true"
|
|
||||||
|
|
||||||
prometheus:
|
|
||||||
image: prom/prometheus
|
|
||||||
deploy:
|
|
||||||
replicas: 1
|
|
||||||
volumes:
|
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
|
||||||
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana
|
|
||||||
deploy:
|
|
||||||
replicas: 1
|
|
||||||
environment:
|
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
|
||||||
```
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
1. **Cloudflare Tunnel ≠ Autoscaling** - It's for secure connectivity
|
|
||||||
2. **Autoscaling happens at container/orchestration level**
|
|
||||||
3. **Traefik provides load balancing between instances**
|
|
||||||
4. **Use Docker Swarm or Kubernetes for production autoscaling**
|
|
||||||
5. **Monitor metrics and implement HPA for automatic scaling**
|
|
||||||
6. **Consider Cloudflare Load Balancer for multi-region setups**
|
|
||||||
|
|
||||||
## Quick Start Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start with autoscaling (Docker Swarm)
|
|
||||||
docker swarm init
|
|
||||||
docker stack deploy -c docker-compose.autoscale.yml containr
|
|
||||||
|
|
||||||
# Scale manually
|
|
||||||
docker service scale containr_backend=5
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
docker service ls
|
|
||||||
docker service ps containr_backend
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker service logs containr_backend
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Contributing to Containr
|
||||||
|
|
||||||
|
Thanks for your interest in contributing. Containr is a self-hosted deployment
|
||||||
|
platform — contributions of all sizes are welcome.
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/backend/ Go control-plane API (Gin, PostgreSQL, Redis, Docker SDK)
|
||||||
|
app/frontend/ React + Vite + TypeScript dashboard
|
||||||
|
infra/ Self-hosted compose stack (Traefik, Cloudflare Tunnel)
|
||||||
|
docs/ Guides, API spec, and design documents
|
||||||
|
templates/ Service template catalog notes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and fill in values (never commit `.env`).
|
||||||
|
2. Start Postgres and Redis: `docker compose up -d postgres redis`
|
||||||
|
3. Backend: `cd app/backend && go run ./cmd/server`
|
||||||
|
4. Frontend: `cd app/frontend && npm install && npm run dev`
|
||||||
|
|
||||||
|
## Checks Before Submitting
|
||||||
|
|
||||||
|
- Backend: `cd app/backend && go build ./... && go test ./... && go vet ./...`
|
||||||
|
- Frontend: `cd app/frontend && npm run build && npm test`
|
||||||
|
- Compose: `docker compose config -q`
|
||||||
|
|
||||||
|
## Boot Smoke Test
|
||||||
|
|
||||||
|
Verify the API actually starts and serves against real dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requires Postgres + Redis reachable; migrations run automatically on boot.
|
||||||
|
cd app/backend
|
||||||
|
DATABASE_URL="postgres://user:pass@localhost:5432/containr?sslmode=disable" \
|
||||||
|
REDIS_URL="redis://localhost:6379" \
|
||||||
|
go run ./cmd/server &
|
||||||
|
|
||||||
|
curl -fsS http://localhost:8080/live # -> 200
|
||||||
|
curl -fsS http://localhost:8080/health # -> {"database":"ok","redis":"ok",...}
|
||||||
|
curl -s http://localhost:8080/api/v1/services/x # -> 401 (auth required, route exists)
|
||||||
|
|
||||||
|
kill %1
|
||||||
|
```
|
||||||
|
|
||||||
|
To verify migrations alone against a scratch database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app/backend
|
||||||
|
DATABASE_URL="postgres://user:pass@localhost:5432/scratch?sslmode=disable" \
|
||||||
|
go run ./cmd/migrate up
|
||||||
|
DATABASE_URL=... go run ./cmd/migrate goose-status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Reuse existing utilities before adding dependencies.
|
||||||
|
- Keep handlers thin; business logic lives in `internal/` packages.
|
||||||
|
- Regenerate API types after schema changes:
|
||||||
|
`npm --prefix app/frontend run generate:api`.
|
||||||
|
- Never commit secrets, tokens, or environment files.
|
||||||
|
- Write tests for non-trivial logic.
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
- Keep PRs focused — one concern per PR.
|
||||||
|
- Describe the "why", not just the "what".
|
||||||
|
- Include a test plan for anything touching deployment, auth, or networking.
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM golang:1.24-alpine AS builder
|
|
||||||
|
|
||||||
# Install build dependencies
|
|
||||||
RUN apk add --no-cache git ca-certificates tzdata
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy go mod files
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
|
|
||||||
# Download dependencies
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go
|
|
||||||
|
|
||||||
# Final stage
|
|
||||||
FROM alpine:latest
|
|
||||||
|
|
||||||
# Install ca-certificates for HTTPS requests
|
|
||||||
RUN apk --no-cache add ca-certificates tzdata
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1001 -S appgroup && \
|
|
||||||
adduser -u 1001 -S appuser -G appgroup
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy binary from builder stage
|
|
||||||
COPY --from=builder /app/main .
|
|
||||||
|
|
||||||
# Copy migrations
|
|
||||||
COPY --from=builder /app/migrations ./migrations
|
|
||||||
|
|
||||||
# Change ownership to non-root user
|
|
||||||
RUN chown -R appuser:appgroup /app
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
|
||||||
|
|
||||||
# Run the binary
|
|
||||||
CMD ["./main"]
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# Build stage
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm ci && npm install --save-dev terser
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# Copy custom nginx config
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Copy built assets from builder stage
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1001 -S appgroup && \
|
|
||||||
adduser -u 1001 -S appuser -G appgroup
|
|
||||||
|
|
||||||
# Change ownership of nginx directories
|
|
||||||
RUN chown -R appuser:appgroup /usr/share/nginx/html && \
|
|
||||||
chown -R appuser:appgroup /var/cache/nginx && \
|
|
||||||
chown -R appuser:appgroup /var/log/nginx && \
|
|
||||||
chown -R appuser:appgroup /etc/nginx/conf.d
|
|
||||||
|
|
||||||
# Create nginx PID directory
|
|
||||||
RUN touch /var/run/nginx.pid && \
|
|
||||||
chown -R appuser:appgroup /var/run/nginx.pid
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1
|
|
||||||
|
|
||||||
# Start nginx
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Tomas Dvorak
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,354 +1,123 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./containr.svg" alt="Containr Logo" width="200">
|
<img src="./containr.svg" alt="Containr" width="120">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">
|
<h1 align="center">Containr</h1>
|
||||||
Containr - Modern Container Management Platform
|
|
||||||
</h1>
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
🚧 <strong>Work in Progress</strong> - Not yet fully tested
|
Self-hosted platform for deploying and managing containerized services.
|
||||||
</p>
|
Railway-style project canvases on your own hardware.
|
||||||
<p align="center">
|
|
||||||
Deploy, manage, and scale containers with built-in Cloudflare tunnel support.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">Quick Start</a>
|
<a href="#quick-start">Quick Start</a> •
|
||||||
<span> • </span>
|
<a href="#features">Features</a> •
|
||||||
<a href="#features">Features</a>
|
<a href="#architecture">Architecture</a> •
|
||||||
<span> • </span>
|
<a href="docs/guides/">Guides</a> •
|
||||||
<a href="#tech-stack">Tech Stack</a>
|
<a href="CONTRIBUTING.md">Contributing</a>
|
||||||
<span> • </span>
|
|
||||||
<a href="#documentation">Documentation</a>
|
|
||||||
<span> • </span>
|
|
||||||
<a href="#contributing">Contributing</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
## What is Containr?
|
||||||
<img src="./docs/archive/research/scorecard.png" alt="Code Quality Scorecard" width="100%">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
> **🚀 Inspired by Railway**: This project draws inspiration from [Railway](https://railway.app)'s seamless deployment experience and developer-friendly approach. The goal is to bring that same level of simplicity and power to self-hosted container management.
|
Containr is an open-source control plane for Docker. It gives you a
|
||||||
|
project-first deployment workflow — create a project, drop services onto a
|
||||||
|
visual canvas, wire them together with environment variables, and deploy —
|
||||||
|
without Kubernetes, without a cloud bill, and without giving up control of
|
||||||
|
your infrastructure.
|
||||||
|
|
||||||
## Introduction
|
The UI is inspired by Railway's project canvas: services appear as nodes,
|
||||||
|
connections are inferred automatically, and everything — logs, metrics,
|
||||||
I built Containr because I was tired of complex container orchestration tools that required weeks of setup and deep expertise. You know how it is – Kubernetes clusters to configure, CI/CD pipelines to maintain, networking nightmares to debug, and that simple app deployment that somehow took three days to get running.
|
deployments, builds — is one click away.
|
||||||
|
|
||||||
So I decided to build my own solution. Containr is basically the platform I wish I had – one place to deploy, manage, and scale containers without the complexity. It's open-source and self-hosted because I believe your infrastructure should belong to you, not some cloud provider.
|
|
||||||
|
|
||||||
It's like if Docker Compose, Traefik, and a modern dashboard had a baby. Built by one person (with AI's help), for anyone who's tired of container management being a mess.
|
|
||||||
|
|
||||||
### My Design Inspiration
|
|
||||||
|
|
||||||
I've always been a huge fan of **Portainer** – their clean, intuitive approach to container management just makes sense. Everything feels intentional and puts your containers first.
|
|
||||||
|
|
||||||
So yeah, Containr borrows heavily from that design philosophy. But I wrote every line of code from scratch – this isn't some fork or copy-paste job. I took that beautiful simplicity and tried to expand it into more of a comprehensive container platform with modern features.
|
|
||||||
|
|
||||||
### The AI-Assisted Journey
|
|
||||||
|
|
||||||
This project was built with **Windsurf SWE 1.5**.
|
|
||||||
|
|
||||||
Look, I'll be honest – I'm not the best DevOps engineer out there. But I really wanted to build something that solves real problems, and I think passion matters more than being perfect. Windsurf helped me figure out the hard stuff, learn new technologies, and actually build something I'm proud of.
|
|
||||||
|
|
||||||
As a solo dev, having an AI pair programmer made this whole thing possible. Go backend, React frontend, Docker orchestration – stuff I probably couldn't have tackled alone. It's pretty cool what you can create when humans and AI work together, even when you're still learning.
|
|
||||||
|
|
||||||
With Containr, your container deployments are centralized, scalable, and easy to manage, while remaining self-hosted so you maintain full control and privacy – just as it should be.
|
|
||||||
|
|
||||||
## Project Status
|
|
||||||
|
|
||||||
Containr is production-ready for small to medium deployments with comprehensive features for container management. The platform has been enhanced with enterprise-grade security, monitoring, and reliability features.
|
|
||||||
|
|
||||||
**Production Readiness Score: 75/100**
|
|
||||||
|
|
||||||
- ✅ Core functionality complete and tested
|
|
||||||
- ✅ Security hardening implemented
|
|
||||||
- ✅ Structured logging and error handling
|
|
||||||
- ✅ Rate limiting and request validation
|
|
||||||
- ✅ Comprehensive documentation
|
|
||||||
- ⚠️ Monitoring integration recommended
|
|
||||||
- ⚠️ Load testing recommended for high-traffic scenarios
|
|
||||||
|
|
||||||
See [PRODUCTION_READINESS.md](./PRODUCTION_READINESS.md) for detailed assessment.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Core Functionality
|
- **Project canvas** — visual service topology with groups, drag/drop layout,
|
||||||
- **Container Management**: Deploy, start, stop, and restart containers with ease
|
and auto-inferred connections
|
||||||
- **Service Orchestration**: Manage multi-container applications with Docker Compose integration
|
- **Deployments** — Docker-based deploys with history, logs, and rollback
|
||||||
- **Automatic SSL**: Built-in Let's Encrypt certificate management with Traefik
|
- **Git integration** — deploy from GitHub, GitLab, Bitbucket, or Gitea;
|
||||||
- **Cloudflare Tunnel**: Zero-config deployment with automatic public URLs
|
repository and branch pickers, webhooks, GitHub App support
|
||||||
- **Web Dashboard**: Modern React-based interface for container management
|
- **Builds** — build pipeline with live status over WebSocket
|
||||||
- **Real-time Logs**: View and search container logs in real-time
|
- **Service metrics** — real CPU/memory/network telemetry from Docker stats
|
||||||
|
- **Templates** — one-click service catalog (databases, apps, tooling)
|
||||||
|
- **Databases** — managed database services with backup/restore actions
|
||||||
|
- **Cron jobs** — scheduled jobs with execution history
|
||||||
|
- **Preview environments** — per-project preview deploys
|
||||||
|
- **Variables & environments** — per-service env vars, production/preview/dev
|
||||||
|
- **Node monitoring** — host CPU, memory, disk, and network telemetry
|
||||||
|
- **Security** — audit logs, vulnerability scans, compliance reports
|
||||||
|
- **Auth** — Better Auth sessions (email + OAuth), per-user resource scoping
|
||||||
|
- **Self-hosted networking** — Traefik reverse proxy, optional Cloudflare
|
||||||
|
Tunnel for exposure without port forwarding
|
||||||
|
|
||||||
### Advanced Features
|
## Architecture
|
||||||
- **Auto-scaling**: Automatic horizontal scaling based on resource metrics
|
|
||||||
- **Health Monitoring**: Comprehensive health checks and monitoring for all services
|
|
||||||
- **Resource Metrics**: CPU, memory, and network usage tracking
|
|
||||||
- **Preview Environments**: Automatic preview deployments for branches
|
|
||||||
- **Git Integration**: Connect repositories for automatic deployments
|
|
||||||
- **Environment Variables**: Secure management of application secrets and configuration
|
|
||||||
- **Database Management**: Built-in PostgreSQL and Redis with automated backups
|
|
||||||
- **Security Scanning**: Container vulnerability scanning and compliance reporting
|
|
||||||
|
|
||||||
### Deployment & Infrastructure
|
```
|
||||||
- **Multi-environment Support**: Development, staging, and production configurations
|
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
|
||||||
- **Load Balancing**: Intelligent traffic distribution with Traefik
|
app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar
|
||||||
- **Service Discovery**: Automatic service registration and discovery
|
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
|
||||||
- **Network Management**: Isolated networks and secure inter-service communication
|
docs/ Guides, OpenAPI spec, design documents
|
||||||
- **Volume Management**: Persistent storage with automated backups
|
```
|
||||||
- **CI/CD Integration**: GitHub Actions and GitLab CI integration
|
|
||||||
|
|
||||||
### Security & Compliance
|
| Component | Default port |
|
||||||
- **Authentication**: JWT-based authentication with OAuth2 support
|
| ------------ | ------------ |
|
||||||
- **Role-based Access Control**: Granular permissions for users and teams
|
| Frontend | 3000 (prod) / 5173 (dev) |
|
||||||
- **Audit Logging**: Comprehensive audit trails for all actions
|
| API | 8082 |
|
||||||
- **Data Encryption**: Encrypted storage for sensitive data
|
| Auth sidecar | 3001 |
|
||||||
- **Security Scanning**: Automated vulnerability assessment
|
| PostgreSQL | 5432 |
|
||||||
- **Compliance Reporting**: GDPR and SOC2 compliance frameworks
|
| Redis | 6379 |
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
- **React + TypeScript** – Modern, reactive UI framework with type safety
|
|
||||||
- **Vite** – Fast build tool and development server with HMR
|
|
||||||
- **TailwindCSS** – Utility-first CSS framework for rapid UI development
|
|
||||||
- **@tanstack/react-query** – Powerful data fetching and state management
|
|
||||||
- **React Router** – Client-side routing for SPAs
|
|
||||||
- **@xyflow/react** – Interactive diagrams and visualizations
|
|
||||||
- **Recharts** – Beautiful charts and data visualization
|
|
||||||
- **Zustand** – Lightweight state management
|
|
||||||
|
|
||||||
### Backend Services
|
|
||||||
- **Main Backend (Go)** – Core API, container management, and business logic
|
|
||||||
- Gin web framework for HTTP routing
|
|
||||||
- GORM for database operations
|
|
||||||
- JWT authentication and authorization
|
|
||||||
- Docker SDK integration
|
|
||||||
- **Database Layer** – PostgreSQL for production data
|
|
||||||
- **Cache Layer** – Redis for session management and caching
|
|
||||||
- **Message Queue** – Background job processing
|
|
||||||
|
|
||||||
### Infrastructure & DevOps
|
|
||||||
- **Docker & Docker Compose** – Containerized deployment
|
|
||||||
- **Traefik** – Reverse proxy with automatic SSL
|
|
||||||
- **Cloudflare Tunnel** – Zero-config public exposure
|
|
||||||
- **PostgreSQL** – Primary database
|
|
||||||
- **Redis** – Caching and session storage
|
|
||||||
- **Nginx** – Static file serving
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Docker Compose (everything)
|
||||||
- Docker Engine with Compose v2 (`docker compose`)
|
|
||||||
- Git
|
|
||||||
|
|
||||||
### Installation with Docker (Recommended)
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/your-username/containr.git
|
|
||||||
cd containr
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Configure environment**
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
cp .env.production.example .env.prod
|
|
||||||
# Edit .env for local development and .env.prod for production
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Start the full local stack**
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Access the application**
|
|
||||||
- **Local full stack**: http://localhost:3000 (Frontend), http://localhost:8082 (API)
|
|
||||||
- **Self-hosted production**: `./start-unified.sh prod`
|
|
||||||
- **Cloudflare profile**: `./start-unified.sh cloudflare`
|
|
||||||
|
|
||||||
### Split Production Deployment
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Frontend -> Vercel
|
cp .env.example .env # fill in secrets
|
||||||
cd app/frontend
|
docker compose up -d
|
||||||
npm run remote
|
|
||||||
|
|
||||||
# Backend only -> self-hosted Docker Compose
|
|
||||||
cd ../backend
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
# Backend -> Railway
|
|
||||||
# Configure Railway service root directory: /app/backend
|
|
||||||
# Configure Railway config file path: /app/backend/railway.toml
|
|
||||||
railway up
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `.env.prod` in the repository root as the canonical production variable set. Copy the relevant values into Vercel and Railway project environments so the remote frontend and backend talk to each other over `FRONTEND_URL`, `BACKEND_URL`, `VITE_API_URL`, and `VITE_AUTH_URL`.
|
Frontend at `http://localhost:3000`, API at `http://localhost:8082`.
|
||||||
|
|
||||||
### Management Commands
|
### Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./start-unified.sh dev # Start local full stack
|
docker compose up -d postgres redis # data services only
|
||||||
./start-unified.sh prod # Start self-hosted production stack from infra/
|
cd app/backend && go run ./cmd/server # API + embedded auth sidecar
|
||||||
./start-unified.sh cloudflare # Start self-hosted production stack with cloudflared
|
cd app/frontend && npm install && npm run dev
|
||||||
./start-unified.sh stop # Stop both compose stacks
|
|
||||||
./start-unified.sh logs # View local stack logs
|
|
||||||
./start-unified.sh status # Check service status
|
|
||||||
./start-unified.sh config # Validate compose configuration
|
|
||||||
./start-unified.sh clean # Clean up containers and volumes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
Frontend dev server at `http://localhost:5173`.
|
||||||
|
|
||||||
### Project Structure
|
### Self-hosted with Traefik + Cloudflare Tunnel
|
||||||
```
|
|
||||||
containr/
|
|
||||||
├── app/
|
|
||||||
│ ├── frontend/ # React + Vite frontend (deploy from here to Vercel)
|
|
||||||
│ └── backend/ # Go API + embedded Better Auth runtime
|
|
||||||
├── infra/ # Self-hosted production compose + Traefik/cloudflared
|
|
||||||
├── docs/ # API contract and design references
|
|
||||||
├── scripts/ # Utility scripts
|
|
||||||
├── .env.prod # Canonical production env template
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Local Development
|
See `infra/docker-compose.yml` and
|
||||||
|
[docs/guides/CLOUDFLARE_SETUP.md](docs/guides/CLOUDFLARE_SETUP.md).
|
||||||
```bash
|
|
||||||
# Backend
|
|
||||||
cd app/backend
|
|
||||||
go mod download
|
|
||||||
go run cmd/migrate/main.go up # optional manual migration run (legacy + goose)
|
|
||||||
go run cmd/server/main.go
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
cd ../frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Comprehensive documentation is available in the project:
|
|
||||||
|
|
||||||
- **[Cloudflare Setup](./CLOUDFLARE_SETUP.md)** – Cloudflare tunnel configuration
|
|
||||||
- **[Docker Setup](./DOCKER_SETUP.md)** – Docker deployment guide
|
|
||||||
- **[Autoscaling](./AUTOSCALING.md)** – Auto-scaling configuration
|
|
||||||
- **[API Documentation](./docs/api/openapi.yaml)** – REST API reference
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Environment Variables
|
All configuration is via environment variables — see `.env.example`.
|
||||||
|
Required for production: `JWT_SECRET`, `BETTER_AUTH_SECRET`,
|
||||||
|
`BETTER_AUTH_INTERNAL_TOKEN`, database and Redis credentials.
|
||||||
|
|
||||||
Key configuration options in `.env`:
|
**Never commit `.env` or `.env.prod`.** Both are gitignored; if a secret was
|
||||||
|
ever committed, rotate it.
|
||||||
|
|
||||||
```bash
|
## Documentation
|
||||||
# Domain Configuration (optional if using Cloudflare)
|
|
||||||
DOMAIN=yourdomain.com
|
|
||||||
[email protected]
|
|
||||||
|
|
||||||
# Database Configuration
|
- [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling
|
||||||
POSTGRES_DB=containr
|
- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract
|
||||||
POSTGRES_USER=containr_user
|
- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding
|
||||||
POSTGRES_PASSWORD=your_secure_postgres_password
|
- [docs/design/](docs/design/) — UI design references
|
||||||
MAX_CONNECTIONS=25
|
- `make help` — common tasks
|
||||||
MAX_IDLE_CONNECTIONS=5
|
|
||||||
CONN_MAX_LIFETIME=5m
|
|
||||||
CONN_MAX_IDLE_TIME=5m
|
|
||||||
|
|
||||||
# Redis Configuration
|
|
||||||
REDIS_PASSWORD=your_secure_redis_password
|
|
||||||
|
|
||||||
# Application Configuration
|
|
||||||
JWT_SECRET=your_very_secure_jwt_secret_key_here # min 32 chars in production
|
|
||||||
CONTAINR_AGENT_AUTH_TOKEN=your_agent_shared_secret # required for node-agent auth
|
|
||||||
# Optional rotating token list (comma-separated)
|
|
||||||
# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret
|
|
||||||
CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
|
||||||
COOKIE_SECURE=true
|
|
||||||
|
|
||||||
# Traefik Authentication (Basic Auth for dashboard)
|
|
||||||
TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/
|
|
||||||
# true for local dev dashboard on :8080, set false in production
|
|
||||||
TRAEFIK_API_INSECURE=true
|
|
||||||
|
|
||||||
# Cloudflare Tunnel (alternative to domain)
|
|
||||||
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
|
|
||||||
|
|
||||||
# Optional: Custom Docker Registry
|
|
||||||
DOCKER_REGISTRY=your-registry.com
|
|
||||||
DOCKER_USERNAME=your_username
|
|
||||||
DOCKER_PASSWORD=your_password
|
|
||||||
|
|
||||||
# Optional: External Services
|
|
||||||
SENTRY_DSN=https://your-sentry-dsn
|
|
||||||
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url
|
|
||||||
|
|
||||||
# Development/Testing
|
|
||||||
ENVIRONMENT=development
|
|
||||||
DEBUG=true
|
|
||||||
TRUSTED_PROXY_CIDR=172.20.0.0/16
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Building Containr as a solo developer has been an incredible journey, but it's always better when we build together! Whether you're fixing a typo, adding a feature, or just sharing ideas – your contribution matters.
|
See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome.
|
||||||
|
|
||||||
**Feel free to contribute and enhance it to your liking. Help me make this project successful!**
|
## Security
|
||||||
|
|
||||||
Every contribution, no matter how small, helps turn this solo dream into a community success story. Whether you're a seasoned developer or just starting out, your perspective and skills can help make Containr better for everyone.
|
See [SECURITY.md](SECURITY.md). Report vulnerabilities privately — do not
|
||||||
|
open public issues.
|
||||||
Here's how you can join this adventure:
|
|
||||||
|
|
||||||
1. **Fork the repository** and make it your own
|
|
||||||
2. **Create a feature branch** (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. **Commit your changes** (`git commit -m 'Add some amazing feature'`)
|
|
||||||
4. **Push to the branch** (`git push origin feature/amazing-feature`)
|
|
||||||
5. **Open a Pull Request** – I'll be excited to review it!
|
|
||||||
|
|
||||||
### Development Guidelines
|
|
||||||
- Follow Go coding standards for backend development
|
|
||||||
- Use TypeScript for frontend development
|
|
||||||
- Write tests for new features (helps future you!)
|
|
||||||
- Update documentation as needed
|
|
||||||
- Remember: this is a labor of love, so let's keep it fun and welcoming
|
|
||||||
|
|
||||||
Don't hesitate to reach out if you're new to contributing – we all started somewhere, and I'm happy to help you get started!
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
[MIT](LICENSE) © 2026 Tomas Dvorak
|
||||||
|
|
||||||
## Community & Support
|
|
||||||
|
|
||||||
- **Issues**: Use GitHub Issues for bug reports and feature requests
|
|
||||||
- **Discussions**: Use GitHub Discussions for questions and community support
|
|
||||||
- **Documentation**: Check the project files for comprehensive guides
|
|
||||||
|
|
||||||
## Acknowledgements
|
|
||||||
|
|
||||||
### Inspiration
|
|
||||||
- **[Portainer](https://www.portainer.io/)** – Container management UI inspiration
|
|
||||||
- **[Docker Compose](https://docs.docker.com/compose/)** – Service orchestration concepts
|
|
||||||
- **[Traefik](https://traefik.io/)** – Reverse proxy and load balancing
|
|
||||||
- **[Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/)** – Zero-config deployment
|
|
||||||
|
|
||||||
### Technology Stack
|
|
||||||
This project is built with amazing open-source technologies:
|
|
||||||
- **Frontend**: React, TypeScript, Vite, TailwindCSS
|
|
||||||
- **Backend**: Go, Gin, GORM, PostgreSQL
|
|
||||||
- **Infrastructure**: Docker, Traefik, Cloudflare, Redis
|
|
||||||
|
|
||||||
## A Personal Note
|
|
||||||
|
|
||||||
Thank you for taking the time to look at my project. Containr represents months of learning, building, and dreaming – all in the service of creating something that makes container management a little more accessible and a lot more powerful.
|
|
||||||
|
|
||||||
In a world of complex orchestration tools and expensive cloud services, I believe there's beauty in owning your infrastructure. Containr is my attempt to build that bridge between simplicity and power.
|
|
||||||
|
|
||||||
Whether you use it, contribute to it, or just find inspiration here – know that you're part of something special. A solo developer's dream, powered by AI assistance, and shared with the open-source community.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Containr** – Built with ❤️ by one human, one AI, and a passion for better container management.
|
|
||||||
+492
@@ -0,0 +1,492 @@
|
|||||||
|
# Containr — Project Roadmap
|
||||||
|
|
||||||
|
Last verified: **2026-09-17** against `main` (`a1e4352`).
|
||||||
|
|
||||||
|
Goal: bring Containr from "working core + large headless backend" to a
|
||||||
|
finished, installable, documented v1.0 self-hosted platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Verified Current State
|
||||||
|
|
||||||
|
Everything below was run, not assumed.
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `go build ./...` | clean |
|
||||||
|
| `go vet ./...` | clean |
|
||||||
|
| `go test ./...` | all packages pass (api, build, cli, config, database, ha, middleware, networking, security) |
|
||||||
|
| `tsc -b` (frontend) | clean |
|
||||||
|
| `eslint .` | clean |
|
||||||
|
| `vite build` | clean |
|
||||||
|
| `vitest run` | 3/3 pass — one smoke test file only |
|
||||||
|
| `go run ./cmd/migrate up` vs empty Postgres | clean — 52 app tables + `goose_db_version`, version `20260917000000` |
|
||||||
|
| `go run ./cmd/migrate up` vs legacy schema | clean — APwhy parallel tables + `migrations` bookkeeping dropped, dead `users` columns removed |
|
||||||
|
| Backend boot vs migrated Postgres+Redis | starts, `/live` → 200, `/health` → `{"database":"ok","redis":"ok"}`, protected routes → 401 |
|
||||||
|
| Frontend demo mode (`?demo=1`) | Projects, workspace canvas, Templates render correctly in the Vertice theme |
|
||||||
|
|
||||||
|
### Feature inventory — backend vs frontend
|
||||||
|
|
||||||
|
The backend exposes **~95 route registrations**; the frontend consumes
|
||||||
|
**52** of them via `api-client.ts`. The gap below is the real roadmap.
|
||||||
|
|
||||||
|
| Feature | Backend | Frontend | Docs |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Auth (Better Auth: email, GitHub, Google; bootstrap gate; internal manual user creation) | done | done (`/auth/*`, People page) | done |
|
||||||
|
| Projects CRUD | done | done (`/projects`) | done |
|
||||||
|
| Project canvas (nodes, groups, auto-connections, drag layout) | via services API | done (React Flow, `ProjectCanvas`) | done |
|
||||||
|
| Services CRUD | done | done (canvas + `/services/:id` detail) | done |
|
||||||
|
| Deployments: create, history, rollback, logs | done | done (service detail) | done |
|
||||||
|
| Service env variables | done | done (service detail config section) | done |
|
||||||
|
| Service metrics (Docker stats) | done | done (metrics section + `/metrics-demo`) | done |
|
||||||
|
| Host monitoring (`/system/host`) | done | done (Usage + Projects telemetry cards) | done |
|
||||||
|
| Builds + live status over `/ws` | done | done (`/builds`, `useBuildUpdates`) | done |
|
||||||
|
| Templates — list/detail/deploy | done | done (catalog page) | done |
|
||||||
|
| Templates — **Compose catalog** (GitHub import, paste YAML, `x-containr` metadata, community source) | **missing** | **missing** | spec written, not built |
|
||||||
|
| Git providers (PAT add/remove, repo list, branches) | done | done (Settings → Git Providers) | done |
|
||||||
|
| Git repo connect + webhook auto-deploy | done (`/git/repositories/connect`, `/git/webhooks`, `/api/git/webhooks/:id` receiver) | done — provider→repo→branch in Add Service, connect+webhook on create | guides claim it works |
|
||||||
|
| GitHub App install flow | done (`install_url` + connect endpoint) | done — Install button + `installation_id` return handling | done (real-provider e2e pending credentials) |
|
||||||
|
| Cron jobs (CRUD, trigger, executions) | done — real scheduler + `docker exec` capture | done — Cron tab on service detail | OPERATIONS.md |
|
||||||
|
| Managed databases (CRUD, actions, backup/restore) | done | done — `/databases` page (conn info, actions, backups) | OPERATIONS.md |
|
||||||
|
| Preview environments (CRUD, promote, cleanup) | done (bookkeeping only — no real container deploy; URL is a placeholder) | done — Previews section on service detail | OPERATIONS.md |
|
||||||
|
| Security scans, vulnerabilities, compliance/GDPR reports | done (heuristic scanner — image tags, insecure URLs, build-command smells; no Trivy/Grype binary scanning) | done — `/security` page (scan trigger, findings, metrics, history) | OPERATIONS.md |
|
||||||
|
| Audit logs | done | done — `/settings/audit-logs` filterable page | done |
|
||||||
|
| Autoscaling policies + manual scale | done | done — per-service Scaling section; policies persisted (scale events still in-memory) | AUTOSCALING.md |
|
||||||
|
| HA / failover policies | done | done (beta) | OPERATIONS.md |
|
||||||
|
| Node agents (register additional VPS/LXC/VM hosts, heartbeats) | done | done — token issue/revoke + install command on Usage page | done |
|
||||||
|
| API gateway (merged APwhy): upstream services, API keys, rate limits, ops/traffic analytics | management API done — native handlers under `/api/v1/gateway/*`, schema-corrected; **traffic proxy path still not mounted** (Phase 2) | **missing** | not documented |
|
||||||
|
| Proxmox provider (cluster/nodes/VM/LXC CRUD) | written, **routes never registered** — unreachable | none | not documented |
|
||||||
|
| Notifications (build/audit feed) | via existing APIs | done (shell bell menu) | — |
|
||||||
|
| Self-upgrade (`/system/upgrade/*`) | done | done (Upgrade button) | — |
|
||||||
|
| Light theme | CSS vars done | done (toggle in top bar) | — |
|
||||||
|
| CLI (`cmd/cli`) | exists | — | thin |
|
||||||
|
| Traefik + Cloudflare Tunnel infra | `infra/` done | — | done |
|
||||||
|
| Demo mode (`?demo=1`) | n/a | done | — |
|
||||||
|
|
||||||
|
### Known issues and debt
|
||||||
|
|
||||||
|
- ~~**React Flow edge warnings**~~ — **fixed**: `ServiceNode` now renders
|
||||||
|
invisible `Handle` components (target left, source right), so inferred
|
||||||
|
auto-connections attach without `#008` warnings.
|
||||||
|
- ~~**OpenAPI drift**~~ — **resolved**: spec now covers all 153 mounted
|
||||||
|
operations (114 path items, 154 operations incl. root probes). Schemas
|
||||||
|
were corrected against the real Go structs (Service, Git*, NodeAgent,
|
||||||
|
HostMonitoring, UpgradeStatus, metrics); stale `source`/`build_config`
|
||||||
|
contract removed. `api-types.ts` regenerated; `api-client.ts` raw types
|
||||||
|
now alias generated schemas — camelCase normalizers stay by design.
|
||||||
|
- **Two DB access patterns remain**: GORM is gone (agents handler ported to
|
||||||
|
sqlc), but most handlers still hand-write `database/sql` — ~140 call sites
|
||||||
|
across ~14 files. Porting them to `sqlc/queries/` is mechanical but large;
|
||||||
|
scheduled as its own pass.
|
||||||
|
- ~~**Two migration systems**~~ — **resolved**: `migrations/` and its loader
|
||||||
|
are deleted. `migrations_goose/` is the only path;
|
||||||
|
`20260401163000_baseline.sql` carries the consolidated schema (idempotent —
|
||||||
|
safe over partially-migrated legacy installs), and
|
||||||
|
`20260917000000_drop_apwhy_parallel_auth.sql` removes the dead APwhy
|
||||||
|
objects. Fresh install and legacy upgrade both verified via `cmd/migrate`.
|
||||||
|
- ~~**APwhy is triple-implemented**~~ — **resolved**: vendored server,
|
||||||
|
`cmd/apwhy`, embedded SolidJS bundle, and the unwired copy under
|
||||||
|
`internal/api/server.go` + cloned packages are all deleted (~7.5k lines).
|
||||||
|
Management API lives at `/api/v1/gateway/*` on Containr auth; traffic
|
||||||
|
proxy mount remains Phase 2 work. Note: `umami_sync_cache` and
|
||||||
|
`database_connections` were dropped with the dead tables — re-add via a
|
||||||
|
new migration when Phase 2 Umami sync lands.
|
||||||
|
- **Proxmox written but never wired**: `internal/proxmox` +
|
||||||
|
`internal/api/proxmox.go` define 17 endpoints; `RegisterProxmoxRoutes` is
|
||||||
|
never called. Kept deliberately — see §2.2.
|
||||||
|
- ~~**Dead weight**~~ — **removed**: `reactflow` dep, `cmd/test-*`
|
||||||
|
harnesses, `migrations/0001_init.sql` + `.bak`. Correction to the earlier
|
||||||
|
audit: `feature_response.go` is *live* (`respondDependencyUnavailable` is
|
||||||
|
used by `logs.go`) and stays.
|
||||||
|
- **Test coverage**: frontend has focused tests (`variable-utils`) but no
|
||||||
|
canvas/flow coverage; backend coverage concentrated in `internal/api` and
|
||||||
|
infra packages; deployment engine, docker client, metrics, scaling,
|
||||||
|
agents have no tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decisions
|
||||||
|
|
||||||
|
1. **APwhy — merged, consolidated.** DECIDED: APwhy becomes a first-class
|
||||||
|
Containr feature ("API Gateway"): deploy a service, publish it behind a
|
||||||
|
managed route with keys, rate limits, and analytics. Canonical
|
||||||
|
implementation is the Containr-native Gin handlers
|
||||||
|
(`internal/api/apwhy_handlers.go`) — they already use Containr auth and
|
||||||
|
DB. The vendored standalone server (`internal/apwhy/*`, `cmd/apwhy`,
|
||||||
|
embedded SolidJS bundle) is deleted after the useful parts are ported
|
||||||
|
(`gateway/proxy.go`, analytics queries). The parallel auth/RBAC schema
|
||||||
|
from migration 008 is dropped — Better Auth owns identity. APwhy's
|
||||||
|
upstream repo at `ref/APwhy` stays as the standalone edition.
|
||||||
|
2. **Proxmox — kept, and generalized into infrastructure providers over
|
||||||
|
Terraform/OpenTofu.** DECIDED: node provisioning becomes an
|
||||||
|
infrastructure-as-code pipeline. `internal/proxmox` stays for
|
||||||
|
read/inventory (cluster status, node stats, existing VMs/LXCs); all
|
||||||
|
*provisioning* goes through Terraform — one engine, providers as HCL
|
||||||
|
modules. Proxmox (bpg/proxmox) is provider #1, AWS EC2 is provider #2;
|
||||||
|
adding Hetzner/GCP/later providers is then a module, not a feature.
|
||||||
|
Provisioning a node = render module → `terraform apply` → cloud-init
|
||||||
|
installs Docker + `containr-agent` → node auto-enrolls. VM CRUD alone is
|
||||||
|
not the feature; capacity-on-click is.
|
||||||
|
- Engine: `terraform-exec` (or OpenTofu binary — same HCL, OSS license;
|
||||||
|
pick at implementation time). Provider binaries + tfstate live under a
|
||||||
|
`data/iac/` dir; provisioning requires the binary, core platform does
|
||||||
|
not.
|
||||||
|
- Secrets: provider credentials encrypted at rest in an
|
||||||
|
`infra_connections` table, instance-scoped.
|
||||||
|
3. **Multi-node / autoscaling / HA in v1.0?** — **Ship agents + host
|
||||||
|
monitoring; mark autoscaling/HA as beta.** They are backend-complete but
|
||||||
|
unproven end-to-end.
|
||||||
|
4. **Environments**: `environment_id` exists in the schema and the seeder
|
||||||
|
creates production/preview/development, but the UI exposes a single
|
||||||
|
`env:production` badge. — **Production-only in v1.0**; preview
|
||||||
|
environments land in Phase 3.
|
||||||
|
5. **DB access**: — **sqlc only.** Remove the GORM dependency (used by ~1
|
||||||
|
handler) and stop hand-writing SQL in handlers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phases
|
||||||
|
|
||||||
|
### Phase 0 — Hygiene and contract (1 pass, blocking)
|
||||||
|
|
||||||
|
Everything else builds on a truthful API contract and a clean tree.
|
||||||
|
|
||||||
|
- [x] Remove dead code: `migrations/0001_init.sql`, `migrations/*.bak`,
|
||||||
|
`cmd/test-build-manager`, `cmd/test-railpack`, `reactflow` npm dep.
|
||||||
|
(`feature_response.go` kept — it is live.)
|
||||||
|
- [x] **APwhy consolidation** (per §2.1): Containr's native
|
||||||
|
`internal/gateway` was already the real proxy — nothing needed porting;
|
||||||
|
the vendored server, `cmd/apwhy`, the embedded SolidJS bundle, the
|
||||||
|
unwired `internal/api/server.go` copy, and the cloned packages are
|
||||||
|
deleted. Management routes moved to `/api/v1/gateway/*` (services CRUD +
|
||||||
|
validate, keys, ops/traffic analytics); handlers fixed against the real
|
||||||
|
schema (UUID ids, integer `enabled`, plan validation). Parallel
|
||||||
|
auth/RBAC tables dropped in the migration consolidation.
|
||||||
|
- [x] Consolidate migrations: `migrations/` collapsed into the goose baseline
|
||||||
|
`20260401163000_baseline.sql` (idempotent); `migrations_goose/` is the
|
||||||
|
only path; legacy loader + Dockerfile copy removed. Fresh-boot (53
|
||||||
|
tables) and upgrade-from-legacy both verified through `cmd/migrate`.
|
||||||
|
- [x] Converge DB access on sqlc: agents handler ported off GORM, `gorm`
|
||||||
|
dependency dropped, agent queries in `sqlc/queries/agents.sql`.
|
||||||
|
Remaining hand-written `database/sql` in other handlers (~140 sites)
|
||||||
|
is tracked as a follow-up pass — sqlc is the target for all new code.
|
||||||
|
- [x] Bring `docs/api/openapi.yaml` to parity with `routes.go`: 114 paths /
|
||||||
|
154 operations cover every mounted route (incl. `/live`, `/health`,
|
||||||
|
`/ready`, `/api/agents/*` token routes with server overrides; Proxmox
|
||||||
|
handlers are registered nowhere, intentionally undocumented).
|
||||||
|
`api-types.ts` regenerated; `api-client.ts` contract types deduped to
|
||||||
|
generated schemas — normalized view models kept.
|
||||||
|
- [x] Fix auto-connection edges — `ServiceNode` lacked `Handle` components;
|
||||||
|
invisible target/source handles added in `nodes.tsx`.
|
||||||
|
- [x] Rerun desloppify; record score. 2026-09-17 scan (`ref/` excluded):
|
||||||
|
objective **66.1**, strict **16.5** — note the tool's scoring model
|
||||||
|
changed: strict now drags the 75%-weighted subjective pool (20 human
|
||||||
|
review items, all unscored). Mechanical dimensions: code quality 90.7,
|
||||||
|
security 97.0, file health 81.4, test health 35.0. The old "≥89 strict"
|
||||||
|
target is not comparable; treat objective ≥ 70 as the next milestone.
|
||||||
|
- [x] `go vet`, `eslint`, `tsc`, `go test`, `vitest`, `vite build` all green;
|
||||||
|
boot smoke verified (`/live` → 200, `/health` → db/redis ok, protected
|
||||||
|
routes → 401) and documented in `CONTRIBUTING.md`.
|
||||||
|
|
||||||
|
**Gate**: `docker compose up -d` from a clean clone produces a working stack;
|
||||||
|
OpenAPI spec matches the router 1:1; zero dead packages.
|
||||||
|
|
||||||
|
### Phase 1 — Finish the core deploy loop (the Railway promise)
|
||||||
|
|
||||||
|
The product's spine: *project → service → deploy → logs → rollback*. Most
|
||||||
|
pieces exist; this phase closes the gaps that block real usage.
|
||||||
|
|
||||||
|
- [x] **Git connect flow, end to end**: Settings → GitProvidersSection manages
|
||||||
|
providers; service dialog picks provider → repo → branch; on create the
|
||||||
|
repo is connected (`POST /git/repositories/connect`, 409-tolerant) and a
|
||||||
|
push webhook is registered (`POST /git/webhooks`). Live provider push
|
||||||
|
verification pending real credentials.
|
||||||
|
- [x] **Webhook auto-deploy**: public receiver `POST /api/git/webhooks/:id`
|
||||||
|
verifies HMAC (GitHub `X-Hub-Signature-256`, Gitea `X-Gitea-Signature`)
|
||||||
|
or GitLab `X-Gitlab-Token`, maps repo+branch → services, inserts
|
||||||
|
deployment rows and enqueues `runDeploymentAndSync` (trigger=webhook),
|
||||||
|
which flips canvas node status to building. Unit-tested signature
|
||||||
|
verification. Not yet e2e-verified against a real provider.
|
||||||
|
- [ ] **Compose template catalog** per `docs/superpowers/specs/2026-04-14-compose-template-catalog-design.md`:
|
||||||
|
- `service_templates.compose_yaml`, `screenshots`, `source_type`,
|
||||||
|
parsed `config` summary (migration + sqlc).
|
||||||
|
- `POST /templates/import/github`, `POST /templates/import/compose`.
|
||||||
|
- Variables discovered from `${VAR}`/`${VAR:-default}` placeholders.
|
||||||
|
- Catalog UI: screenshots, badges, import dialogs (GitHub + paste),
|
||||||
|
install wizard → creates one service per Compose service.
|
||||||
|
- [x] **Service creation flows on canvas**: Add Service covers Image, Git
|
||||||
|
repo (provider → repo → branch → connect+webhook), Database (engine +
|
||||||
|
plan → real `POST /databases` provisioning, not a hollow service row),
|
||||||
|
worker/cron types, and a Template entry that jumps to the catalog with
|
||||||
|
the project preselected (`/templates?project=`).
|
||||||
|
- [x] **Variables UX**: Variables section on the service detail page — add /
|
||||||
|
edit / remove rows, secret masking (masked `********` secrets preserved on
|
||||||
|
save; editing replaces), bulk `.env` paste (`KEY=VAL`, comments, `export`
|
||||||
|
prefix, quoted values), inline validation (required / invalid / duplicate
|
||||||
|
keys). Per-environment values are already covered by the schema: each
|
||||||
|
environment owns its service instances, and variables are per-service.
|
||||||
|
- [x] **Deploy lifecycle polish**: backend already writes deploy progress to
|
||||||
|
`services.status`; the canvas now polls faster while any service is in a
|
||||||
|
transitional state so build → deploy propagates to nodes, deployment
|
||||||
|
history rows link straight into that deployment's log tail, and rollback
|
||||||
|
asks for confirmation before firing.
|
||||||
|
- [x] **Node/port collision handling**: verified structurally impossible in the
|
||||||
|
current model — services never claim host ports (`PortMappings` is never
|
||||||
|
populated; traffic routes via container IPs + Traefik discovery), and
|
||||||
|
managed databases bind `127.0.0.1` with ephemeral host ports. If a claim
|
||||||
|
ever conflicts, Docker's bind error lands in `deployments.error` and shows
|
||||||
|
in the failed-deploy log tail — not silent. Revisit if a service-level
|
||||||
|
host-port config is ever added.
|
||||||
|
- [x] Auth hardening: e2e-verified against a live stack (scratch Postgres +
|
||||||
|
Redis + real Better Auth sidecar). Fixed a real gate hole: sign-ups write
|
||||||
|
`auth_users` but the gate counted `users`, which only mirrors on the
|
||||||
|
first authenticated call — a second registration slipped through the
|
||||||
|
pre-mirror window. `countLocalUsers` now unions both tables (verified:
|
||||||
|
`users=0, auth_users=1` already returns `mode: login` and 403s on both
|
||||||
|
public register paths; internal-token manual creation works, wrong token
|
||||||
|
refused). Session expiry redirect confirmed in `App.tsx`
|
||||||
|
(401 → null → `/auth/sign-in?redirect=…`). OAuth happy paths remain
|
||||||
|
unverifiable without real GitHub/Google credentials — providers are
|
||||||
|
env-gated and `disableImplicitSignUp` means OAuth cannot bypass the
|
||||||
|
gate by creating accounts.
|
||||||
|
|
||||||
|
**Gate**: a fresh instance can register → create project → connect GitHub repo
|
||||||
|
→ push → see build → see deploy → view logs → roll back, with zero manual API
|
||||||
|
calls. Template install from pasted Compose works the same way.
|
||||||
|
|
||||||
|
### Phase 2 — Surface the headless backend
|
||||||
|
|
||||||
|
All of these have working APIs and no UI. Cheapest feature wins in the repo.
|
||||||
|
|
||||||
|
- [x] **Databases** (`/databases/*` + backup/restore): `/databases` page
|
||||||
|
(nav item) — expandable cards with connection URL + copy, start/stop/
|
||||||
|
restart actions, metrics summary, backup list + New backup + Restore
|
||||||
|
(with confirm). Verified e2e against a real `postgres:16` image:
|
||||||
|
provision → `psql` connect → stop/start → seed → backup (6.4MB
|
||||||
|
archive) → drop table → restore → data recovered.
|
||||||
|
- [x] **Cron jobs** (`/cron-jobs/*`): real engine + UI. Backend was a stub
|
||||||
|
(`calculateNextRun` returned now+1h, `executeCronJob` slept 2s and wrote
|
||||||
|
"success" without running anything). Now: `robfig/cron` parses the
|
||||||
|
schedule (validated on create → 400), a 60s scheduler goroutine executes
|
||||||
|
due enabled jobs, and commands run via `docker exec sh -c` inside the
|
||||||
|
service's `containr-<serviceID>-*` container with stdout+stderr captured
|
||||||
|
into `cron_executions` — verified e2e (trigger + scheduler tick, real
|
||||||
|
alpine output). UI: "Cron" tab on the service detail page — list/create/
|
||||||
|
edit/enable-toggle, manual Run, execution history with logs. (Not added
|
||||||
|
to Add Service: jobs exec into an existing service's container, so they
|
||||||
|
live on the service, not as a standalone type.)
|
||||||
|
- [x] **Preview environments UI**: service-detail "Previews" section — list
|
||||||
|
per service, create (branch/PR/TTL), promote-to-production with
|
||||||
|
confirm, delete with confirm. Verified e2e: create→list→promote→delete.
|
||||||
|
Fixed two latent backend bugs: create scanned NULL `services.type`
|
||||||
|
(every real service failed) and three deployment inserts omitted the
|
||||||
|
NOT NULL `version` column (create + rollback + promote all broke on
|
||||||
|
fresh installs).
|
||||||
|
- [ ] **Preview environments runtime**: preview creation writes bookkeeping
|
||||||
|
rows and a fake `*.preview.containr.local` URL — nothing is actually
|
||||||
|
deployed. Real preview = build the branch + deploy container + route a
|
||||||
|
real subdomain + TTL cleanup that tears down containers. Sizeable;
|
||||||
|
schedule with env support (§2.4).
|
||||||
|
- [x] **Audit logs**: real page at `/settings/audit-logs` (linked from the
|
||||||
|
Settings page) — filterable table by resource, action, actor (email),
|
||||||
|
and time range, with pagination. Backend widened accordingly: the list
|
||||||
|
endpoint was self-scoped and dropped `user_email`; it now joins `users`
|
||||||
|
and accepts `user_id`/`actor`/`since` filters. Widening is consistent
|
||||||
|
with the platform's no-roles trust model (every authed user can already
|
||||||
|
create users).
|
||||||
|
- [x] **Agents/nodes**: onboarding flow on the Usage page — issue per-node
|
||||||
|
tokens (raw `cagt_…` shown once, only sha256 hash stored in
|
||||||
|
`agent_auth_tokens`), one-time copy + prefilled
|
||||||
|
`CONTAINR_API_URL`/`CONTAINR_AGENT_AUTH_TOKEN` install command, issued
|
||||||
|
tokens list with label/created/last-used and revoke (revoked tokens
|
||||||
|
rejected at register/heartbeat — verified e2e). Env-configured shared
|
||||||
|
tokens (`CONTAINR_AGENT_AUTH_TOKEN(S)`) still accepted. Node cards with
|
||||||
|
heartbeat status + telemetry were already on the Usage page.
|
||||||
|
- [ ] **API Gateway (merged APwhy, full revival)** — new runtime work, not
|
||||||
|
just UI. Target: every upstream service published through Containr is
|
||||||
|
reachable, key-gated, rate-limited, and metered:
|
||||||
|
- [ ] Mount the traffic path: catch-all route (e.g. `/g/:serviceSlug/*path`
|
||||||
|
or per-service subdomain) → resolve `api_services` → API-key check →
|
||||||
|
rate limit (`rpm_limit`, `monthly_quota` via `usage_counters`) →
|
||||||
|
proxy through the ported `proxy.go` → record `metrics_timeseries` +
|
||||||
|
`incident_events`.
|
||||||
|
- [ ] Port `POST /services/:id/validate` (upstream reachability check) from
|
||||||
|
the vendored server.
|
||||||
|
- [ ] Full management surface under `/api/v1/gateway/*`: services CRUD +
|
||||||
|
validate, keys issue/revoke/quotas, `analytics/ops`,
|
||||||
|
`analytics/traffic`, incidents list.
|
||||||
|
- [ ] UI: new "Gateway" page — register upstream (auto-suggest running
|
||||||
|
Containr services), assign route prefix, issue/revoke keys with
|
||||||
|
quotas, per-service traffic + ops charts (feeds Usage page too),
|
||||||
|
incident timeline.
|
||||||
|
- [ ] Optional: Umami sync (`umami_sync_cache`) if a Umami instance is
|
||||||
|
configured — otherwise drop the table.
|
||||||
|
- [ ] Note: APwhy's own auth/RBAC/deploy/database endpoints are *not*
|
||||||
|
ported — Better Auth owns identity, Containr owns deployments and
|
||||||
|
databases natively.
|
||||||
|
- [x] **Notifications depth**: `notifications` table + `GET /notifications`,
|
||||||
|
`POST /notifications/:id/read`, `POST /notifications/read-all`. Bell
|
||||||
|
feed now reads real persisted rows with unread badge + mark-read.
|
||||||
|
Producer wired: deployment success/failure writes a notification.
|
||||||
|
Verified e2e: list, unread count, mark-one, mark-all. Further producers
|
||||||
|
(backup completion, agent offline, upgrade available) can reuse
|
||||||
|
`insertUserNotification`.
|
||||||
|
- [x] **GitHub App install surfacing**: Settings → Git Providers shows an
|
||||||
|
"Install GitHub App" action that opens `install_url`, and handles the
|
||||||
|
`?installation_id=` return by calling `POST /git/github-app/connect`.
|
||||||
|
(Verified at typecheck/lint level; real GitHub App redirect e2e tracked
|
||||||
|
under the OAuth/provider-credentials item in Phase 4.)
|
||||||
|
- [x] **Database → service binding**: `/databases` cards have a Bind action —
|
||||||
|
pick project → service → variable key (default `DATABASE_URL`) and the
|
||||||
|
`connection_url` is injected as a secret env var. Verified e2e: merged
|
||||||
|
PUT lands the URL and preserves masked secrets verbatim.
|
||||||
|
- [x] **Backup export**: `GET /databases/:id/backups/:bid/download` streams
|
||||||
|
the archive out of the `containr-db-backups` volume via a stopped utility
|
||||||
|
container + `CopyFromContainer` (daemon logs mangle non-UTF-8, so `cat`
|
||||||
|
through logs was not viable). Download action in the `/databases` backups
|
||||||
|
list. Verified e2e: byte-identical archive, 404 missing/cross-user,
|
||||||
|
401 unauthenticated.
|
||||||
|
- [x] **Scheduled database backups**: `database_services.backup_schedule`
|
||||||
|
(cron expr) + `next_backup_at`; `PUT /databases/:id` accepts
|
||||||
|
`backup_schedule` (validated, empty string disables). The cron tick folds
|
||||||
|
due databases into `createBackupProcess`. Schedule row in the
|
||||||
|
`/databases` backups section. Verified e2e: `* * * * *` produced three
|
||||||
|
consecutive completed archives with real volume data.
|
||||||
|
|
||||||
|
**Gate**: every registered route is reachable from the UI or has an explicit
|
||||||
|
"API-only" note in the spec. A request to `/g/{service}` with a valid key
|
||||||
|
reaches the upstream container and appears in the analytics tables.
|
||||||
|
|
||||||
|
### Phase 3 — Advanced operations
|
||||||
|
|
||||||
|
- [x] **Autoscaling UI** (`/scaling/*`): per-service policy editor
|
||||||
|
(min/max/target CPU/mem + enable toggle) and manual scale control
|
||||||
|
on service detail Scaling section. Verified live: policy CRUD,
|
||||||
|
state auto-registration, bounds rejection, scheduler dispatch
|
||||||
|
(fails "no ready nodes" without agents — error surfaces in UI).
|
||||||
|
Scaling history timeline not surfaced. Policies now persisted to
|
||||||
|
Postgres (survive restart); scale events remain in-memory.
|
||||||
|
- [x] **HA / failover UI** (`/ha/*`): `/ha` page with manager enable/disable,
|
||||||
|
status cards (nodes/health checks/alerts), manual failover with
|
||||||
|
confirmation, per-service failover policy CRUD (project→service
|
||||||
|
picker), active alerts with resolve, health check results. Verified
|
||||||
|
live. Beta: policies/alerts are in-memory, lost on restart.
|
||||||
|
Health-check and notifier CRUD endpoints remain unexposed.
|
||||||
|
- [x] **Security center** (`/security/*`): `/security` page with project
|
||||||
|
picker, scan trigger (type + optional service), security score and
|
||||||
|
vuln metrics cards, findings list with resolve/ignore, scan history.
|
||||||
|
Fixed latent bug: `security/metrics` queried a non-existent `score`
|
||||||
|
column (score lives in `summary` jsonb) — was 500 on every project.
|
||||||
|
Verified live: comprehensive scan produced 4 findings (3 high, 1
|
||||||
|
medium), resolve flow, metrics aggregation. Real mechanism
|
||||||
|
documented: heuristic config analysis, NOT Trivy/Grype.
|
||||||
|
Remaining: compliance assessment/report viewer UI, security audit
|
||||||
|
correlation view.
|
||||||
|
- [ ] **Multi-node scheduling**: service → node placement rules using agent
|
||||||
|
telemetry; health-based rescheduling. Depends on Phase 2 agents.
|
||||||
|
- [ ] **Infrastructure providers over Terraform** (per §2.2):
|
||||||
|
- [ ] `internal/iac` package: wraps terraform-exec/OpenTofu — render
|
||||||
|
provider module + vars, `init`/`apply`/`destroy` in per-node
|
||||||
|
workspaces under `data/iac/`, capture outputs (IP, name, node id).
|
||||||
|
- [ ] `infra_connections` table: provider type (`proxmox`, `aws`),
|
||||||
|
encrypted credentials, instance-scoped; managed in Settings.
|
||||||
|
- [ ] `proxmox` module: LXC/VM on chosen node/storage, cloud-init installs
|
||||||
|
Docker + `containr-agent` with enrollment token → auto-registers.
|
||||||
|
- [ ] `aws` module: EC2 instance (AMI + instance type + SG) with the same
|
||||||
|
cloud-init enrollment → same node pipeline, different provider.
|
||||||
|
- [ ] Register `RegisterProxmoxRoutes` behind auth at
|
||||||
|
`/api/v1/proxmox/*` for read/inventory (cluster, nodes, stats,
|
||||||
|
existing VMs/LXCs) — write/provision path is Terraform only.
|
||||||
|
- [ ] Node lifecycle: destroy = `terraform destroy` + agent deregistration;
|
||||||
|
drift detection via periodic `plan` (mark node drifted in UI).
|
||||||
|
- [ ] UI — Nodes/Infrastructure page: provider connections, provision
|
||||||
|
wizard (pick provider → region/node → size → enroll), node cards
|
||||||
|
with provider badge, VM/LXC lifecycle actions.
|
||||||
|
- [ ] **Environments** (per §2.4): env switcher, per-env variables/domains,
|
||||||
|
promote flow — ships with preview environments.
|
||||||
|
- [x] **Service exec console**: `POST /services/:id/exec` + Console section
|
||||||
|
on service detail — one-off `sh -c` commands via `ExecRun` (30s cap,
|
||||||
|
64KB output), ownership-checked, captured output + exit code per run.
|
||||||
|
Verified live: real container exec, stderr capture, 404 foreign
|
||||||
|
service, 401 unauthenticated. Interactive PTY remains out of scope.
|
||||||
|
|
||||||
|
**Gate**: scaling and failover demonstrably work against a two-node lab;
|
||||||
|
security scans produce real reports, not empty tables.
|
||||||
|
|
||||||
|
### Phase 4 — Productization and release
|
||||||
|
|
||||||
|
- [ ] **First-run experience**: setup wizard (create admin → optional OAuth →
|
||||||
|
optional GitHub App → done), seeded demo project optional, empty states
|
||||||
|
everywhere.
|
||||||
|
- [ ] **Install story**: single `curl | sh`-style installer that writes
|
||||||
|
`.env`, pulls images, runs compose; verify on a clean VPS.
|
||||||
|
- [ ] **Self-upgrade loop**: verify `/system/upgrade/pull` actually swaps the
|
||||||
|
running image; document rollback.
|
||||||
|
- [x] **Docs pass (partial)**: `AUTOSCALING.md` rewritten to document the
|
||||||
|
real `/scaling` API + Scaling section (was generic Cloudflare/Swarm
|
||||||
|
advice); new `docs/guides/OPERATIONS.md` covers cron, exec console,
|
||||||
|
managed databases, preview environments, security scans, HA, and
|
||||||
|
node-agent onboarding — all synced to verified behavior. Remaining:
|
||||||
|
rewrite getting-started to the canvas flow; publish OpenAPI at
|
||||||
|
`/docs` inside the app (Docs page exists — render the spec there).
|
||||||
|
- [ ] **Tests**: raise frontend beyond smoke (canvas interactions, template
|
||||||
|
install wizard, git connect); backend table-driven tests for
|
||||||
|
deployments, templates import, webhooks; Playwright e2e suite for the
|
||||||
|
Phase-1 golden path.
|
||||||
|
- [ ] **OAuth e2e with real credentials**: GitHub/Google providers are
|
||||||
|
env-gated and config-verified only — run the happy path once real
|
||||||
|
provider apps exist (bootstrap gate already proven: OAuth cannot bypass
|
||||||
|
it via `disableImplicitSignUp`).
|
||||||
|
- [ ] **sqlc port — remaining raw SQL**: ~140 hand-written `database/sql`
|
||||||
|
call sites across ~14 handler files remain (agents + tokens are sqlc).
|
||||||
|
Mechanical port to `sqlc/queries/`; the consolidated baseline schema is
|
||||||
|
now the codegen source of truth.
|
||||||
|
- [x] **Autoscaling persistence**: `scaling_policies` table + autoscaler
|
||||||
|
`WithPersistence`/`LoadPolicies` — policies upsert on every set and
|
||||||
|
hydrate on boot, re-registering service state at `min_replicas`.
|
||||||
|
Verified: policy survives a backend restart. Scale event history and
|
||||||
|
placement maps remain in-memory (history resets on restart —
|
||||||
|
acceptable for beta).
|
||||||
|
- [x] **Demo-data parity for new pages**: `demoDatabases` fixture wired
|
||||||
|
into `/databases?demo=1`; `demoCronJobsByService` fixtures wired into
|
||||||
|
the service Cron tab (worker + api demo services). Scaling, Previews,
|
||||||
|
HA and Security sections still show empty in demo mode — acceptable:
|
||||||
|
those are interaction surfaces, not dashboards.
|
||||||
|
- [x] **CI**: existing workflow already covered Go tests, frontend
|
||||||
|
lint/typecheck/build, compose config validation, and startup
|
||||||
|
preflight. Added: compose **boot** smoke (postgres + redis + backend
|
||||||
|
+ frontend + traefik up, `/health` poll, frontend check, teardown)
|
||||||
|
and a tag-gated GHCR publish job (backend + frontend images on
|
||||||
|
`v*` tags). Not yet observed green on GitHub — first run pending.
|
||||||
|
- [ ] **Release**: `v1.0.0` tag, changelog, release notes, screenshots refresh
|
||||||
|
in README.
|
||||||
|
- [ ] Post-build workflow per repo rules: codebase-to-course, desloppify,
|
||||||
|
security-reviewer, audit; append README scorecard.
|
||||||
|
|
||||||
|
**Gate**: clean VPS → installer → first service deployed in under 10 minutes;
|
||||||
|
CI green; docs match the product.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Cross-Cutting (every phase)
|
||||||
|
|
||||||
|
- **Spec parity**: OpenAPI is the contract — regenerate types on every API
|
||||||
|
change (`npm run generate:api`); CI check for drift.
|
||||||
|
- **No manual API type duplication** in `api-client.ts` — consume
|
||||||
|
`generated/api-types.ts`.
|
||||||
|
- **Vertice design system**: all new UI follows `index.css` tokens, mono
|
||||||
|
labels, lime accent; no new palettes.
|
||||||
|
- **Demo mode parity**: every new page ships demo data in `demo-data.ts`.
|
||||||
|
- **Accessibility**: keyboard nav, focus rings, aria labels — the shell
|
||||||
|
already sets the pattern.
|
||||||
|
- **Commits**: conventional messages, `Authored By: TDvorak <[email protected]>`
|
||||||
|
trailer; squash-merge bodies kept short.
|
||||||
|
- **Pre-push gate**: build + test + lint + boot smoke, every time.
|
||||||
|
|
||||||
|
## 5. Explicitly Out of Scope for v1.0
|
||||||
|
|
||||||
|
- Billing / metering (self-hosted; Usage page is monitoring-only).
|
||||||
|
- Kubernetes backend — Docker/Compose is the runtime.
|
||||||
|
- Magic-link, GitLab/Bitbucket/Gitea **auth** (removed by auth spec). Git
|
||||||
|
*providers* beyond GitHub remain PAT-based.
|
||||||
|
- Mobile app, TUI, desktop app.
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please do **not** open a public issue for security vulnerabilities.
|
||||||
|
|
||||||
|
Report privately via GitHub's private vulnerability reporting
|
||||||
|
(Security tab → Report a vulnerability), or contact the maintainer directly.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- A description of the vulnerability and its impact
|
||||||
|
- Steps to reproduce
|
||||||
|
- Affected version/commit if known
|
||||||
|
|
||||||
|
You will receive an acknowledgement as soon as possible. Please allow
|
||||||
|
reasonable time for a fix before any public disclosure.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Containr is a self-hosted platform that runs Docker containers, stores
|
||||||
|
credentials, and terminates user traffic. In scope:
|
||||||
|
|
||||||
|
- Authentication and session handling (Better Auth sidecar, JWT, agent tokens)
|
||||||
|
- Secrets storage (Git provider tokens, environment variables)
|
||||||
|
- API authorization and per-user resource scoping
|
||||||
|
- Webhook signature validation
|
||||||
|
- CORS and trusted-origin configuration
|
||||||
|
- Deployment engine command/path handling
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
- Never commit `.env`, `.env.prod`, or any file containing credentials.
|
||||||
|
- Git provider access tokens are stored server-side only and are never
|
||||||
|
returned by the API.
|
||||||
|
- Rotate any secret that has ever been committed to git history.
|
||||||
|
|
||||||
|
## Self-Hosting Notes
|
||||||
|
|
||||||
|
- Set strong `JWT_SECRET`, `BETTER_AUTH_SECRET`, and
|
||||||
|
`BETTER_AUTH_INTERNAL_TOKEN` values in production.
|
||||||
|
- Set `COOKIE_SECURE=true` when serving over HTTPS.
|
||||||
|
- Restrict `CORS_ALLOWED_ORIGINS` to your actual frontend origin.
|
||||||
|
- Keep Traefik's dashboard disabled or behind auth in production
|
||||||
|
(`TRAEFIK_API_INSECURE=false`).
|
||||||
@@ -32,7 +32,6 @@ RUN addgroup -g 1001 -S appgroup && \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=go-builder /app/main ./main
|
COPY --from=go-builder /app/main ./main
|
||||||
COPY --from=go-builder /app/migrations ./migrations
|
|
||||||
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
COPY --from=go-builder /app/migrations_goose ./migrations_goose
|
||||||
COPY --from=auth-builder /auth ./auth
|
COPY --from=auth-builder /auth ./auth
|
||||||
|
|
||||||
|
|||||||
@@ -170,8 +170,7 @@ internal/
|
|||||||
├── config/ - Configuration management
|
├── config/ - Configuration management
|
||||||
├── database/ - Database connections and migrations
|
├── database/ - Database connections and migrations
|
||||||
└── middleware/ - HTTP middleware
|
└── middleware/ - HTTP middleware
|
||||||
migrations/ - SQL migration files
|
migrations_goose/ - Goose-managed migrations (single migration path)
|
||||||
migrations_goose/ - Goose-managed migrations for new schema changes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding New Endpoints
|
### Adding New Endpoints
|
||||||
@@ -179,9 +178,9 @@ migrations_goose/ - Goose-managed migrations for new schema changes
|
|||||||
1. Create handler functions in `internal/api/`
|
1. Create handler functions in `internal/api/`
|
||||||
2. Add routes in `internal/api/routes.go`
|
2. Add routes in `internal/api/routes.go`
|
||||||
3. Update database schema:
|
3. Update database schema:
|
||||||
- Legacy chain lives in `migrations/` and is executed first.
|
- All migrations live in `migrations_goose/` using goose format (`-- +goose Up/Down`).
|
||||||
- New migrations must be added to `migrations_goose/` using goose format (`-- +goose Up/Down`).
|
- `20260401163000_baseline.sql` carries the consolidated schema; append new files after it.
|
||||||
- Keep legacy files immutable after they are shipped.
|
- Never edit the baseline in place; always add a new migration file.
|
||||||
|
|
||||||
### SQLC (Type-Safe Queries)
|
### SQLC (Type-Safe Queries)
|
||||||
|
|
||||||
|
|||||||
+16
-183
@@ -1,6 +1,4 @@
|
|||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
import { magicLink } from 'better-auth/plugins/magic-link';
|
|
||||||
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { PostgresDialect } from 'kysely';
|
import { PostgresDialect } from 'kysely';
|
||||||
import { getMigrations } from 'better-auth/db/migration';
|
import { getMigrations } from 'better-auth/db/migration';
|
||||||
@@ -50,41 +48,22 @@ const database = new PostgresDialect({
|
|||||||
|
|
||||||
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
|
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
|
||||||
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
|
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
|
||||||
|
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || '';
|
||||||
const gitlabClientId = env.GITLAB_CLIENT_ID?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_ID';
|
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || '';
|
||||||
const gitlabClientSecret = env.GITLAB_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_SECRET';
|
|
||||||
const gitlabAuthorizeUrl =
|
|
||||||
env.GITLAB_OAUTH_AUTHORIZE_URL?.trim() || 'https://gitlab.com/oauth/authorize';
|
|
||||||
const gitlabTokenUrl = env.GITLAB_OAUTH_TOKEN_URL?.trim() || 'https://gitlab.com/oauth/token';
|
|
||||||
const gitlabUserUrl = env.GITLAB_OAUTH_USERINFO_URL?.trim() || 'https://gitlab.com/api/v4/user';
|
|
||||||
|
|
||||||
const giteaClientId = env.GITEA_CLIENT_ID?.trim() || 'PLACEHOLDER_GITEA_CLIENT_ID';
|
|
||||||
const giteaClientSecret = env.GITEA_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITEA_CLIENT_SECRET';
|
|
||||||
const giteaAuthorizeUrl =
|
|
||||||
env.GITEA_OAUTH_AUTHORIZE_URL?.trim() ||
|
|
||||||
'https://gitea.example.com/login/oauth/authorize';
|
|
||||||
const giteaTokenUrl =
|
|
||||||
env.GITEA_OAUTH_TOKEN_URL?.trim() ||
|
|
||||||
'https://gitea.example.com/login/oauth/access_token';
|
|
||||||
const giteaUserUrl = env.GITEA_OAUTH_USERINFO_URL?.trim() || 'https://gitea.example.com/api/v1/user';
|
|
||||||
|
|
||||||
const bitbucketClientId = env.BITBUCKET_CLIENT_ID?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_ID';
|
|
||||||
const bitbucketClientSecret =
|
|
||||||
env.BITBUCKET_CLIENT_SECRET?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_SECRET';
|
|
||||||
const bitbucketAuthorizeUrl =
|
|
||||||
env.BITBUCKET_OAUTH_AUTHORIZE_URL?.trim() || 'https://bitbucket.org/site/oauth2/authorize';
|
|
||||||
const bitbucketTokenUrl =
|
|
||||||
env.BITBUCKET_OAUTH_TOKEN_URL?.trim() || 'https://bitbucket.org/site/oauth2/access_token';
|
|
||||||
const bitbucketUserUrl =
|
|
||||||
env.BITBUCKET_OAUTH_USERINFO_URL?.trim() || 'https://api.bitbucket.org/2.0/user';
|
|
||||||
const bitbucketEmailsUrl =
|
|
||||||
env.BITBUCKET_OAUTH_EMAILS_URL?.trim() || 'https://api.bitbucket.org/2.0/user/emails';
|
|
||||||
|
|
||||||
const socialProviders = {};
|
const socialProviders = {};
|
||||||
if (githubClientId && githubClientSecret) {
|
if (githubClientId && githubClientSecret) {
|
||||||
socialProviders.github = {
|
socialProviders.github = {
|
||||||
clientId: githubClientId,
|
clientId: githubClientId,
|
||||||
clientSecret: githubClientSecret,
|
clientSecret: githubClientSecret,
|
||||||
|
disableImplicitSignUp: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (googleClientId && googleClientSecret) {
|
||||||
|
socialProviders.google = {
|
||||||
|
clientId: googleClientId,
|
||||||
|
clientSecret: googleClientSecret,
|
||||||
|
disableImplicitSignUp: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +76,12 @@ const trustedOrigins = [
|
|||||||
'http://localhost:3000',
|
'http://localhost:3000',
|
||||||
'http://localhost:8082',
|
'http://localhost:8082',
|
||||||
'http://localhost:3001',
|
'http://localhost:3001',
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://localhost:5174',
|
||||||
|
'http://localhost:5175',
|
||||||
|
'http://127.0.0.1:5173',
|
||||||
|
'http://127.0.0.1:5174',
|
||||||
|
'http://127.0.0.1:5175',
|
||||||
]
|
]
|
||||||
.map((origin) => (origin || '').trim())
|
.map((origin) => (origin || '').trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -130,158 +115,6 @@ export const auth = betterAuth({
|
|||||||
maxPasswordLength: 128,
|
maxPasswordLength: 128,
|
||||||
},
|
},
|
||||||
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
|
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
|
||||||
plugins: [
|
|
||||||
magicLink({
|
|
||||||
sendMagicLink: async ({ email, url, token, metadata }) => {
|
|
||||||
const payload = {
|
|
||||||
email,
|
|
||||||
url,
|
|
||||||
token,
|
|
||||||
metadata,
|
|
||||||
};
|
|
||||||
console.log('[better-auth] magic-link', JSON.stringify(payload));
|
|
||||||
},
|
|
||||||
disableSignUp: false,
|
|
||||||
}),
|
|
||||||
genericOAuth({
|
|
||||||
config: [
|
|
||||||
{
|
|
||||||
providerId: 'gitlab',
|
|
||||||
clientId: gitlabClientId,
|
|
||||||
clientSecret: gitlabClientSecret,
|
|
||||||
authorizationUrl: gitlabAuthorizeUrl,
|
|
||||||
tokenUrl: gitlabTokenUrl,
|
|
||||||
userInfoUrl: gitlabUserUrl,
|
|
||||||
scopes: ['read_user', 'read_api'],
|
|
||||||
getUserInfo: async (tokens) => {
|
|
||||||
if (!tokens.accessToken) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(gitlabUserUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${tokens.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await response.json();
|
|
||||||
if (!profile || (!profile.id && !profile.username)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: profile.id || profile.username,
|
|
||||||
email: profile.email || null,
|
|
||||||
name: profile.name || profile.username || 'GitLab User',
|
|
||||||
image: profile.avatar_url || null,
|
|
||||||
emailVerified: Boolean(profile.email),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
providerId: 'gitea',
|
|
||||||
clientId: giteaClientId,
|
|
||||||
clientSecret: giteaClientSecret,
|
|
||||||
authorizationUrl: giteaAuthorizeUrl,
|
|
||||||
tokenUrl: giteaTokenUrl,
|
|
||||||
userInfoUrl: giteaUserUrl,
|
|
||||||
scopes: ['read:user', 'read:repository'],
|
|
||||||
getUserInfo: async (tokens) => {
|
|
||||||
if (!tokens.accessToken) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(giteaUserUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `token ${tokens.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await response.json();
|
|
||||||
if (!profile || (!profile.id && !profile.login)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: profile.id || profile.login,
|
|
||||||
email: profile.email || null,
|
|
||||||
name: profile.full_name || profile.login || 'Gitea User',
|
|
||||||
image: profile.avatar_url || null,
|
|
||||||
emailVerified: Boolean(profile.email),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
providerId: 'bitbucket',
|
|
||||||
clientId: bitbucketClientId,
|
|
||||||
clientSecret: bitbucketClientSecret,
|
|
||||||
authorizationUrl: bitbucketAuthorizeUrl,
|
|
||||||
tokenUrl: bitbucketTokenUrl,
|
|
||||||
userInfoUrl: bitbucketUserUrl,
|
|
||||||
scopes: ['account', 'email', 'repository'],
|
|
||||||
getUserInfo: async (tokens) => {
|
|
||||||
if (!tokens.accessToken) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userResponse = await fetch(bitbucketUserUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${tokens.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userResponse.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await userResponse.json();
|
|
||||||
if (!user || (!user.account_id && !user.username && !user.nickname)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let primaryEmail = null;
|
|
||||||
const emailsResponse = await fetch(bitbucketEmailsUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Bearer ${tokens.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (emailsResponse.ok) {
|
|
||||||
const emailsPayload = await emailsResponse.json();
|
|
||||||
const values = Array.isArray(emailsPayload?.values) ? emailsPayload.values : [];
|
|
||||||
const primary = values.find((entry) => entry?.is_primary) || values[0];
|
|
||||||
if (primary && typeof primary.email === 'string') {
|
|
||||||
primaryEmail = primary.email;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.account_id || user.username || user.nickname,
|
|
||||||
email: primaryEmail,
|
|
||||||
name: user.display_name || user.nickname || 'Bitbucket User',
|
|
||||||
image: user?.links?.avatar?.href || null,
|
|
||||||
emailVerified: Boolean(primaryEmail),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function runAuthMigrations() {
|
export async function runAuthMigrations() {
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type agentConfig struct {
|
||||||
|
APIURL string
|
||||||
|
Token string
|
||||||
|
Name string
|
||||||
|
Hostname string
|
||||||
|
IP string
|
||||||
|
Port int
|
||||||
|
Interval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type capabilities struct {
|
||||||
|
ContainerRuntimes []string `json:"container_runtimes"`
|
||||||
|
SupportedArchitectures []string `json:"supported_architectures"`
|
||||||
|
MaxContainers int `json:"max_containers"`
|
||||||
|
StorageDriver string `json:"storage_driver"`
|
||||||
|
NetworkPlugins []string `json:"network_plugins"`
|
||||||
|
Features []string `json:"features"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type nodeResources struct {
|
||||||
|
CPU cpuResources `json:"cpu"`
|
||||||
|
Memory memoryResources `json:"memory"`
|
||||||
|
Storage storageResources `json:"storage"`
|
||||||
|
Network networkResources `json:"network"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cpuResources struct {
|
||||||
|
Cores int `json:"cores"`
|
||||||
|
Allocation float64 `json:"allocation"`
|
||||||
|
Usage float64 `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryResources struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Allocated int `json:"allocated"`
|
||||||
|
Used int `json:"used"`
|
||||||
|
Available int `json:"available"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type storageResources struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Allocated int `json:"allocated"`
|
||||||
|
Used int `json:"used"`
|
||||||
|
Available int `json:"available"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type networkResources struct {
|
||||||
|
Interfaces []networkInterface `json:"interfaces"`
|
||||||
|
Bandwidth bandwidthInfo `json:"bandwidth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type networkInterface struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
MACAddress string `json:"mac_address"`
|
||||||
|
Speed int `json:"speed"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bandwidthInfo struct {
|
||||||
|
Inbound int `json:"inbound"`
|
||||||
|
Outbound int `json:"outbound"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type systemLoad struct {
|
||||||
|
Load1M float64 `json:"load_1m"`
|
||||||
|
Load5M float64 `json:"load_5m"`
|
||||||
|
Load15M float64 `json:"load_15m"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type command struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload map[string]interface{} `json:"payload"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commandList struct {
|
||||||
|
Commands []command `json:"commands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerResponse struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
agentID, err := register(context.Background(), client, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("register agent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("containr agent connected: id=%s api=%s", agentID, cfg.APIURL)
|
||||||
|
ticker := time.NewTicker(cfg.Interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := sendHeartbeat(context.Background(), client, cfg, agentID); err != nil {
|
||||||
|
log.Printf("heartbeat failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := processCommands(context.Background(), client, cfg, agentID); err != nil {
|
||||||
|
log.Printf("command poll failed: %v", err)
|
||||||
|
}
|
||||||
|
<-ticker.C
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (agentConfig, error) {
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
apiURL := strings.TrimRight(firstEnv("CONTAINR_API_URL", "http://localhost:8082"), "/")
|
||||||
|
apiURL = strings.TrimSuffix(strings.TrimSuffix(apiURL, "/api/v1"), "/api")
|
||||||
|
token := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKEN"))
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(os.Getenv("CONTAINR_AGENT_TOKEN"))
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return agentConfig{}, errors.New("set CONTAINR_AGENT_AUTH_TOKEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalSeconds, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_INTERVAL_SECONDS", "15"))
|
||||||
|
if intervalSeconds < 5 {
|
||||||
|
intervalSeconds = 5
|
||||||
|
}
|
||||||
|
port, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_PORT", "9090"))
|
||||||
|
|
||||||
|
return agentConfig{
|
||||||
|
APIURL: apiURL,
|
||||||
|
Token: token,
|
||||||
|
Name: firstEnv("CONTAINR_AGENT_NAME", hostname),
|
||||||
|
Hostname: firstEnv("CONTAINR_AGENT_HOSTNAME", hostname),
|
||||||
|
IP: firstEnv("CONTAINR_AGENT_IP", detectOutboundIP()),
|
||||||
|
Port: port,
|
||||||
|
Interval: time.Duration(intervalSeconds) * time.Second,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(ctx context.Context, client *http.Client, cfg agentConfig) (string, error) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"name": cfg.Name,
|
||||||
|
"hostname": cfg.Hostname,
|
||||||
|
"ip_address": cfg.IP,
|
||||||
|
"port": cfg.Port,
|
||||||
|
"capabilities": detectCapabilities(),
|
||||||
|
}
|
||||||
|
var response registerResponse
|
||||||
|
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if response.AgentID == "" {
|
||||||
|
return "", errors.New("register response missing agent_id")
|
||||||
|
}
|
||||||
|
return response.AgentID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendHeartbeat(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error {
|
||||||
|
resources := collectResources(cfg.IP)
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"node_agent_id": agentID,
|
||||||
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"status": agentStatus(),
|
||||||
|
"resources": resources,
|
||||||
|
"container_count": dockerContainerCount(),
|
||||||
|
"system_load": readSystemLoad(),
|
||||||
|
"uptime": readSystemUptime(),
|
||||||
|
"version": firstEnv("CONTAINR_AGENT_VERSION", "dev"),
|
||||||
|
}
|
||||||
|
return postJSON(ctx, client, cfg, "/api/agents/heartbeat", payload, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func processCommands(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/agents/%s/commands", cfg.APIURL, agentID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Containr-Agent-Token", cfg.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("command poll status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var list commandList
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range list.Commands {
|
||||||
|
result, runErr := executeCommand(item)
|
||||||
|
status := "completed"
|
||||||
|
errText := ""
|
||||||
|
if runErr != nil {
|
||||||
|
status = "failed"
|
||||||
|
errText = runErr.Error()
|
||||||
|
}
|
||||||
|
payload := map[string]string{
|
||||||
|
"status": status,
|
||||||
|
"result": result,
|
||||||
|
"error": errText,
|
||||||
|
}
|
||||||
|
path := fmt.Sprintf("/api/agents/%s/commands/%s/result", agentID, item.ID)
|
||||||
|
if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil {
|
||||||
|
log.Printf("report command result failed: command=%s err=%v", item.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeCommand(item command) (string, error) {
|
||||||
|
switch item.Type {
|
||||||
|
case "create_container":
|
||||||
|
return createContainer(item.Payload)
|
||||||
|
case "start_container":
|
||||||
|
return runDocker("start", commandTarget(item.Payload))
|
||||||
|
case "stop_container":
|
||||||
|
return runDocker("stop", commandTarget(item.Payload))
|
||||||
|
case "restart_container":
|
||||||
|
return runDocker("restart", commandTarget(item.Payload))
|
||||||
|
case "remove_container":
|
||||||
|
return runDocker("rm", "-f", commandTarget(item.Payload))
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported command type %q", item.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createContainer(payload map[string]interface{}) (string, error) {
|
||||||
|
container, ok := payload["container"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("missing container payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := stringValue(container["name"])
|
||||||
|
image := stringValue(container["image"])
|
||||||
|
if name == "" || image == "" {
|
||||||
|
return "", errors.New("container name and image are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"run", "-d", "--name", name, "--restart", "unless-stopped"}
|
||||||
|
if env, ok := container["environment"].(map[string]interface{}); ok {
|
||||||
|
for key, value := range env {
|
||||||
|
args = append(args, "-e", fmt.Sprintf("%s=%v", key, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ports, ok := container["ports"].([]interface{}); ok {
|
||||||
|
for _, rawPort := range ports {
|
||||||
|
port, ok := rawPort.(map[string]interface{})
|
||||||
|
if !ok || !boolValue(port["published"]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hostPort := intValue(port["host_port"])
|
||||||
|
containerPort := intValue(port["container_port"])
|
||||||
|
protocol := firstString(stringValue(port["protocol"]), "tcp")
|
||||||
|
if hostPort > 0 && containerPort > 0 {
|
||||||
|
args = append(args, "-p", fmt.Sprintf("%d:%d/%s", hostPort, containerPort, protocol))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if volumes, ok := container["volumes"].([]interface{}); ok {
|
||||||
|
for _, rawVolume := range volumes {
|
||||||
|
volume, ok := rawVolume.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source := stringValue(volume["source"])
|
||||||
|
target := stringValue(volume["target"])
|
||||||
|
if source != "" && target != "" {
|
||||||
|
mount := source + ":" + target
|
||||||
|
if boolValue(volume["read_only"]) {
|
||||||
|
mount += ":ro"
|
||||||
|
}
|
||||||
|
args = append(args, "-v", mount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args = append(args, image)
|
||||||
|
return runDocker(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandTarget(payload map[string]interface{}) string {
|
||||||
|
for _, key := range []string{"container_name", "docker_name", "name", "container_id"} {
|
||||||
|
if value := stringValue(payload[key]); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSON(ctx context.Context, client *http.Client, cfg agentConfig, path string, payload interface{}, out interface{}) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.APIURL+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Containr-Agent-Token", cfg.Token)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("request %s failed with status %d", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectCapabilities() capabilities {
|
||||||
|
runtimes := []string{}
|
||||||
|
if dockerAvailable() {
|
||||||
|
runtimes = append(runtimes, "docker")
|
||||||
|
}
|
||||||
|
return capabilities{
|
||||||
|
ContainerRuntimes: runtimes,
|
||||||
|
SupportedArchitectures: []string{runtime.GOARCH},
|
||||||
|
MaxContainers: 100,
|
||||||
|
StorageDriver: dockerStorageDriver(),
|
||||||
|
NetworkPlugins: []string{"bridge"},
|
||||||
|
Features: []string{"host-metrics", "heartbeats", "command-poll", "docker-lifecycle"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectResources(ip string) nodeResources {
|
||||||
|
mem := readMemory()
|
||||||
|
storage := readStorage("/")
|
||||||
|
return nodeResources{
|
||||||
|
CPU: cpuResources{
|
||||||
|
Cores: runtime.NumCPU(),
|
||||||
|
Allocation: 0,
|
||||||
|
Usage: readCPULoadPercent(),
|
||||||
|
},
|
||||||
|
Memory: mem,
|
||||||
|
Storage: storage,
|
||||||
|
Network: networkResources{
|
||||||
|
Interfaces: []networkInterface{{
|
||||||
|
Name: "primary",
|
||||||
|
IPAddress: ip,
|
||||||
|
Speed: 0,
|
||||||
|
Status: "up",
|
||||||
|
}},
|
||||||
|
Bandwidth: bandwidthInfo{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMemory() memoryResources {
|
||||||
|
values := map[string]int{}
|
||||||
|
data, err := os.ReadFile("/proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return memoryResources{}
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(fields[1])
|
||||||
|
if err == nil {
|
||||||
|
values[strings.TrimSuffix(fields[0], ":")] = value * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := values["MemTotal"]
|
||||||
|
available := values["MemAvailable"]
|
||||||
|
used := total - available
|
||||||
|
if used < 0 {
|
||||||
|
used = 0
|
||||||
|
}
|
||||||
|
return memoryResources{Total: total, Used: used, Available: available}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStorage(path string) storageResources {
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(path, &stat); err != nil {
|
||||||
|
return storageResources{}
|
||||||
|
}
|
||||||
|
total := int(stat.Blocks * uint64(stat.Bsize))
|
||||||
|
available := int(stat.Bavail * uint64(stat.Bsize))
|
||||||
|
used := total - available
|
||||||
|
if used < 0 {
|
||||||
|
used = 0
|
||||||
|
}
|
||||||
|
return storageResources{Total: total, Used: used, Available: available}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSystemLoad() systemLoad {
|
||||||
|
data, err := os.ReadFile("/proc/loadavg")
|
||||||
|
if err != nil {
|
||||||
|
return systemLoad{}
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(data))
|
||||||
|
if len(fields) < 3 {
|
||||||
|
return systemLoad{}
|
||||||
|
}
|
||||||
|
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||||
|
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||||
|
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||||
|
return systemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSystemUptime() int64 {
|
||||||
|
data, err := os.ReadFile("/proc/uptime")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(data))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
uptime, _ := strconv.ParseFloat(fields[0], 64)
|
||||||
|
return int64(uptime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCPULoadPercent() float64 {
|
||||||
|
load := readSystemLoad()
|
||||||
|
cores := runtime.NumCPU()
|
||||||
|
if cores == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
percent := load.Load1M / float64(cores) * 100
|
||||||
|
if percent < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if percent > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return percent
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDocker(args ...string) (string, error) {
|
||||||
|
if len(args) == 0 || args[len(args)-1] == "" {
|
||||||
|
return "", errors.New("missing docker target")
|
||||||
|
}
|
||||||
|
cmd := exec.Command("docker", args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
return strings.TrimSpace(string(output)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerAvailable() bool {
|
||||||
|
_, err := exec.LookPath("docker")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cmd := exec.Command("docker", "info", "--format", "{{.ServerVersion}}")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerStorageDriver() string {
|
||||||
|
out, err := exec.Command("docker", "info", "--format", "{{.Driver}}").Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func dockerContainerCount() int {
|
||||||
|
out, err := exec.Command("docker", "ps", "-q").Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
if strings.TrimSpace(line) != "" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func agentStatus() string {
|
||||||
|
if dockerAvailable() {
|
||||||
|
return "online"
|
||||||
|
}
|
||||||
|
return "degraded"
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectOutboundIP() string {
|
||||||
|
conn, err := net.DialTimeout("udp", "8.8.8.8:80", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||||
|
return addr.IP.String()
|
||||||
|
}
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstEnv(key, fallback string) string {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstString(value, fallback string) string {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(value interface{}) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return typed
|
||||||
|
case fmt.Stringer:
|
||||||
|
return typed.String()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intValue(value interface{}) int {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case int:
|
||||||
|
return typed
|
||||||
|
case int64:
|
||||||
|
return int(typed)
|
||||||
|
case float64:
|
||||||
|
return int(typed)
|
||||||
|
case json.Number:
|
||||||
|
out, _ := typed.Int64()
|
||||||
|
return int(out)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolValue(value interface{}) bool {
|
||||||
|
typed, _ := value.(bool)
|
||||||
|
return typed
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"containr/internal/apwhy/api"
|
|
||||||
"containr/internal/apwhy/config"
|
|
||||||
"containr/internal/apwhy/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cfg := config.Load()
|
|
||||||
|
|
||||||
store, err := storage.Open(cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to initialize storage: %v", err)
|
|
||||||
}
|
|
||||||
defer store.Close()
|
|
||||||
|
|
||||||
server := api.NewServer(store, cfg)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
|
||||||
log.Printf("APwhy server listening on http://localhost%s", addr)
|
|
||||||
if err := http.ListenAndServe(addr, server.Handler()); err != nil {
|
|
||||||
log.Fatalf("server exited: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,11 +25,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
legacyDir := os.Getenv("LEGACY_MIGRATIONS_DIR")
|
|
||||||
if legacyDir == "" {
|
|
||||||
legacyDir = "migrations"
|
|
||||||
}
|
|
||||||
|
|
||||||
gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR")
|
gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR")
|
||||||
if gooseDir == "" {
|
if gooseDir == "" {
|
||||||
gooseDir = "migrations_goose"
|
gooseDir = "migrations_goose"
|
||||||
@@ -41,24 +36,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "up":
|
case "up", "goose-up":
|
||||||
migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout)
|
migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout)
|
||||||
if err := db.MigrateAllWithLock(migrationCtx, legacyDir, gooseDir); err != nil {
|
if err := db.MigrateAllWithLock(migrationCtx, gooseDir); err != nil {
|
||||||
migrationCancel()
|
migrationCancel()
|
||||||
log.Fatalf("Migration failed: %v", err)
|
log.Fatalf("Migration failed: %v", err)
|
||||||
}
|
}
|
||||||
migrationCancel()
|
migrationCancel()
|
||||||
log.Println("Legacy + goose migrations completed successfully")
|
log.Println("Migrations completed successfully")
|
||||||
case "legacy-up":
|
|
||||||
if err := db.Migrate(legacyDir); err != nil {
|
|
||||||
log.Fatalf("Legacy migration failed: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("Legacy migrations completed successfully")
|
|
||||||
case "goose-up":
|
|
||||||
if err := db.MigrateGoose(gooseDir); err != nil {
|
|
||||||
log.Fatalf("Goose migration failed: %v", err)
|
|
||||||
}
|
|
||||||
log.Println("Goose migrations completed successfully")
|
|
||||||
case "goose-status":
|
case "goose-status":
|
||||||
if err := db.GooseStatus(gooseDir); err != nil {
|
if err := db.GooseStatus(gooseDir); err != nil {
|
||||||
log.Fatalf("Goose status failed: %v", err)
|
log.Fatalf("Goose status failed: %v", err)
|
||||||
@@ -73,6 +58,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Printf("Created goose migration %q in %q", name, gooseDir)
|
log.Printf("Created goose migration %q in %q", name, gooseDir)
|
||||||
default:
|
default:
|
||||||
log.Fatalf("Unknown command %q. Supported commands: up | legacy-up | goose-up | goose-status | goose-create", command)
|
log.Fatalf("Unknown command %q. Supported commands: up | goose-status | goose-create", command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@ func main() {
|
|||||||
// Run startup migrations unless explicitly disabled.
|
// Run startup migrations unless explicitly disabled.
|
||||||
if cfg.AutoMigrate {
|
if cfg.AutoMigrate {
|
||||||
migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout)
|
migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout)
|
||||||
if err := db.MigrateAllWithLock(migrationCtx, "migrations", "migrations_goose"); err != nil {
|
if err := db.MigrateAllWithLock(migrationCtx, "migrations_goose"); err != nil {
|
||||||
migrationCancel()
|
migrationCancel()
|
||||||
log.Fatalf("Failed to run database migrations: %v", err)
|
log.Fatalf("Failed to run database migrations: %v", err)
|
||||||
}
|
}
|
||||||
@@ -48,8 +48,8 @@ func main() {
|
|||||||
log.Println("AUTO_MIGRATE disabled; skipping startup migrations")
|
log.Println("AUTO_MIGRATE disabled; skipping startup migrations")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed demo data in development (or when explicitly requested).
|
// Seed demo data only when explicitly requested.
|
||||||
if cfg.IsDevelopment() || cfg.SeedDataOnStart {
|
if cfg.SeedDataOnStart {
|
||||||
if err := db.SeedData(); err != nil {
|
if err := db.SeedData(); err != nil {
|
||||||
log.Printf("Warning: Failed to seed data: %v", err)
|
log.Printf("Warning: Failed to seed data: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"containr/internal/build"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
fmt.Println("🧪 Testing Build Manager Detection...")
|
|
||||||
|
|
||||||
// Test build type detection on the current project
|
|
||||||
fmt.Println("\n📁 Testing on current project (has package.json)...")
|
|
||||||
|
|
||||||
// Note: We can't fully test BuildManager without a docker client,
|
|
||||||
// but we can test the detection logic
|
|
||||||
|
|
||||||
// Create mock scenarios
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{"Node.js project (package.json)", "railpack"},
|
|
||||||
{"Python project (requirements.txt)", "railpack"},
|
|
||||||
{"Go project (go.mod)", "railpack"},
|
|
||||||
{"Dockerfile project", "dockerfile"},
|
|
||||||
{"Unknown project", "nixpacks"},
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\n🎯 Expected detection priorities:")
|
|
||||||
for _, tc := range testCases {
|
|
||||||
fmt.Printf(" • %s → %s\n", tc.name, tc.expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Railpack builder directly
|
|
||||||
builder := build.NewRailpackBuilder("/tmp/test", nil)
|
|
||||||
|
|
||||||
fmt.Println("\n🔍 Testing Railpack detection on current project:")
|
|
||||||
err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("❌ Detection failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Println("✅ Railpack can build this project!")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\n📋 Build Priority Order:")
|
|
||||||
fmt.Println(" 1. Dockerfile (if present)")
|
|
||||||
fmt.Println(" 2. Railpack (primary choice)")
|
|
||||||
fmt.Println(" 3. Nixpacks (fallback)")
|
|
||||||
fmt.Println(" 4. Prebuilt (manual)")
|
|
||||||
|
|
||||||
fmt.Println("\n🚀 Build Manager Integration Test Complete!")
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"containr/internal/build"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Create a RailpackBuilder
|
|
||||||
builder := build.NewRailpackBuilder("/tmp/containr-build-test", nil)
|
|
||||||
|
|
||||||
// Test detection on the current project (has package.json)
|
|
||||||
fmt.Println("Testing Railpack detection on current project...")
|
|
||||||
|
|
||||||
err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("❌ Railpack detection failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Println("✅ Railpack can build this project!")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show supported frameworks
|
|
||||||
fmt.Println("\nSupported frameworks:")
|
|
||||||
frameworks := builder.GetSupportedFrameworks()
|
|
||||||
for _, fw := range frameworks {
|
|
||||||
fmt.Printf(" - %s\n", fw)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\n✅ Railpack integration test completed successfully!")
|
|
||||||
}
|
|
||||||
+2
-18
@@ -8,11 +8,9 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/go-playground/validator/v10 v10.30.1
|
github.com/go-playground/validator/v10 v10.30.1
|
||||||
github.com/go-redis/redis/v8 v8.11.5
|
github.com/go-redis/redis/v8 v8.11.5
|
||||||
github.com/go-sql-driver/mysql v1.9.3
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/jackc/pgx/v5 v5.7.1
|
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
github.com/pressly/goose/v3 v3.24.1
|
github.com/pressly/goose/v3 v3.24.1
|
||||||
@@ -22,13 +20,9 @@ require (
|
|||||||
github.com/sqlc-dev/pqtype v0.3.0
|
github.com/sqlc-dev/pqtype v0.3.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
|
||||||
gorm.io/gorm v1.31.1
|
|
||||||
modernc.org/sqlite v1.39.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -40,7 +34,6 @@ require (
|
|||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
@@ -52,11 +45,6 @@ require (
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
@@ -68,13 +56,12 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/morikuni/aec v1.1.0 // indirect
|
github.com/morikuni/aec v1.1.0 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
@@ -95,7 +82,6 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
@@ -106,7 +92,5 @@ require (
|
|||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gotest.tools/v3 v3.5.2 // indirect
|
gotest.tools/v3 v3.5.2 // indirect
|
||||||
modernc.org/libc v1.66.3 // indirect
|
modernc.org/sqlite v1.39.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.11.0 // indirect
|
|
||||||
)
|
)
|
||||||
+2
-40
@@ -1,5 +1,3 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
@@ -63,8 +61,6 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
|
|||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
@@ -74,8 +70,6 @@ github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -84,18 +78,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZ
|
|||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
|
|
||||||
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
@@ -152,6 +134,8 @@ github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4
|
|||||||
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
||||||
@@ -259,36 +243,14 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
|
||||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
|
||||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
|
||||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
|
||||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
|
||||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
|
||||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
|
||||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
|
||||||
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||||
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package analytics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
BaseURL string
|
|
||||||
APIKey string
|
|
||||||
WebsiteID string
|
|
||||||
HTTP *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(baseURL, apiKey, websiteID string) *Client {
|
|
||||||
return &Client{
|
|
||||||
BaseURL: baseURL,
|
|
||||||
APIKey: apiKey,
|
|
||||||
WebsiteID: websiteID,
|
|
||||||
HTTP: &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Enabled() bool {
|
|
||||||
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
|
|
||||||
if !c.Enabled() {
|
|
||||||
return map[string]any{
|
|
||||||
"enabled": false,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
query := u.Query()
|
|
||||||
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
|
|
||||||
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
|
|
||||||
u.RawQuery = query.Encode()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
res, err := c.HTTP.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode >= 400 {
|
|
||||||
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
payload["enabled"] = true
|
|
||||||
return payload, nil
|
|
||||||
}
|
|
||||||
+640
-224
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,21 +14,14 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/lib/pq"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Constants for validation and limits
|
// Default quota limits applied when the request omits them.
|
||||||
const (
|
const (
|
||||||
MaxServiceNameLength = 100
|
DefaultRPMLimit = 60
|
||||||
MaxRoutePrefixLength = 200
|
DefaultMonthlyQuota = 1000
|
||||||
MaxUpstreamURLLength = 500
|
|
||||||
MaxAPIKeyNameLength = 100
|
|
||||||
MinAPIKeyLength = 20
|
|
||||||
MaxAPIKeyLength = 100
|
|
||||||
DefaultRPMLimit = 60
|
|
||||||
DefaultMonthlyQuota = 1000
|
|
||||||
MaxRPMLimit = 10000
|
|
||||||
MaxMonthlyQuota = 10000000
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validator instance for request validation
|
// Validator instance for request validation
|
||||||
@@ -75,15 +69,6 @@ type APIKeyRequest struct {
|
|||||||
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
|
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateServiceID generates a cryptographically secure service ID
|
|
||||||
func generateServiceID() (string, error) {
|
|
||||||
bytes := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
|
||||||
}
|
|
||||||
return "svc_" + hex.EncodeToString(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateAPIKey generates a cryptographically secure API key
|
// generateAPIKey generates a cryptographically secure API key
|
||||||
func generateAPIKey() (string, error) {
|
func generateAPIKey() (string, error) {
|
||||||
bytes := make([]byte, 32)
|
bytes := make([]byte, 32)
|
||||||
@@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isUniqueViolation reports whether err is a Postgres unique-constraint violation.
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
var pqErr *pq.Error
|
||||||
|
return errors.As(err, &pqErr) && pqErr.Code == "23505"
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAPwhyServiceValidate probes the upstream health path and records the
|
||||||
|
// result on the service row. Ported from the standalone APwhy server.
|
||||||
|
func handleAPwhyServiceValidate(c *gin.Context) {
|
||||||
|
serviceID := c.Param("id")
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
var upstreamURL, healthPath string
|
||||||
|
var authHeader, authValue sql.NullString
|
||||||
|
var timeoutMs sql.NullInt64
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
|
||||||
|
FROM api_services WHERE id = $1
|
||||||
|
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
|
||||||
|
if err != nil {
|
||||||
|
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := 8000
|
||||||
|
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
|
||||||
|
timeout = int(timeoutMs.Int64)
|
||||||
|
}
|
||||||
|
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||||
|
if authHeader.Valid && authHeader.String != "" {
|
||||||
|
req.Header.Set(authHeader.String, authValue.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond}
|
||||||
|
res, probeErr := client.Do(req)
|
||||||
|
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
message := ""
|
||||||
|
ok := false
|
||||||
|
if probeErr != nil {
|
||||||
|
message = probeErr.Error()
|
||||||
|
} else {
|
||||||
|
defer res.Body.Close()
|
||||||
|
status = res.StatusCode
|
||||||
|
message = fmt.Sprintf("HTTP %d", res.StatusCode)
|
||||||
|
ok = res.StatusCode >= 200 && res.StatusCode < 300
|
||||||
|
}
|
||||||
|
|
||||||
|
validationStatus := "failed"
|
||||||
|
if ok {
|
||||||
|
validationStatus = "healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = db.ExecContext(ctx, `
|
||||||
|
UPDATE api_services
|
||||||
|
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
`, validationStatus, message, serviceID)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
_, _ = db.ExecContext(ctx, `
|
||||||
|
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
|
||||||
|
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
|
||||||
|
`, serviceID, message, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
|
||||||
|
"validation": map[string]interface{}{
|
||||||
|
"ok": ok,
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// handleAPwhyServicesList returns a list of API services
|
// handleAPwhyServicesList returns a list of API services
|
||||||
func handleAPwhyServicesList(c *gin.Context) {
|
func handleAPwhyServicesList(c *gin.Context) {
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
@@ -170,7 +233,7 @@ func handleAPwhyServicesList(c *gin.Context) {
|
|||||||
// Query services from database
|
// Query services from database
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
|
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
|
||||||
FROM api_services
|
FROM api_services
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var services []map[string]interface{}
|
services := make([]map[string]interface{}, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
|
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
|
||||||
var enabled bool
|
var enabled int
|
||||||
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
|
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // Skip malformed rows
|
continue // Skip malformed rows
|
||||||
@@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) {
|
|||||||
"slug": slug,
|
"slug": slug,
|
||||||
"upstreamUrl": upstreamURL,
|
"upstreamUrl": upstreamURL,
|
||||||
"routePrefix": routePrefix,
|
"routePrefix": routePrefix,
|
||||||
"enabled": enabled,
|
"enabled": enabled != 0,
|
||||||
"createdAt": createdAt,
|
"createdAt": createdAt,
|
||||||
"updatedAt": updatedAt,
|
"updatedAt": updatedAt,
|
||||||
})
|
})
|
||||||
@@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Generate slug and ID
|
|
||||||
id, err := generateServiceID()
|
|
||||||
if err != nil {
|
|
||||||
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
|
|
||||||
"Failed to generate service ID", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-"))
|
slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-"))
|
||||||
|
|
||||||
// Insert service into database
|
|
||||||
query := `
|
|
||||||
INSERT INTO api_services (
|
|
||||||
id, name, slug, upstream_url, route_prefix, health_path,
|
|
||||||
enabled, rpm_limit, monthly_quota, created_at, updated_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, '/health', true, $6, $7, NOW(), NOW())
|
|
||||||
`
|
|
||||||
|
|
||||||
var rpmLimit, monthlyQuota int
|
var rpmLimit, monthlyQuota int
|
||||||
if req.RPMLimit != nil {
|
if req.RPMLimit != nil {
|
||||||
rpmLimit = *req.RPMLimit
|
rpmLimit = *req.RPMLimit
|
||||||
@@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) {
|
|||||||
monthlyQuota = DefaultMonthlyQuota
|
monthlyQuota = DefaultMonthlyQuota
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL,
|
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||||
req.RoutePrefix, rpmLimit, monthlyQuota)
|
var id string
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO api_services (
|
||||||
|
name, slug, upstream_url, route_prefix, health_path,
|
||||||
|
rpm_limit, monthly_quota, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
sendErrorResponse(c, http.StatusConflict, "CONFLICT",
|
||||||
|
"A service with this slug or route prefix already exists", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
|
||||||
"Failed to create service", err.Error())
|
"Failed to create service", err.Error())
|
||||||
return
|
return
|
||||||
@@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
|
||||||
if input.Enabled != nil {
|
if input.Enabled != nil {
|
||||||
|
enabled := 0
|
||||||
|
if *input.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
}
|
||||||
_, err := db.ExecContext(context.Background(),
|
_, err := db.ExecContext(context.Background(),
|
||||||
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||||
*input.Enabled, serviceID)
|
enabled, serviceID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var keys []map[string]interface{}
|
keys := make([]map[string]interface{}, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id, name, keyPrefix, plan, createdAt, updatedAt string
|
var id, name, keyPrefix, plan, createdAt, updatedAt string
|
||||||
var enabled bool
|
var enabled int
|
||||||
var rpmLimit, monthlyQuota sql.NullInt64
|
var rpmLimit, monthlyQuota sql.NullInt64
|
||||||
|
|
||||||
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
|
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
|
||||||
@@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) {
|
|||||||
"name": name,
|
"name": name,
|
||||||
"keyPrefix": keyPrefix,
|
"keyPrefix": keyPrefix,
|
||||||
"plan": plan,
|
"plan": plan,
|
||||||
"enabled": enabled,
|
"enabled": enabled != 0,
|
||||||
"createdAt": createdAt,
|
"createdAt": createdAt,
|
||||||
"updatedAt": updatedAt,
|
"updatedAt": updatedAt,
|
||||||
}
|
}
|
||||||
@@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate ID and prefix
|
|
||||||
id, err := generateServiceID()
|
|
||||||
if err != nil {
|
|
||||||
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
|
|
||||||
"Failed to generate key ID", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
keyPrefix := apiKey[:8]
|
keyPrefix := apiKey[:8]
|
||||||
|
|
||||||
// Set default limits based on plan
|
// Set default limits based on plan
|
||||||
var rpmLimit, monthlyQuota int
|
plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan)
|
||||||
switch input.Plan {
|
if err != nil {
|
||||||
case "free":
|
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
|
||||||
rpmLimit, monthlyQuota = 60, 1000
|
"Invalid plan", err.Error())
|
||||||
case "pro":
|
return
|
||||||
rpmLimit, monthlyQuota = 600, 50000
|
|
||||||
case "business":
|
|
||||||
rpmLimit, monthlyQuota = 3000, 300000
|
|
||||||
default:
|
|
||||||
rpmLimit, monthlyQuota = 60, 1000
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert key into database
|
// id defaults to gen_random_uuid(); enabled defaults to 1.
|
||||||
_, err = db.ExecContext(context.Background(), `
|
var id string
|
||||||
|
err = db.QueryRowContext(context.Background(), `
|
||||||
INSERT INTO api_keys (
|
INSERT INTO api_keys (
|
||||||
id, name, key_hash, key_prefix, plan, allowed_service_ids,
|
name, key_hash, key_prefix, plan, allowed_service_ids,
|
||||||
enabled, rpm_limit, monthly_quota, created_at, updated_at
|
rpm_limit, monthly_quota, created_at, updated_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW())
|
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
|
||||||
`, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota)
|
RETURNING id
|
||||||
|
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
|
|||||||
"data": gin.H{
|
"data": gin.H{
|
||||||
"id": id,
|
"id": id,
|
||||||
"name": input.Name,
|
"name": input.Name,
|
||||||
"plan": input.Plan,
|
"plan": plan,
|
||||||
"key": apiKey, // Only return the actual key once
|
"key": apiKey, // Only return the actual key once
|
||||||
"keyPrefix": keyPrefix,
|
"keyPrefix": keyPrefix,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
|
||||||
if input.Enabled != nil {
|
if input.Enabled != nil {
|
||||||
|
enabled := 0
|
||||||
|
if *input.Enabled {
|
||||||
|
enabled = 1
|
||||||
|
}
|
||||||
_, err := db.ExecContext(context.Background(),
|
_, err := db.ExecContext(context.Background(),
|
||||||
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
|
||||||
*input.Enabled, keyID)
|
enabled, keyID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
|||||||
@@ -93,10 +93,11 @@ func SetAuditDB(db *database.DB) {
|
|||||||
|
|
||||||
func handleGetAuditLogs(c *gin.Context) {
|
func handleGetAuditLogs(c *gin.Context) {
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
userID := c.MustGet("user_id").(string)
|
|
||||||
|
|
||||||
resource := strings.TrimSpace(c.Query("resource"))
|
resource := strings.TrimSpace(c.Query("resource"))
|
||||||
action := strings.TrimSpace(c.Query("action"))
|
action := strings.TrimSpace(c.Query("action"))
|
||||||
|
actor := strings.TrimSpace(c.Query("actor"))
|
||||||
|
userIDFilter := strings.TrimSpace(c.Query("user_id"))
|
||||||
|
since := strings.TrimSpace(c.Query("since"))
|
||||||
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
||||||
limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50)
|
limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50)
|
||||||
if limit > 500 {
|
if limit > 500 {
|
||||||
@@ -104,35 +105,57 @@ func handleGetAuditLogs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
offset := (page - 1) * limit
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
conditions := []string{"user_id::text = $1"}
|
conditions := []string{"TRUE"}
|
||||||
args := []interface{}{userID}
|
args := []interface{}{}
|
||||||
nextArg := 2
|
nextArg := 1
|
||||||
|
|
||||||
|
if userIDFilter != "" {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("a.user_id = $%d::uuid", nextArg))
|
||||||
|
args = append(args, userIDFilter)
|
||||||
|
nextArg++
|
||||||
|
}
|
||||||
|
if actor != "" {
|
||||||
|
conditions = append(conditions, fmt.Sprintf("u.email ILIKE $%d", nextArg))
|
||||||
|
args = append(args, "%"+actor+"%")
|
||||||
|
nextArg++
|
||||||
|
}
|
||||||
if resource != "" {
|
if resource != "" {
|
||||||
conditions = append(conditions, fmt.Sprintf("resource = $%d", nextArg))
|
conditions = append(conditions, fmt.Sprintf("a.resource = $%d", nextArg))
|
||||||
args = append(args, resource)
|
args = append(args, resource)
|
||||||
nextArg++
|
nextArg++
|
||||||
}
|
}
|
||||||
if action != "" {
|
if action != "" {
|
||||||
conditions = append(conditions, fmt.Sprintf("action = $%d", nextArg))
|
conditions = append(conditions, fmt.Sprintf("a.action = $%d", nextArg))
|
||||||
args = append(args, action)
|
args = append(args, action)
|
||||||
nextArg++
|
nextArg++
|
||||||
}
|
}
|
||||||
|
if since != "" {
|
||||||
|
sinceTime, err := time.Parse(time.RFC3339, since)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since timestamp, expected RFC3339"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conditions = append(conditions, fmt.Sprintf("a.created_at >= $%d", nextArg))
|
||||||
|
args = append(args, sinceTime)
|
||||||
|
nextArg++
|
||||||
|
}
|
||||||
|
|
||||||
whereClause := strings.Join(conditions, " AND ")
|
whereClause := strings.Join(conditions, " AND ")
|
||||||
query := fmt.Sprintf(`SELECT
|
query := fmt.Sprintf(`SELECT
|
||||||
id,
|
a.id,
|
||||||
COALESCE(user_id::text, ''),
|
COALESCE(a.user_id::text, ''),
|
||||||
resource,
|
COALESCE(u.email, ''),
|
||||||
COALESCE(resource_id::text, ''),
|
a.resource,
|
||||||
action,
|
COALESCE(a.resource_id::text, ''),
|
||||||
COALESCE(details::text, '{}'),
|
a.action,
|
||||||
COALESCE(ip_address::text, ''),
|
COALESCE(a.details::text, '{}'),
|
||||||
COALESCE(user_agent, ''),
|
COALESCE(a.ip_address::text, ''),
|
||||||
created_at
|
COALESCE(a.user_agent, ''),
|
||||||
FROM audit_logs
|
a.created_at
|
||||||
|
FROM audit_logs a
|
||||||
|
LEFT JOIN users u ON u.id = a.user_id
|
||||||
WHERE %s
|
WHERE %s
|
||||||
ORDER BY created_at DESC
|
ORDER BY a.created_at DESC
|
||||||
LIMIT $%d OFFSET $%d`, whereClause, nextArg, nextArg+1)
|
LIMIT $%d OFFSET $%d`, whereClause, nextArg, nextArg+1)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
@@ -146,7 +169,7 @@ func handleGetAuditLogs(c *gin.Context) {
|
|||||||
var logs []AuditLog
|
var logs []AuditLog
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var log AuditLog
|
var log AuditLog
|
||||||
err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
|
err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,10 +26,16 @@ type LoginRequest struct {
|
|||||||
|
|
||||||
type RegisterRequest struct {
|
type RegisterRequest struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||||
Name string `json:"name" binding:"required,min=2"`
|
Name string `json:"name" binding:"required,min=2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ManualUserCreateRequest struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||||
|
Name string `json:"name" binding:"required,min=2,max=255"`
|
||||||
|
}
|
||||||
|
|
||||||
type AuthResponse struct {
|
type AuthResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
User interface{} `json:"user"`
|
User interface{} `json:"user"`
|
||||||
@@ -55,17 +69,28 @@ func handleLogin(c *gin.Context) {
|
|||||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
||||||
return
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err = createLocalUserFromBetterAuth(db, authUser)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create local user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
return
|
return
|
||||||
}
|
} else {
|
||||||
|
// Check password
|
||||||
// Check password
|
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
if _, ok := verifyBetterAuthCredentials(c, req.Email, req.Password); !ok {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
@@ -81,6 +106,154 @@ func handleLogin(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleAuthBootstrap(c *gin.Context) {
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
|
||||||
|
count, err := countLocalUsers(db)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to inspect auth bootstrap state"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := "login"
|
||||||
|
if count == 0 {
|
||||||
|
mode = "register"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"has_users": count > 0,
|
||||||
|
"user_count": count,
|
||||||
|
"mode": mode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type betterAuthLoginUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type betterAuthLoginResponse struct {
|
||||||
|
User betterAuthLoginUser `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyBetterAuthCredentials(c *gin.Context, email string, password string) (betterAuthLoginUser, bool) {
|
||||||
|
endpoint, err := betterAuthSignInURL()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Better Auth fallback disabled: %v", err)
|
||||||
|
return betterAuthLoginUser{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(gin.H{
|
||||||
|
"email": strings.TrimSpace(email),
|
||||||
|
"password": password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return betterAuthLoginUser{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return betterAuthLoginUser{}, false
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Better Auth fallback failed: %v", err)
|
||||||
|
return betterAuthLoginUser{}, false
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return betterAuthLoginUser{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload betterAuthLoginResponse
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
|
log.Printf("Better Auth fallback response decode failed: %v", err)
|
||||||
|
return betterAuthLoginUser{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.User.Email = strings.ToLower(strings.TrimSpace(firstNonEmpty(payload.User.Email, email)))
|
||||||
|
payload.User.Name = strings.TrimSpace(payload.User.Name)
|
||||||
|
if payload.User.Name == "" {
|
||||||
|
payload.User.Name = "Containr User"
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.User.Email == "" {
|
||||||
|
return betterAuthLoginUser{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.User, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func betterAuthSignInURL() (string, error) {
|
||||||
|
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
||||||
|
if rawTarget == "" {
|
||||||
|
rawTarget = "http://127.0.0.1:3001"
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := parseAuthProxyTarget(rawTarget)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-in/email"}).String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func betterAuthSignUpURL() (string, error) {
|
||||||
|
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
||||||
|
if rawTarget == "" {
|
||||||
|
rawTarget = "http://127.0.0.1:3001"
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := parseAuthProxyTarget(rawTarget)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-up/email"}).String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser) (User, error) {
|
||||||
|
email := strings.ToLower(strings.TrimSpace(authUser.Email))
|
||||||
|
if email == "" {
|
||||||
|
return User{}, errors.New("email is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(authUser.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "Containr User"
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(uuid.NewString()), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
err = db.QueryRow(`
|
||||||
|
INSERT INTO users (email, password_hash, name, avatar_url)
|
||||||
|
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||||
|
ON CONFLICT (email) DO UPDATE
|
||||||
|
SET name = EXCLUDED.name,
|
||||||
|
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||||
|
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)).
|
||||||
|
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleRegister(c *gin.Context) {
|
func handleRegister(c *gin.Context) {
|
||||||
var req RegisterRequest
|
var req RegisterRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -91,9 +264,18 @@ func handleRegister(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||||
|
|
||||||
// Check if user already exists
|
count, err := countLocalUsers(db)
|
||||||
var count int
|
if err != nil {
|
||||||
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&count)
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
return
|
return
|
||||||
@@ -137,6 +319,140 @@ func handleRegister(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleCreateUser(c *gin.Context) {
|
||||||
|
var req ManualUserCreateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Email == "" || req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Email and name are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing int
|
||||||
|
if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing > 0 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createBetterAuthUser(c, req.Name, req.Email, req.Password); err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if errors.Is(err, errBetterAuthUserExists) {
|
||||||
|
status = http.StatusConflict
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
err = db.QueryRow(`
|
||||||
|
INSERT INTO users (email, password_hash, name)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||||
|
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"user": user})
|
||||||
|
}
|
||||||
|
|
||||||
|
var errBetterAuthUserExists = errors.New("User already exists")
|
||||||
|
|
||||||
|
func createBetterAuthUser(c *gin.Context, name string, email string, password string) error {
|
||||||
|
endpoint, err := betterAuthSignUpURL()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(gin.H{
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
|
||||||
|
request.Header.Set("X-Containr-Auth-Internal", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := (&http.Client{Timeout: 5 * time.Second}).Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
_ = json.NewDecoder(response.Body).Decode(&payload)
|
||||||
|
message := "Failed to create auth user"
|
||||||
|
if rawMessage, ok := payload["message"].(string); ok && strings.TrimSpace(rawMessage) != "" {
|
||||||
|
message = rawMessage
|
||||||
|
} else if rawError, ok := payload["error"].(string); ok && strings.TrimSpace(rawError) != "" {
|
||||||
|
message = rawError
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.StatusCode == http.StatusConflict || strings.Contains(strings.ToLower(message), "already") {
|
||||||
|
return errBetterAuthUserExists
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countLocalUsers(db *database.DB) (int, error) {
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Better Auth writes to auth_users; a row there only mirrors into users on
|
||||||
|
// the first authenticated call. Count it too or a second public sign-up
|
||||||
|
// slips through the bootstrap window. UNION by email avoids double-counting
|
||||||
|
// mirrored users.
|
||||||
|
var authTable *string
|
||||||
|
if err := db.QueryRow("SELECT to_regclass('auth_users')").Scan(&authTable); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if authTable != nil && *authTable != "" {
|
||||||
|
if err := db.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT email FROM users
|
||||||
|
UNION
|
||||||
|
SELECT email FROM auth_users
|
||||||
|
) u
|
||||||
|
`).Scan(&count); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleGetProfile(c *gin.Context) {
|
func handleGetProfile(c *gin.Context) {
|
||||||
userID := c.MustGet("user_id").(string)
|
userID := c.MustGet("user_id").(string)
|
||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"containr/internal/database"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config, db *database.DB) {
|
||||||
targetURL, err := parseAuthProxyTarget(cfg.BetterAuthProxyURL)
|
targetURL, err := parseAuthProxyTarget(cfg.BetterAuthProxyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: auth proxy disabled: %v", err)
|
log.Printf("Warning: auth proxy disabled: %v", err)
|
||||||
@@ -28,6 +29,12 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handler := func(c *gin.Context) {
|
handler := func(c *gin.Context) {
|
||||||
|
if shouldBlockPublicBetterAuthSignup(c, cfg, db) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
proxy.ServeHTTP(c.Writer, c.Request)
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
}
|
}
|
||||||
@@ -36,6 +43,33 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
|||||||
router.Any("/api/auth/*proxyPath", handler)
|
router.Any("/api/auth/*proxyPath", handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *database.DB) bool {
|
||||||
|
if db == nil || db.DB == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.EqualFold(c.Request.Method, http.MethodPost) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(c.Request.URL.Path, "/sign-up/email") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
internalToken := strings.TrimSpace(cfg.BetterAuthInternalToken)
|
||||||
|
if internalToken != "" && c.GetHeader("X-Containr-Auth-Internal") == internalToken {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := countLocalUsers(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to inspect signup bootstrap state: %v", err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
||||||
trimmed := strings.TrimSpace(rawTarget)
|
trimmed := strings.TrimSpace(rawTarget)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"encoding/json"
|
"containr/internal/database/sqlcdb"
|
||||||
|
"containr/internal/docker"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CronJob struct {
|
type CronJob struct {
|
||||||
@@ -62,6 +69,7 @@ func handleGetCronJobs(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
userID := c.MustGet("user_id").(string)
|
userID := c.MustGet("user_id").(string)
|
||||||
projectID := c.Query("project_id")
|
projectID := c.Query("project_id")
|
||||||
|
serviceID := c.Query("service_id")
|
||||||
|
|
||||||
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
|
||||||
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
|
||||||
@@ -72,8 +80,12 @@ func handleGetCronJobs(c *gin.Context) {
|
|||||||
args := []interface{}{userID}
|
args := []interface{}{userID}
|
||||||
|
|
||||||
if projectID != "" {
|
if projectID != "" {
|
||||||
query += " AND cj.project_id = $2"
|
|
||||||
args = append(args, projectID)
|
args = append(args, projectID)
|
||||||
|
query += fmt.Sprintf(" AND cj.project_id = $%d", len(args))
|
||||||
|
}
|
||||||
|
if serviceID != "" {
|
||||||
|
args = append(args, serviceID)
|
||||||
|
query += fmt.Sprintf(" AND cj.service_id = $%d", len(args))
|
||||||
}
|
}
|
||||||
|
|
||||||
query += " ORDER BY cj.created_at DESC"
|
query += " ORDER BY cj.created_at DESC"
|
||||||
@@ -130,7 +142,11 @@ func handleCreateCronJob(c *gin.Context) {
|
|||||||
req.Retention = 30
|
req.Retention = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
nextRun := calculateNextRun(req.Schedule, req.Timezone)
|
nextRun, err := calculateNextRun(req.Schedule, req.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid schedule: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
job := CronJob{
|
job := CronJob{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
@@ -227,8 +243,13 @@ func handleUpdateCronJob(c *gin.Context) {
|
|||||||
updates["name"] = req.Name
|
updates["name"] = req.Name
|
||||||
}
|
}
|
||||||
if req.Schedule != "" {
|
if req.Schedule != "" {
|
||||||
|
next, err := calculateNextRun(req.Schedule, req.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid schedule: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
updates["schedule"] = req.Schedule
|
updates["schedule"] = req.Schedule
|
||||||
updates["next_run_at"] = calculateNextRun(req.Schedule, "UTC")
|
updates["next_run_at"] = next
|
||||||
}
|
}
|
||||||
if req.Command != "" {
|
if req.Command != "" {
|
||||||
updates["command"] = req.Command
|
updates["command"] = req.Command
|
||||||
@@ -352,11 +373,12 @@ func handleTriggerCronJob(c *gin.Context) {
|
|||||||
var job CronJob
|
var job CronJob
|
||||||
var ownerCheck string
|
var ownerCheck string
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
`SELECT cj.command, p.owner_id FROM cron_jobs cj
|
`SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id
|
||||||
|
FROM cron_jobs cj
|
||||||
JOIN projects p ON cj.project_id = p.id
|
JOIN projects p ON cj.project_id = p.id
|
||||||
WHERE cj.id = $1`,
|
WHERE cj.id = $1`,
|
||||||
jobID,
|
jobID,
|
||||||
).Scan(&job.Command, &ownerCheck)
|
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
|
||||||
|
|
||||||
if err != nil || ownerCheck != userID {
|
if err != nil || ownerCheck != userID {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
@@ -372,7 +394,9 @@ func handleTriggerCronJob(c *gin.Context) {
|
|||||||
execID, jobID, now, "running",
|
execID, jobID, now, "running",
|
||||||
)
|
)
|
||||||
|
|
||||||
go executeCronJob(jobID, execID, job.Command)
|
dockerClient, _ := c.Get("docker_client")
|
||||||
|
dockerCli, _ := dockerClient.(*docker.Client)
|
||||||
|
go executeCronJob(db, dockerCli, jobID, job.ServiceID, execID, job.Command, job.Schedule, job.Timezone, job.Retention)
|
||||||
|
|
||||||
LogAudit(userID, "cron_job", jobID, "trigger", map[string]interface{}{
|
LogAudit(userID, "cron_job", jobID, "trigger", map[string]interface{}{
|
||||||
"execution_id": execID,
|
"execution_id": execID,
|
||||||
@@ -384,33 +408,186 @@ func handleTriggerCronJob(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func calculateNextRun(schedule, timezone string) *time.Time {
|
// cronScheduleParser parses standard 5-field cron expressions plus
|
||||||
now := time.Now()
|
// descriptors (@daily etc.) and CRON_TZ= prefixes.
|
||||||
next := now.Add(1 * time.Hour)
|
var cronScheduleParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||||
return &next
|
|
||||||
|
func calculateNextRun(schedule, timezone string) (*time.Time, error) {
|
||||||
|
expr := strings.TrimSpace(schedule)
|
||||||
|
if tz := strings.TrimSpace(timezone); tz != "" && tz != "UTC" {
|
||||||
|
expr = "CRON_TZ=" + tz + " " + expr
|
||||||
|
}
|
||||||
|
parsed, err := cronScheduleParser.Parse(expr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
next := parsed.Next(time.Now())
|
||||||
|
return &next, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeCronJob(jobID, execID, command string) {
|
// StartCronScheduler ticks every minute and executes enabled cron jobs whose
|
||||||
db := auditDB
|
// next_run_at is due. Each run is recorded in cron_executions and output is
|
||||||
if db == nil {
|
// captured from docker exec inside the service's container.
|
||||||
|
func StartCronScheduler(ctx context.Context, db *database.DB, dockerClient *docker.Client) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runDueCronJobs(db, dockerClient)
|
||||||
|
runDueDatabaseBackups(db, dockerClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDueCronJobs(db *database.DB, dockerClient *docker.Client) {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention
|
||||||
|
FROM cron_jobs
|
||||||
|
WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW()`)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cron scheduler: failed to list due jobs: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
type dueJob struct {
|
||||||
|
CronJob
|
||||||
|
Command string
|
||||||
|
}
|
||||||
|
var jobs []dueJob
|
||||||
|
for rows.Next() {
|
||||||
|
var j dueJob
|
||||||
|
if err := rows.Scan(&j.ID, &j.ProjectID, &j.ServiceID, &j.Name, &j.Schedule,
|
||||||
|
&j.Command, &j.Timezone, &j.Enabled, &j.Retention); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, j := range jobs {
|
||||||
|
execID := uuid.New().String()
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO cron_executions (id, cron_job_id, started_at, status) VALUES ($1, $2, $3, $4)`,
|
||||||
|
execID, j.ID, time.Now(), "running"); err != nil {
|
||||||
|
log.Printf("cron scheduler: failed to record execution for %s: %v", j.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go executeCronJob(db, dockerClient, j.ID, j.ServiceID, execID, j.Command, j.Schedule, j.Timezone, j.Retention)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDueDatabaseBackups snapshots managed databases whose backup_schedule is
|
||||||
|
// due, reusing the same archive pipeline as manual backups.
|
||||||
|
func runDueDatabaseBackups(db *database.DB, dockerClient *docker.Client) {
|
||||||
|
if dockerClient == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := sqlcdb.New(db.DB)
|
||||||
|
due, err := q.ListDueDatabaseBackups(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cron scheduler: failed to list due database backups: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler := NewDatabaseHandler(db.DB, dockerClient)
|
||||||
|
for _, row := range due {
|
||||||
|
schedule := row.BackupSchedule.String
|
||||||
|
next, err := calculateNextRun(schedule, "UTC")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cron scheduler: invalid backup schedule %q on %s: %v", schedule, row.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := q.SetDatabaseNextBackupAt(context.Background(), sqlcdb.SetDatabaseNextBackupAtParams{
|
||||||
|
NextBackupAt: sql.NullTime{Time: *next, Valid: true},
|
||||||
|
ID: row.ID,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("cron scheduler: failed to advance backup schedule on %s: %v", row.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
backupID := generateDatabaseBackupID(row.ID)
|
||||||
|
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
|
||||||
|
if err := q.CreateDatabaseBackup(context.Background(), sqlcdb.CreateDatabaseBackupParams{
|
||||||
|
ID: backupID,
|
||||||
|
DatabaseID: row.ID,
|
||||||
|
Size: "pending",
|
||||||
|
Status: "in_progress",
|
||||||
|
BackupPath: sql.NullString{String: archivePath, Valid: true},
|
||||||
|
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("cron scheduler: failed to record backup for %s: %v", row.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go handler.createBackupProcess(row.ID, backupID, archivePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findServiceContainer returns the first running container whose name matches
|
||||||
|
// the deployment naming convention containr-<serviceID>-*.
|
||||||
|
func findServiceContainer(ctx context.Context, dockerClient *docker.Client, serviceID string) (string, error) {
|
||||||
|
containers, err := dockerClient.ListContainers(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
prefix := "containr-" + serviceID + "-"
|
||||||
|
for _, ctr := range containers {
|
||||||
|
for _, name := range ctr.Names {
|
||||||
|
if strings.HasPrefix(strings.TrimPrefix(name, "/"), prefix) {
|
||||||
|
return ctr.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no running container for service %s", serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeCronJob(db *database.DB, dockerClient *docker.Client, jobID, serviceID, execID, command, schedule, timezone string, retention int) {
|
||||||
|
status := "success"
|
||||||
|
var output, errText string
|
||||||
|
|
||||||
|
if dockerClient == nil {
|
||||||
|
status, errText = "failed", "docker unavailable"
|
||||||
|
} else {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
containerID, findErr := findServiceContainer(ctx, dockerClient, serviceID)
|
||||||
|
if findErr != nil {
|
||||||
|
status, errText = "failed", findErr.Error()
|
||||||
|
} else {
|
||||||
|
out, exitCode, runErr := dockerClient.ExecRun(ctx, containerID, []string{"sh", "-c", command})
|
||||||
|
output = out
|
||||||
|
if len(output) > 64*1024 {
|
||||||
|
output = output[:64*1024]
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
status, errText = "failed", runErr.Error()
|
||||||
|
} else if exitCode != 0 {
|
||||||
|
status, errText = "failed", fmt.Sprintf("exit code %d", exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
db.Exec(
|
db.Exec(
|
||||||
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3 WHERE id = $4`,
|
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5`,
|
||||||
now, "success", "Job completed successfully", execID,
|
now, status, output, errText, execID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
next, _ := calculateNextRun(schedule, timezone)
|
||||||
db.Exec(
|
db.Exec(
|
||||||
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, next_run_at = $3 WHERE id = $4`,
|
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5`,
|
||||||
now, "success", time.Now().Add(1*time.Hour), jobID,
|
now, status, output, next, jobID,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
if retention > 0 {
|
||||||
cronJobsData, _ := json.Marshal([]CronJob{})
|
db.Exec(
|
||||||
_ = cronJobsData
|
`DELETE FROM cron_executions WHERE cron_job_id = $1 AND id NOT IN (
|
||||||
|
SELECT id FROM cron_executions WHERE cron_job_id = $1
|
||||||
|
ORDER BY started_at DESC LIMIT $2)`,
|
||||||
|
jobID, retention,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"containr/internal/database/sqlcdb"
|
"containr/internal/database/sqlcdb"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
"context"
|
"context"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -38,19 +40,21 @@ const (
|
|||||||
|
|
||||||
// DatabaseService represents a managed database service
|
// DatabaseService represents a managed database service
|
||||||
type DatabaseService struct {
|
type DatabaseService struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
Name string `json:"name" db:"name"`
|
Name string `json:"name" db:"name"`
|
||||||
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
|
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
|
||||||
Status string `json:"status" db:"status"` // running, stopped, building, error
|
Status string `json:"status" db:"status"` // running, stopped, building, error
|
||||||
Version string `json:"version" db:"version"`
|
Version string `json:"version" db:"version"`
|
||||||
Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business
|
Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business
|
||||||
Region string `json:"region" db:"region"`
|
Region string `json:"region" db:"region"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
BackupSchedule string `json:"backup_schedule,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
||||||
ConnectionURL string `json:"connection_url"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
Metrics DatabaseMetrics `json:"metrics"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
Backups DatabaseBackupConfig `json:"backups"`
|
ConnectionURL string `json:"connection_url"`
|
||||||
Settings DatabaseSettings `json:"settings"`
|
Metrics DatabaseMetrics `json:"metrics"`
|
||||||
|
Backups DatabaseBackupConfig `json:"backups"`
|
||||||
|
Settings DatabaseSettings `json:"settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseMetrics represents database performance metrics
|
// DatabaseMetrics represents database performance metrics
|
||||||
@@ -100,8 +104,9 @@ type DatabaseCreateRequest struct {
|
|||||||
|
|
||||||
// DatabaseUpdateRequest represents a request to update a database
|
// DatabaseUpdateRequest represents a request to update a database
|
||||||
type DatabaseUpdateRequest struct {
|
type DatabaseUpdateRequest struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Plan string `json:"plan,omitempty"`
|
Plan string `json:"plan,omitempty"`
|
||||||
|
BackupSchedule *string `json:"backup_schedule,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseActionRequest represents a request to perform database actions
|
// DatabaseActionRequest represents a request to perform database actions
|
||||||
@@ -181,7 +186,7 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
|
|||||||
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
||||||
|
|
||||||
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
|
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
|
||||||
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db.ID); err == nil {
|
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
|
||||||
db.Backups = backupConfig
|
db.Backups = backupConfig
|
||||||
} else {
|
} else {
|
||||||
db.Backups = h.generateMockBackupConfig()
|
db.Backups = h.generateMockBackupConfig()
|
||||||
@@ -218,7 +223,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
|
|||||||
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
||||||
|
|
||||||
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
|
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
|
||||||
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db.ID); err == nil {
|
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
|
||||||
db.Backups = backupConfig
|
db.Backups = backupConfig
|
||||||
} else {
|
} else {
|
||||||
db.Backups = h.generateMockBackupConfig()
|
db.Backups = h.generateMockBackupConfig()
|
||||||
@@ -362,40 +367,67 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
|||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
req.Plan = strings.TrimSpace(req.Plan)
|
req.Plan = strings.TrimSpace(req.Plan)
|
||||||
|
|
||||||
if req.Name == "" && req.Plan == "" {
|
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
now := sql.NullTime{Time: time.Now(), Valid: true}
|
if req.BackupSchedule != nil {
|
||||||
var err error
|
var schedule sql.NullString
|
||||||
switch {
|
var nextBackup sql.NullTime
|
||||||
case req.Name != "" && req.Plan != "":
|
if expr := strings.TrimSpace(*req.BackupSchedule); expr != "" {
|
||||||
err = h.queries.UpdateDatabaseServiceNameAndPlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameAndPlanByIDAndUserParams{
|
next, err := calculateNextRun(expr, "UTC")
|
||||||
Name: req.Name,
|
if err != nil {
|
||||||
Plan: req.Plan,
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cron schedule"})
|
||||||
UpdatedAt: now,
|
return
|
||||||
ID: databaseID,
|
}
|
||||||
UserID: userID,
|
schedule = sql.NullString{String: expr, Valid: true}
|
||||||
})
|
nextBackup = sql.NullTime{Time: *next, Valid: true}
|
||||||
case req.Name != "":
|
}
|
||||||
err = h.queries.UpdateDatabaseServiceNameByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameByIDAndUserParams{
|
err := h.queries.SetDatabaseBackupScheduleByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupScheduleByIDAndUserParams{
|
||||||
Name: req.Name,
|
BackupSchedule: schedule,
|
||||||
UpdatedAt: now,
|
NextBackupAt: nextBackup,
|
||||||
ID: databaseID,
|
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
UserID: userID,
|
ID: databaseID,
|
||||||
})
|
UserID: userID,
|
||||||
default:
|
|
||||||
err = h.queries.UpdateDatabaseServicePlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServicePlanByIDAndUserParams{
|
|
||||||
Plan: req.Plan,
|
|
||||||
UpdatedAt: now,
|
|
||||||
ID: databaseID,
|
|
||||||
UserID: userID,
|
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup schedule"})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"})
|
if req.Name != "" || req.Plan != "" {
|
||||||
return
|
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case req.Name != "" && req.Plan != "":
|
||||||
|
err = h.queries.UpdateDatabaseServiceNameAndPlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameAndPlanByIDAndUserParams{
|
||||||
|
Name: req.Name,
|
||||||
|
Plan: req.Plan,
|
||||||
|
UpdatedAt: now,
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
case req.Name != "":
|
||||||
|
err = h.queries.UpdateDatabaseServiceNameByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameByIDAndUserParams{
|
||||||
|
Name: req.Name,
|
||||||
|
UpdatedAt: now,
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
err = h.queries.UpdateDatabaseServicePlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServicePlanByIDAndUserParams{
|
||||||
|
Plan: req.Plan,
|
||||||
|
UpdatedAt: now,
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Database updated successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "Database updated successfully"})
|
||||||
@@ -643,6 +675,87 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadBackup streams a completed backup archive out of the backups volume.
|
||||||
|
// Archives live inside the containr-db-backups docker volume, so a one-shot
|
||||||
|
// utility container cats the file and its stdout is demuxed to the response.
|
||||||
|
func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
databaseID := c.Param("id")
|
||||||
|
backupID := c.Param("bid")
|
||||||
|
|
||||||
|
backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
|
||||||
|
ID: backupID,
|
||||||
|
DatabaseID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check backup"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if backup.Status != "completed" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "Backup is not completed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.dockerClient == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
archive := managedDatabaseBackupArchivePath(backupID)
|
||||||
|
if backup.BackupPath.Valid && strings.TrimSpace(backup.BackupPath.String) != "" {
|
||||||
|
archive = sanitizeBackupArchivePath(backup.BackupPath.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container logs cannot carry binary data (the daemon sanitizes non-UTF-8),
|
||||||
|
// so the archive is copied out of a stopped utility container instead.
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
containerID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{
|
||||||
|
Name: h.generateUtilityContainerName("backup-dl", "shared"),
|
||||||
|
Image: managedDatabaseBackupImage,
|
||||||
|
Cmd: []string{"true"},
|
||||||
|
RestartPolicy: "no",
|
||||||
|
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup", ReadOnly: true}},
|
||||||
|
Labels: map[string]string{
|
||||||
|
"containr.managed": "true",
|
||||||
|
"containr.utility": "backup-dl",
|
||||||
|
},
|
||||||
|
NetworkMode: "none",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read backup archive"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), containerID, true) }()
|
||||||
|
|
||||||
|
tarStream, err := h.dockerClient.CopyFromContainer(ctx, containerID, "/backup/"+archive)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Backup archive not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tarStream.Close()
|
||||||
|
|
||||||
|
tarReader := tar.NewReader(tarStream)
|
||||||
|
if _, err := tarReader.Next(); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Backup archive not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "application/gzip")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", archive))
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
if _, err := io.Copy(c.Writer, tarReader); err != nil {
|
||||||
|
log.Printf("backup download %s: stream error: %v", backupID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
|
func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics {
|
||||||
if h.dockerClient == nil {
|
if h.dockerClient == nil {
|
||||||
return h.generateMockMetrics()
|
return h.generateMockMetrics()
|
||||||
@@ -756,9 +869,9 @@ func roundFloat(value float64, places int) float64 {
|
|||||||
return math.Round(value*pow) / pow
|
return math.Round(value*pow) / pow
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID, databaseID string) (DatabaseBackupConfig, error) {
|
func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string, db DatabaseService) (DatabaseBackupConfig, error) {
|
||||||
rows, err := h.queries.ListDatabaseBackupsByDatabaseAndUser(ctx, sqlcdb.ListDatabaseBackupsByDatabaseAndUserParams{
|
rows, err := h.queries.ListDatabaseBackupsByDatabaseAndUser(ctx, sqlcdb.ListDatabaseBackupsByDatabaseAndUserParams{
|
||||||
DatabaseID: databaseID,
|
DatabaseID: db.ID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Limit: 20,
|
Limit: 20,
|
||||||
})
|
})
|
||||||
@@ -782,17 +895,11 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID, datab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextBackup *time.Time
|
|
||||||
if lastBackup != nil {
|
|
||||||
t := lastBackup.Add(24 * time.Hour)
|
|
||||||
nextBackup = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
return DatabaseBackupConfig{
|
return DatabaseBackupConfig{
|
||||||
Enabled: true,
|
Enabled: db.BackupSchedule != "",
|
||||||
LastBackup: lastBackup,
|
LastBackup: lastBackup,
|
||||||
Retention: 30,
|
Retention: 30,
|
||||||
NextBackup: nextBackup,
|
NextBackup: db.NextBackupAt,
|
||||||
Backups: backups,
|
Backups: backups,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -933,31 +1040,35 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
|
|||||||
|
|
||||||
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
|
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
|
||||||
return DatabaseService{
|
return DatabaseService{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Type: row.Type,
|
Type: row.Type,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
Version: row.Version,
|
Version: row.Version,
|
||||||
Plan: row.Plan,
|
Plan: row.Plan,
|
||||||
Region: row.Region,
|
Region: row.Region,
|
||||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
BackupSchedule: databaseNullString(row.BackupSchedule),
|
||||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||||
|
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||||
|
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
|
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
|
||||||
return DatabaseService{
|
return DatabaseService{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Type: row.Type,
|
Type: row.Type,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
Version: row.Version,
|
Version: row.Version,
|
||||||
Plan: row.Plan,
|
Plan: row.Plan,
|
||||||
Region: row.Region,
|
Region: row.Region,
|
||||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
BackupSchedule: databaseNullString(row.BackupSchedule),
|
||||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||||
|
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||||
|
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,6 +1079,14 @@ func databaseNullTime(value sql.NullTime) time.Time {
|
|||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func databaseNullTimePtr(value sql.NullTime) *time.Time {
|
||||||
|
if value.Valid {
|
||||||
|
t := value.Time
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func databaseNullString(value sql.NullString) string {
|
func databaseNullString(value sql.NullString) string {
|
||||||
if value.Valid {
|
if value.Valid {
|
||||||
return value.String
|
return value.String
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -198,10 +199,10 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO deployments
|
`INSERT INTO deployments
|
||||||
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -368,12 +369,19 @@ func runDeploymentAndSync(
|
|||||||
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
||||||
time.Now(), service.ID,
|
time.Now(), service.ID,
|
||||||
)
|
)
|
||||||
|
insertUserNotification(db, userID, "deployment", "Deployment succeeded",
|
||||||
|
fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String())
|
||||||
return
|
return
|
||||||
case "failed":
|
case "failed":
|
||||||
_, _ = db.Exec(
|
_, _ = db.Exec(
|
||||||
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||||
time.Now(), service.ID,
|
time.Now(), service.ID,
|
||||||
)
|
)
|
||||||
|
body := fmt.Sprintf("Service %s failed to deploy.", service.Name)
|
||||||
|
if current.Error != "" {
|
||||||
|
body = fmt.Sprintf("Service %s failed to deploy: %s", service.Name, current.Error)
|
||||||
|
}
|
||||||
|
insertUserNotification(db, userID, "deployment", "Deployment failed", body, "service", service.ID.String())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,10 +533,10 @@ func handleRollbackDeployment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO deployments
|
`INSERT INTO deployments
|
||||||
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status,
|
rollback.ID, rollback.ServiceID, fmt.Sprintf("rollback-%d", now.Unix()), rollback.CommitHash, rollback.Status,
|
||||||
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
|
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/docker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleExecInService runs a one-off shell command inside the service's
|
||||||
|
// running container and returns captured output. Intended for debugging;
|
||||||
|
// commands run as the container's default user with a 30s ceiling.
|
||||||
|
func handleExecInService(c *gin.Context) {
|
||||||
|
serviceID := c.Param("id")
|
||||||
|
userID := c.MustGet("user_id").(string)
|
||||||
|
db := c.MustGet("db").(*database.DB)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Command string `json:"command" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
command := strings.TrimSpace(req.Command)
|
||||||
|
if command == "" || len(command) > 4096 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "command must be 1-4096 chars"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownerID string
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT p.owner_id FROM services s
|
||||||
|
JOIN projects p ON p.id = s.project_id
|
||||||
|
WHERE s.id = $1`,
|
||||||
|
serviceID,
|
||||||
|
).Scan(&ownerID)
|
||||||
|
if err != nil || ownerID != userID {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerClient, _ := c.Get("docker_client")
|
||||||
|
dockerCli, _ := dockerClient.(*docker.Client)
|
||||||
|
if dockerCli == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
containerID, err := findServiceContainer(ctx, dockerCli, serviceID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "No running container for this service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
output, exitCode, runErr := dockerCli.ExecRun(ctx, containerID, []string{"sh", "-c", command})
|
||||||
|
if len(output) > 64*1024 {
|
||||||
|
output = output[:64*1024]
|
||||||
|
}
|
||||||
|
|
||||||
|
errText := ""
|
||||||
|
if runErr != nil {
|
||||||
|
errText = runErr.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"output": output,
|
||||||
|
"exit_code": exitCode,
|
||||||
|
"error": errText,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1306,3 +1306,208 @@ func generateWebhookSecret() (string, error) {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(buf), nil
|
return hex.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteGitProvider removes a provider connection. Repositories and
|
||||||
|
// webhooks are removed by ON DELETE CASCADE.
|
||||||
|
func handleDeleteGitProvider(c *gin.Context) {
|
||||||
|
ctx, ok := requireGitRequestContext(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
providerID := strings.TrimSpace(c.Param("providerId"))
|
||||||
|
if _, err := uuid.Parse(providerID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ctx.db.Exec(
|
||||||
|
"DELETE FROM git_providers WHERE id = $1 AND user_id = $2",
|
||||||
|
providerID, ctx.userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Provider disconnected"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetGitRepositoryBranches lists branches for a repository through the
|
||||||
|
// provider's stored credentials.
|
||||||
|
func handleGetGitRepositoryBranches(c *gin.Context) {
|
||||||
|
ctx, ok := requireGitRequestContext(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
providerID := strings.TrimSpace(c.Param("providerId"))
|
||||||
|
if _, err := uuid.Parse(providerID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := strings.TrimSpace(c.Param("owner"))
|
||||||
|
repo := strings.TrimSpace(c.Param("repo"))
|
||||||
|
if owner == "" || repo == "" || strings.ContainsAny(owner+repo, " \t\r\n") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository reference"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerName, providerAPIURL, providerAccessToken string
|
||||||
|
err := ctx.db.QueryRow(`
|
||||||
|
SELECT name, api_url, access_token
|
||||||
|
FROM git_providers
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`, providerID, ctx.userID).Scan(&providerName, &providerAPIURL, &providerAccessToken)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("branch fetch failed for %s/%s via %s: %v", owner, repo, providerName, err)
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch branches from provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"branches": branches})
|
||||||
|
}
|
||||||
|
|
||||||
|
type remoteBranch struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Protected bool `json:"protected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchRepositoryBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(providerName)) {
|
||||||
|
case "github", "github_app":
|
||||||
|
return fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo)
|
||||||
|
case "gitea":
|
||||||
|
return fetchGiteaBranches(providerAPIURL, accessToken, owner, repo)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("branch listing is not supported for provider %s", providerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||||
|
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.github.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(accessToken)
|
||||||
|
if strings.EqualFold(providerName, "github_app") {
|
||||||
|
installationID, err := strconv.ParseInt(token, 10, 64)
|
||||||
|
if err != nil || installationID <= 0 {
|
||||||
|
return nil, fmt.Errorf("github_app installation id is invalid")
|
||||||
|
}
|
||||||
|
token, err = getGitHubAppInstallationToken(baseURL, installationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("github access token is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
branches := make([]remoteBranch, 0)
|
||||||
|
for page := 1; page <= 5; page++ {
|
||||||
|
request, err := http.NewRequest(http.MethodGet,
|
||||||
|
fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d", baseURL, owner, repo, page), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||||
|
response.Body.Close()
|
||||||
|
return nil, fmt.Errorf("github branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
decodeErr := json.NewDecoder(response.Body).Decode(&rows)
|
||||||
|
response.Body.Close()
|
||||||
|
if decodeErr != nil {
|
||||||
|
return nil, decodeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
name := asGitString(row["name"])
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) < 100 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return branches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchGiteaBranches(providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||||
|
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "")), "/")
|
||||||
|
}
|
||||||
|
if baseURL == "" {
|
||||||
|
return nil, fmt.Errorf("gitea api_url is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
request, err := http.NewRequest(http.MethodGet,
|
||||||
|
fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?limit=100", baseURL, owner, repo), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
if token := strings.TrimSpace(accessToken); token != "" {
|
||||||
|
request.Header.Set("Authorization", "token "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("gitea branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
branches := make([]remoteBranch, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if name := asGitString(row["name"]); name != "" {
|
||||||
|
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return branches, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/deployment"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGitWebhookPush is the public push receiver. Providers call
|
||||||
|
// POST /api/git/webhooks/:id with a signed payload; the HMAC against
|
||||||
|
// git_webhooks.webhook_secret replaces session auth.
|
||||||
|
func handleGitWebhookPush(c *gin.Context) {
|
||||||
|
dbValue, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := dbValue.(*database.DB)
|
||||||
|
|
||||||
|
webhookID := strings.TrimSpace(c.Param("id"))
|
||||||
|
if _, err := uuid.Parse(webhookID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
secret string
|
||||||
|
repoID string
|
||||||
|
providerID string
|
||||||
|
branchFilter string
|
||||||
|
active bool
|
||||||
|
)
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, ''), active
|
||||||
|
FROM git_webhooks WHERE id = $1`, webhookID).
|
||||||
|
Scan(&secret, &repoID, &providerID, &branchFilter, &active)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Webhook is disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerName string
|
||||||
|
_ = db.QueryRow(`SELECT name FROM git_providers WHERE id = $1`, providerID).Scan(&providerName)
|
||||||
|
|
||||||
|
if !verifyGitWebhookSignature(providerName, secret, c.Request, body) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
After string `json:"after"`
|
||||||
|
Repository struct {
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
PathWithNamespace string `json:"path_with_namespace"`
|
||||||
|
} `json:"repository"`
|
||||||
|
Project struct {
|
||||||
|
PathWithNamespace string `json:"path_with_namespace"`
|
||||||
|
} `json:"project"`
|
||||||
|
HeadCommit struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"head_commit"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(payload.Ref, "refs/heads/") {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
branch := strings.TrimPrefix(payload.Ref, "refs/heads/")
|
||||||
|
if branchFilter != "" && branchFilter != branch {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commit := payload.HeadCommit.ID
|
||||||
|
if commit == "" {
|
||||||
|
commit = payload.After
|
||||||
|
}
|
||||||
|
|
||||||
|
var cloneURL, fullName, repoUserID string
|
||||||
|
err = db.QueryRow(
|
||||||
|
`SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1`, repoID,
|
||||||
|
).Scan(&cloneURL, &fullName, &repoUserID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||||
|
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||||
|
s.created_at, s.updated_at
|
||||||
|
FROM services s
|
||||||
|
WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`,
|
||||||
|
branch, cloneURL, fullName)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var services []Service
|
||||||
|
for rows.Next() {
|
||||||
|
var s Service
|
||||||
|
if err := rows.Scan(
|
||||||
|
&s.ID, &s.ProjectID, &s.Name, &s.Type, &s.Status, &s.Image, &s.Command,
|
||||||
|
&s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory,
|
||||||
|
&s.CreatedAt, &s.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
services = append(services, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
engineValue, _ := c.Get("deployment_engine")
|
||||||
|
engine, _ := engineValue.(*deployment.DeploymentEngine)
|
||||||
|
|
||||||
|
enqueued := 0
|
||||||
|
for _, service := range services {
|
||||||
|
now := time.Now()
|
||||||
|
var commitHash *string
|
||||||
|
if commit != "" {
|
||||||
|
commitHash = &commit
|
||||||
|
}
|
||||||
|
d := DeploymentModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
ServiceID: service.ID,
|
||||||
|
CommitHash: commitHash,
|
||||||
|
Status: "pending",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO deployments
|
||||||
|
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if engine == nil {
|
||||||
|
failedAt := time.Now()
|
||||||
|
failure := "Deployment engine unavailable. Docker may not be configured on this server."
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`,
|
||||||
|
failure, failedAt, d.ID,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), service.ID,
|
||||||
|
)
|
||||||
|
go runDeploymentAndSync(
|
||||||
|
context.Background(), db, engine, &d, service,
|
||||||
|
CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"},
|
||||||
|
repoUserID,
|
||||||
|
)
|
||||||
|
enqueued++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{
|
||||||
|
"received": true,
|
||||||
|
"branch": branch,
|
||||||
|
"deployments": enqueued,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyGitWebhookSignature checks the push signature per provider:
|
||||||
|
// GitHub X-Hub-Signature-256 and Gitea X-Gitea-Signature are HMAC-SHA256 hex;
|
||||||
|
// GitLab compares X-Gitlab-Token to the stored secret directly.
|
||||||
|
func verifyGitWebhookSignature(provider, secret string, r *http.Request, body []byte) bool {
|
||||||
|
if strings.EqualFold(provider, "gitlab") {
|
||||||
|
token := r.Header.Get("X-Gitlab-Token")
|
||||||
|
return token != "" && subtle.ConstantTimeCompare([]byte(token), []byte(secret)) == 1
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(body)
|
||||||
|
sum := mac.Sum(nil)
|
||||||
|
for _, header := range []string{"X-Hub-Signature-256", "X-Gitea-Signature"} {
|
||||||
|
sig := strings.TrimPrefix(r.Header.Get(header), "sha256=")
|
||||||
|
if decoded, err := hex.DecodeString(sig); err == nil && len(decoded) > 0 && hmac.Equal(decoded, sum) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func signBody(t *testing.T, secret string, body []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(body)
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyGitWebhookSignature(t *testing.T) {
|
||||||
|
body := []byte(`{"ref":"refs/heads/main"}`)
|
||||||
|
secret := "test-secret"
|
||||||
|
valid := signBody(t, secret, body)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
provider string
|
||||||
|
headers map[string]string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "github sha256 prefix",
|
||||||
|
provider: "github",
|
||||||
|
headers: map[string]string{"X-Hub-Signature-256": "sha256=" + valid},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gitea signature",
|
||||||
|
provider: "gitea",
|
||||||
|
headers: map[string]string{"X-Gitea-Signature": valid},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gitlab token",
|
||||||
|
provider: "gitlab",
|
||||||
|
headers: map[string]string{"X-Gitlab-Token": secret},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gitlab rejects wrong token",
|
||||||
|
provider: "gitlab",
|
||||||
|
headers: map[string]string{"X-Gitlab-Token": "wrong"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "github rejects wrong signature",
|
||||||
|
provider: "github",
|
||||||
|
headers: map[string]string{"X-Hub-Signature-256": "sha256=" + signBody(t, "other", body)},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing signature",
|
||||||
|
provider: "github",
|
||||||
|
headers: map[string]string{},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed hex",
|
||||||
|
provider: "github",
|
||||||
|
headers: map[string]string{"X-Hub-Signature-256": "sha256=zzz"},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("POST", "/api/git/webhooks/x", nil)
|
||||||
|
for k, v := range tc.headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
if got := verifyGitWebhookSignature(tc.provider, secret, req, body); got != tc.want {
|
||||||
|
t.Fatalf("verifyGitWebhookSignature = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,11 +64,15 @@ func handleGetLogs(c *gin.Context) {
|
|||||||
|
|
||||||
dockerClient, exists := c.Get("docker_client")
|
dockerClient, exists := c.Get("docker_client")
|
||||||
if !exists || dockerClient == nil {
|
if !exists || dockerClient == nil {
|
||||||
respondDependencyUnavailable(c, "docker", "Container log streaming is unavailable because Docker is not configured.")
|
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := dockerClient.(*docker.Client)
|
client, ok := dockerClient.(*docker.Client)
|
||||||
|
if !ok || client == nil {
|
||||||
|
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||||
|
return
|
||||||
|
}
|
||||||
containerName := fmt.Sprintf("containr-%s", serviceID)
|
containerName := fmt.Sprintf("containr-%s", serviceID)
|
||||||
|
|
||||||
logOpts := docker.LogOptions{
|
logOpts := docker.LogOptions{
|
||||||
@@ -82,6 +86,10 @@ func handleGetLogs(c *gin.Context) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDockerNotFoundError(err) {
|
||||||
|
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -156,6 +164,13 @@ func handleGetLogs(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
|
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func respondEmptyServiceLogs(c *gin.Context, message string) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"logs": []LogEntry{},
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func handleGetDeploymentLogs(c *gin.Context) {
|
func handleGetDeploymentLogs(c *gin.Context) {
|
||||||
db, exists := c.Get("db")
|
db, exists := c.Get("db")
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/docker"
|
||||||
|
|
||||||
|
containertypes "github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceMetricsResponse is the aggregated live telemetry for a service,
|
||||||
|
// sourced from Docker container stats for containers named
|
||||||
|
// containr-<serviceID>-<deploymentID>-<index>.
|
||||||
|
type ServiceMetricsResponse struct {
|
||||||
|
ServiceID string `json:"service_id"`
|
||||||
|
Status string `json:"status"` // ok, no_containers, docker_unavailable
|
||||||
|
Instances []ServiceInstanceMetrics `json:"instances"`
|
||||||
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||||
|
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
|
||||||
|
NetworkRxBytes uint64 `json:"network_rx_bytes"`
|
||||||
|
NetworkTxBytes uint64 `json:"network_tx_bytes"`
|
||||||
|
CollectedAt time.Time `json:"collected_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceInstanceMetrics holds live stats for one running container.
|
||||||
|
type ServiceInstanceMetrics struct {
|
||||||
|
ContainerID string `json:"container_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||||
|
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
|
||||||
|
NetworkRxBytes uint64 `json:"network_rx_bytes"`
|
||||||
|
NetworkTxBytes uint64 `json:"network_tx_bytes"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetServiceMetrics returns real resource usage for the service's
|
||||||
|
// Docker containers. Empty result when the service has never deployed.
|
||||||
|
func handleGetServiceMetrics(c *gin.Context) {
|
||||||
|
dbValue, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db := dbValue.(*database.DB)
|
||||||
|
|
||||||
|
serviceID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownerID string
|
||||||
|
var status string
|
||||||
|
err = db.QueryRow(
|
||||||
|
`SELECT p.owner_id, COALESCE(s.status, '')
|
||||||
|
FROM services s JOIN projects p ON s.project_id = p.id
|
||||||
|
WHERE s.id = $1`,
|
||||||
|
serviceID,
|
||||||
|
).Scan(&ownerID, &status)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ownerID != userID.(string) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := ServiceMetricsResponse{
|
||||||
|
ServiceID: serviceID.String(),
|
||||||
|
Status: "ok",
|
||||||
|
Instances: []ServiceInstanceMetrics{},
|
||||||
|
CollectedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerValue, exists := c.Get("docker_client")
|
||||||
|
if !exists || dockerValue == nil {
|
||||||
|
response.Status = "docker_unavailable"
|
||||||
|
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dockerClient := dockerValue.(*docker.Client)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
containers, err := dockerClient.ListContainers(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
response.Status = "docker_unavailable"
|
||||||
|
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
namePrefix := fmt.Sprintf("containr-%s-", serviceID.String())
|
||||||
|
matched := 0
|
||||||
|
for _, ctr := range containers {
|
||||||
|
name := ""
|
||||||
|
for _, n := range ctr.Names {
|
||||||
|
name = strings.TrimPrefix(n, "/")
|
||||||
|
if strings.HasPrefix(name, namePrefix) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
name = ""
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matched++
|
||||||
|
|
||||||
|
instance := ServiceInstanceMetrics{
|
||||||
|
ContainerID: ctr.ID,
|
||||||
|
Name: name,
|
||||||
|
State: ctr.State,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := dockerClient.GetContainer(ctx, ctr.ID); err == nil {
|
||||||
|
if info.State != nil && info.State.StartedAt != "" {
|
||||||
|
if parsed, perr := time.Parse(time.RFC3339Nano, info.State.StartedAt); perr == nil {
|
||||||
|
instance.StartedAt = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctr.State == "running" {
|
||||||
|
if stats, err := readContainerStats(ctx, dockerClient, ctr.ID); err == nil {
|
||||||
|
instance.CPUPercent = stats.cpuPercent
|
||||||
|
instance.MemoryUsageBytes = stats.memoryUsage
|
||||||
|
instance.MemoryLimitBytes = stats.memoryLimit
|
||||||
|
instance.NetworkRxBytes = stats.networkRx
|
||||||
|
instance.NetworkTxBytes = stats.networkTx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Instances = append(response.Instances, instance)
|
||||||
|
response.CPUPercent += instance.CPUPercent
|
||||||
|
response.MemoryUsageBytes += instance.MemoryUsageBytes
|
||||||
|
response.MemoryLimitBytes += instance.MemoryLimitBytes
|
||||||
|
response.NetworkRxBytes += instance.NetworkRxBytes
|
||||||
|
response.NetworkTxBytes += instance.NetworkTxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
if matched == 0 {
|
||||||
|
response.Status = "no_containers"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||||
|
}
|
||||||
|
|
||||||
|
type containerStatsSnapshot struct {
|
||||||
|
cpuPercent float64
|
||||||
|
memoryUsage uint64
|
||||||
|
memoryLimit uint64
|
||||||
|
networkRx uint64
|
||||||
|
networkTx uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func readContainerStats(ctx context.Context, client *docker.Client, containerID string) (*containerStatsSnapshot, error) {
|
||||||
|
reader, err := client.GetContainerStats(ctx, containerID, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer reader.Body.Close()
|
||||||
|
|
||||||
|
var stats containertypes.StatsResponse
|
||||||
|
if err := json.NewDecoder(reader.Body).Decode(&stats); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := &containerStatsSnapshot{
|
||||||
|
memoryUsage: stats.MemoryStats.Usage,
|
||||||
|
memoryLimit: stats.MemoryStats.Limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage)
|
||||||
|
systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage)
|
||||||
|
onlineCPUs := float64(stats.CPUStats.OnlineCPUs)
|
||||||
|
if onlineCPUs == 0 {
|
||||||
|
onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
|
||||||
|
}
|
||||||
|
if cpuDelta > 0 && systemDelta > 0 && onlineCPUs > 0 {
|
||||||
|
snapshot.cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, net := range stats.Networks {
|
||||||
|
snapshot.networkRx += net.RxBytes
|
||||||
|
snapshot.networkTx += net.TxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
ResourceType string `json:"resource_type"`
|
||||||
|
ResourceID string `json:"resource_id"`
|
||||||
|
ReadAt *time.Time `json:"read_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertUserNotification records a persistent notification for a user.
|
||||||
|
// Best-effort: notification delivery must never break the caller's flow.
|
||||||
|
func insertUserNotification(db *database.DB, userID, kind, title, body, resourceType, resourceID string) {
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil || db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = sqlcdb.New(db.DB).CreateNotification(context.Background(), sqlcdb.CreateNotificationParams{
|
||||||
|
ID: uuid.New(),
|
||||||
|
UserID: userUUID,
|
||||||
|
Kind: kind,
|
||||||
|
Title: title,
|
||||||
|
Body: sql.NullString{String: body, Valid: body != ""},
|
||||||
|
ResourceType: sql.NullString{String: resourceType, Valid: resourceType != ""},
|
||||||
|
ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""},
|
||||||
|
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapNotification(row sqlcdb.Notification) NotificationItem {
|
||||||
|
var readAt *time.Time
|
||||||
|
if row.ReadAt.Valid {
|
||||||
|
t := row.ReadAt.Time
|
||||||
|
readAt = &t
|
||||||
|
}
|
||||||
|
return NotificationItem{
|
||||||
|
ID: row.ID.String(),
|
||||||
|
Kind: row.Kind,
|
||||||
|
Title: row.Title,
|
||||||
|
Body: databaseNullString(row.Body),
|
||||||
|
ResourceType: databaseNullString(row.ResourceType),
|
||||||
|
ResourceID: databaseNullString(row.ResourceID),
|
||||||
|
ReadAt: readAt,
|
||||||
|
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListNotifications(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := sqlcdb.New(db.(*database.DB).DB)
|
||||||
|
limit := int64(20)
|
||||||
|
if raw := c.Query("limit"); raw != "" {
|
||||||
|
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 && parsed <= 100 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := q.ListNotificationsByUser(c.Request.Context(), sqlcdb.ListNotificationsByUserParams{
|
||||||
|
UserID: userUUID,
|
||||||
|
Limit: int32(limit),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unread, err := q.CountUnreadNotificationsByUser(c.Request.Context(), userUUID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count notifications"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]NotificationItem, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, mapNotification(row))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"notifications": items, "unread": unread})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMarkNotificationRead(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := sqlcdb.New(db.(*database.DB).DB)
|
||||||
|
if err := q.MarkNotificationReadByIDAndUser(c.Request.Context(), sqlcdb.MarkNotificationReadByIDAndUserParams{
|
||||||
|
ID: id,
|
||||||
|
UserID: userUUID,
|
||||||
|
ReadAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Notification marked read"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMarkAllNotificationsRead(c *gin.Context) {
|
||||||
|
userID, ok := requireAuthenticatedUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, exists := c.Get("db")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := sqlcdb.New(db.(*database.DB).DB)
|
||||||
|
if err := q.MarkAllNotificationsReadByUser(c.Request.Context(), sqlcdb.MarkAllNotificationsReadByUserParams{
|
||||||
|
UserID: userUUID,
|
||||||
|
ReadAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notifications"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "All notifications marked read"})
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
// firstPathParam returns the first non-empty route param from the provided names.
|
// firstPathParam returns the first non-empty route param from the provided names.
|
||||||
func firstPathParam(c *gin.Context, names ...string) string {
|
func firstPathParam(c *gin.Context, names ...string) string {
|
||||||
@@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// firstNonEmpty returns the first non-blank string, trimmed.
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -201,7 +201,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
|
|||||||
// Check if service exists and belongs to the project
|
// Check if service exists and belongs to the project
|
||||||
var service Service
|
var service Service
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
"SELECT id, name, type FROM services WHERE id = $1 AND project_id = $2",
|
"SELECT id, name, COALESCE(type, service_type, '') FROM services WHERE id = $1 AND project_id = $2",
|
||||||
req.ServiceID, req.ProjectID,
|
req.ServiceID, req.ProjectID,
|
||||||
).Scan(&service.ID, &service.Name, &service.Type)
|
).Scan(&service.ID, &service.Name, &service.Type)
|
||||||
|
|
||||||
@@ -586,10 +586,11 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deploymentID := uuid.New()
|
deploymentID := uuid.New()
|
||||||
|
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO deployments (id, service_id, status, created_at, updated_at)
|
`INSERT INTO deployments (id, service_id, version, status, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, NOW(), NOW())`,
|
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
|
||||||
deploymentID, env.ServiceID, "pending",
|
deploymentID, env.ServiceID, promotionVersion, "pending",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
|
||||||
|
|||||||
@@ -17,13 +17,11 @@ import (
|
|||||||
"containr/internal/scaling"
|
"containr/internal/scaling"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||||
// Expose Better Auth through backend so frontend can use a single backend origin.
|
// Expose Better Auth through backend so frontend can use a single backend origin.
|
||||||
setupAuthProxyRoutes(router, cfg)
|
setupAuthProxyRoutes(router, cfg, db)
|
||||||
|
|
||||||
// Initialize Docker client (non-fatal if it fails)
|
// Initialize Docker client (non-fatal if it fails)
|
||||||
var dockerClient *docker.Client
|
var dockerClient *docker.Client
|
||||||
@@ -50,20 +48,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
}
|
}
|
||||||
metricsCollector := metrics.NewMetricsCollector(scheduler, metricsStorage)
|
metricsCollector := metrics.NewMetricsCollector(scheduler, metricsStorage)
|
||||||
autoScaler := scaling.NewAutoScaler(scheduler, metricsCollector)
|
autoScaler := scaling.NewAutoScaler(scheduler, metricsCollector)
|
||||||
|
if db != nil && db.DB != nil {
|
||||||
|
autoScaler.WithPersistence(db.DB)
|
||||||
|
if err := autoScaler.LoadPolicies(context.Background()); err != nil {
|
||||||
|
log.Printf("Failed to restore scaling policies: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
|
haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector)
|
||||||
haAPIManager := NewHAManager(haManager)
|
haAPIManager := NewHAManager(haManager)
|
||||||
|
|
||||||
// Initialize scaling handler
|
// Initialize scaling handler
|
||||||
scalingHandler := NewScalingHandler(autoScaler)
|
scalingHandler := NewScalingHandler(autoScaler)
|
||||||
|
|
||||||
// Initialize GORM for agent system
|
// Initialize agent handler (sqlc-backed)
|
||||||
gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{})
|
agentHandler := NewNodeAgentHandler(db)
|
||||||
if err != nil {
|
|
||||||
panic("Failed to initialize GORM: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize agent handler
|
// Start the cron scheduler — executes due cron_jobs via docker exec.
|
||||||
agentHandler := NewNodeAgentHandler(gormDB)
|
StartCronScheduler(context.Background(), db, dockerClient)
|
||||||
|
|
||||||
// Initialize database handler
|
// Initialize database handler
|
||||||
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
|
||||||
@@ -90,7 +91,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
c.Set("auto_scaler", autoScaler)
|
c.Set("auto_scaler", autoScaler)
|
||||||
c.Set("ha_manager", haManager)
|
c.Set("ha_manager", haManager)
|
||||||
c.Set("scaling_handler", scalingHandler)
|
c.Set("scaling_handler", scalingHandler)
|
||||||
c.Set("gorm_db", gormDB)
|
|
||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,11 +158,19 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
router.HEAD("/ready", healthHandler)
|
router.HEAD("/ready", healthHandler)
|
||||||
|
|
||||||
// API v1 routes
|
// API v1 routes
|
||||||
|
publicAgents := router.Group("/api")
|
||||||
|
agentHandler.SetupPublicRoutes(publicAgents)
|
||||||
|
|
||||||
|
// Public push receiver: HMAC signature on git_webhooks.webhook_secret
|
||||||
|
// replaces session auth. Providers POST to /api/git/webhooks/:id.
|
||||||
|
router.POST("/api/git/webhooks/:id", handleGitWebhookPush)
|
||||||
|
|
||||||
v1 := router.Group("/api/v1")
|
v1 := router.Group("/api/v1")
|
||||||
{
|
{
|
||||||
// Public routes (no authentication required)
|
// Public routes (no authentication required)
|
||||||
public := v1.Group("/")
|
public := v1.Group("/")
|
||||||
{
|
{
|
||||||
|
public.GET("/auth/bootstrap", handleAuthBootstrap)
|
||||||
public.POST("/auth/login", handleLogin)
|
public.POST("/auth/login", handleLogin)
|
||||||
public.POST("/auth/register", handleRegister)
|
public.POST("/auth/register", handleRegister)
|
||||||
}
|
}
|
||||||
@@ -174,6 +182,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// User routes
|
// User routes
|
||||||
protected.GET("/user/profile", handleGetProfile)
|
protected.GET("/user/profile", handleGetProfile)
|
||||||
protected.PUT("/user/profile", handleUpdateProfile)
|
protected.PUT("/user/profile", handleUpdateProfile)
|
||||||
|
protected.POST("/users", handleCreateUser)
|
||||||
|
|
||||||
// Project routes
|
// Project routes
|
||||||
protected.GET("/projects", handleGetProjects)
|
protected.GET("/projects", handleGetProjects)
|
||||||
@@ -192,6 +201,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
protected.GET("/services/:id", handleGetService)
|
protected.GET("/services/:id", handleGetService)
|
||||||
protected.PUT("/services/:id", handleUpdateService)
|
protected.PUT("/services/:id", handleUpdateService)
|
||||||
protected.DELETE("/services/:id", handleDeleteService)
|
protected.DELETE("/services/:id", handleDeleteService)
|
||||||
|
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||||
|
|
||||||
// Deployment routes
|
// Deployment routes
|
||||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
protected.GET("/services/:id/deployments", handleGetDeployments)
|
||||||
@@ -207,12 +217,17 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
protected.GET("/services/:id/logs", handleGetLogs)
|
protected.GET("/services/:id/logs", handleGetLogs)
|
||||||
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
|
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
|
||||||
|
|
||||||
|
// One-off exec console (docker exec, 30s ceiling)
|
||||||
|
protected.POST("/services/:id/exec", handleExecInService)
|
||||||
|
|
||||||
// Git integration routes
|
// Git integration routes
|
||||||
protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
|
||||||
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
|
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||||
protected.GET("/git/providers", handleGetGitProviders)
|
protected.GET("/git/providers", handleGetGitProviders)
|
||||||
protected.POST("/git/providers", handleCreateGitProvider)
|
protected.POST("/git/providers", handleCreateGitProvider)
|
||||||
|
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
|
||||||
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
|
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
|
||||||
|
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
|
||||||
protected.POST("/git/repositories/connect", handleConnectGitRepository)
|
protected.POST("/git/repositories/connect", handleConnectGitRepository)
|
||||||
protected.GET("/git/repositories", handleGetConnectedRepositories)
|
protected.GET("/git/repositories", handleGetConnectedRepositories)
|
||||||
protected.POST("/git/webhooks", handleCreateWebhook)
|
protected.POST("/git/webhooks", handleCreateWebhook)
|
||||||
@@ -226,6 +241,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
|
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
|
||||||
protected.GET("/builds/detect", buildHandler.DetectBuildType)
|
protected.GET("/builds/detect", buildHandler.DetectBuildType)
|
||||||
|
|
||||||
|
// System routes
|
||||||
|
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
|
||||||
|
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
|
||||||
|
protected.GET("/system/host", handleGetHostMonitoring)
|
||||||
|
|
||||||
// Scaling routes
|
// Scaling routes
|
||||||
scalingHandler.RegisterRoutes(protected)
|
scalingHandler.RegisterRoutes(protected)
|
||||||
haAPIManager.RegisterRoutes(protected)
|
haAPIManager.RegisterRoutes(protected)
|
||||||
@@ -239,11 +259,15 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
|
protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
|
||||||
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
|
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
|
||||||
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||||
|
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
|
||||||
|
|
||||||
|
// Notification routes
|
||||||
|
protected.GET("/notifications", handleListNotifications)
|
||||||
|
protected.POST("/notifications/:id/read", handleMarkNotificationRead)
|
||||||
|
protected.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
||||||
|
|
||||||
// Node Agent routes
|
// Node Agent routes
|
||||||
api := router.Group("/api")
|
agentHandler.SetupRoutes(protected)
|
||||||
api.Use(middleware.Auth(cfg.JWTSecret))
|
|
||||||
agentHandler.SetupRoutes(api)
|
|
||||||
|
|
||||||
// Preview Environments routes
|
// Preview Environments routes
|
||||||
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||||
@@ -287,42 +311,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
// Audit Logs routes
|
// Audit Logs routes
|
||||||
protected.GET("/audit-logs", handleGetAuditLogs)
|
protected.GET("/audit-logs", handleGetAuditLogs)
|
||||||
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||||
}
|
|
||||||
|
|
||||||
// APwhy Gateway routes
|
// API Gateway routes (merged APwhy) - namespaced to avoid
|
||||||
apwhy := router.Group("/api/v1")
|
// colliding with Containr's own /services paths.
|
||||||
{
|
gateway := protected.Group("/gateway")
|
||||||
// Health check (no auth required)
|
{
|
||||||
apwhy.GET("/health", func(c *gin.Context) {
|
gateway.GET("/services", handleAPwhyServicesList)
|
||||||
c.JSON(200, gin.H{
|
gateway.POST("/services", handleAPwhyServicesCreate)
|
||||||
"ok": true,
|
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
|
||||||
"data": gin.H{
|
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
|
||||||
"status": "ok",
|
|
||||||
"name": "Containr + APwhy",
|
|
||||||
"database": "postgresql",
|
|
||||||
"generatedAt": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protected APwhy routes (authentication required)
|
gateway.GET("/keys", handleAPwhyKeysList)
|
||||||
protectedAPwhy := router.Group("/api/v1")
|
gateway.POST("/keys", handleAPwhyKeysCreate)
|
||||||
protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret))
|
gateway.PATCH("/keys/:id", handleAPwhyKeysPatch)
|
||||||
{
|
|
||||||
// Service management
|
|
||||||
protectedAPwhy.GET("/services", handleAPwhyServicesList)
|
|
||||||
protectedAPwhy.POST("/services", handleAPwhyServicesCreate)
|
|
||||||
protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch)
|
|
||||||
|
|
||||||
// API Keys
|
gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps)
|
||||||
protectedAPwhy.GET("/keys", handleAPwhyKeysList)
|
gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
|
||||||
protectedAPwhy.POST("/keys", handleAPwhyKeysCreate)
|
}
|
||||||
protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch)
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps)
|
|
||||||
protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,10 +394,10 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = sh.db.QueryRow(`
|
err = sh.db.QueryRow(`
|
||||||
SELECT id, score, started_at as scanned_at, status
|
SELECT id, COALESCE((summary->>'score')::int, 0), started_at as scanned_at, status
|
||||||
FROM security_scans
|
FROM security_scans
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
|
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -99,8 +99,18 @@ func handleGetServices(c *gin.Context) {
|
|||||||
|
|
||||||
// Get services for the project
|
// Get services for the project
|
||||||
rows, err := db.(*database.DB).Query(
|
rows, err := db.(*database.DB).Query(
|
||||||
`SELECT id, project_id, name, type, status, image, command, environment,
|
`SELECT id, project_id, name,
|
||||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at
|
COALESCE(type, service_type, ''),
|
||||||
|
COALESCE(status, ''),
|
||||||
|
COALESCE(image, image_name, ''),
|
||||||
|
COALESCE(command, start_command, ''),
|
||||||
|
COALESCE(environment, ''),
|
||||||
|
COALESCE(git_repo, source_url, ''),
|
||||||
|
COALESCE(git_branch, ''),
|
||||||
|
COALESCE(build_path, ''),
|
||||||
|
COALESCE(cpu, ''),
|
||||||
|
COALESCE(memory, ''),
|
||||||
|
created_at, updated_at
|
||||||
FROM services
|
FROM services
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
ORDER BY created_at DESC`,
|
ORDER BY created_at DESC`,
|
||||||
@@ -228,15 +238,25 @@ func handleCreateService(c *gin.Context) {
|
|||||||
service.Memory = "512Mi"
|
service.Memory = "512Mi"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceType := inferServiceSourceType(service)
|
||||||
|
|
||||||
// Insert service into database
|
// Insert service into database
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO services
|
`INSERT INTO services
|
||||||
(id, project_id, name, type, status, image, command, environment,
|
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||||
|
build_command, start_command, type, status, image, command, environment,
|
||||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
|
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
|
||||||
service.ID, service.ProjectID, service.Name, service.Type, service.Status,
|
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||||
service.Image, service.Command, service.Environment, service.GitRepo,
|
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||||
service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||||
|
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||||
service.CreatedAt, service.UpdatedAt,
|
service.CreatedAt, service.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -248,6 +268,26 @@ func handleCreateService(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
|
||||||
|
var environmentID uuid.UUID
|
||||||
|
err := db.QueryRow(
|
||||||
|
"SELECT id FROM environments WHERE project_id = $1 AND name = $2",
|
||||||
|
projectID,
|
||||||
|
environment,
|
||||||
|
).Scan(&environmentID)
|
||||||
|
return environmentID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferServiceSourceType(service Service) string {
|
||||||
|
if service.GitRepo != "" {
|
||||||
|
return "github"
|
||||||
|
}
|
||||||
|
if service.Image != "" {
|
||||||
|
return "image"
|
||||||
|
}
|
||||||
|
return "dockerfile"
|
||||||
|
}
|
||||||
|
|
||||||
// handleGetService retrieves a specific service
|
// handleGetService retrieves a specific service
|
||||||
func handleGetService(c *gin.Context) {
|
func handleGetService(c *gin.Context) {
|
||||||
db, exists := c.Get("db")
|
db, exists := c.Get("db")
|
||||||
@@ -273,8 +313,17 @@ func handleGetService(c *gin.Context) {
|
|||||||
// Get service with project ownership check
|
// Get service with project ownership check
|
||||||
var service Service
|
var service Service
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
`SELECT s.id, s.project_id, s.name,
|
||||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
COALESCE(s.type, s.service_type, ''),
|
||||||
|
COALESCE(s.status, ''),
|
||||||
|
COALESCE(s.image, s.image_name, ''),
|
||||||
|
COALESCE(s.command, s.start_command, ''),
|
||||||
|
COALESCE(s.environment, ''),
|
||||||
|
COALESCE(s.git_repo, s.source_url, ''),
|
||||||
|
COALESCE(s.git_branch, ''),
|
||||||
|
COALESCE(s.build_path, ''),
|
||||||
|
COALESCE(s.cpu, ''),
|
||||||
|
COALESCE(s.memory, ''),
|
||||||
s.created_at, s.updated_at
|
s.created_at, s.updated_at
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
@@ -326,8 +375,17 @@ func handleUpdateService(c *gin.Context) {
|
|||||||
// Check if service exists and user has access
|
// Check if service exists and user has access
|
||||||
var existingService Service
|
var existingService Service
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
`SELECT s.id, s.project_id, s.name,
|
||||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
COALESCE(s.type, s.service_type, ''),
|
||||||
|
COALESCE(s.status, ''),
|
||||||
|
COALESCE(s.image, s.image_name, ''),
|
||||||
|
COALESCE(s.command, s.start_command, ''),
|
||||||
|
COALESCE(s.environment, ''),
|
||||||
|
COALESCE(s.git_repo, s.source_url, ''),
|
||||||
|
COALESCE(s.git_branch, ''),
|
||||||
|
COALESCE(s.build_path, ''),
|
||||||
|
COALESCE(s.cpu, ''),
|
||||||
|
COALESCE(s.memory, ''),
|
||||||
s.created_at, s.updated_at
|
s.created_at, s.updated_at
|
||||||
FROM services s
|
FROM services s
|
||||||
JOIN projects p ON s.project_id = p.id
|
JOIN projects p ON s.project_id = p.id
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,d09GMgABAAAAAAZYAA8AAAAAC8QAAAX7AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG4F8HC4GYD9TVEFULgCBChEICokwh0kLIAABNgIkAzwEIAWEWAeBDgwHG7UJUZRRTjrBz4QKcv+QWjxhcjsVprsyM/p7jc9kFU2t525PaKAAX1gOyg6dISDJzAqQDFwnMAUYFEIW+IFRPPQ7/9W9LLSKCzCeDgimtvwvBEjFDoWDY6mfHo2VAv9KFfedVkGdKysb2MC6af4bbj5u3FK7g8trAXi8WwgIgAb9SPi4hLQ8hD1NcjXC3km2WoRhAPBN1FVZ5FqoAJQOBk6QoAKH+icDuzEPCqgAAEAY+CZqjSuR1b67rPaAb5HvJFC9/KTP5oPWf5lpmvg+C59ETNMN0PAP48BDgAJKqKABgeAHPvEUQADAQQEGghJK0HSVFNA8TGBbQB5ggHT7KIBtUGGXsvG0igAIYewkYAPcmbsqsiwSk+SUADicmrvTk3ETXTyryvvvIOA/iSsSQTsBYJLgBIjYVNMEIIw+tDOuoazcRgDAgQfAEU+rBlYXAXQ4DN/4+QwOQFtBeCjgD5GBE7eIYyfySn4lnl1QRzH7eF7OLw/xfeOmagZEWz4oQWgLMSE/BCDI0/0hQAUF1FBCAwL5CAQAgOx6cE+gdIATACg6SQOAPwe+oZGgSqlj0syo0oiXBdnUHZgvSN1JfJL+fRnrq6VQNIvTtC3aJk2zttFMgWjJnNGB78HjIe0st3ap11/jclWx+QvcVlFlC1gLM3q9FcvcbnAafrYndivNEldsoULTMoFCYfPx2zT7nU/80z56C7NaPyxY6n2HZr/tKah6fmue6y23hW/WNrVoG7kW7klr9vztFocIusq90phGbaZgTOWhquGUFOly0Owg4cIsd00pf4bHowtyN25s53+xyKG7ah1R12ZQZjri7aZ1umKd2bHw0oAD3hcvOh1pyRl9edKJo67OGm8D45t+fNmv3PoU15iDuoeyJJtmrcqSsgDNliKl0OQEKYFuM0pGf6wUW4CTJInm0Et6ujxAGoDvJ/XD95J64URJ3MPWUeoIXTfBecf/95u72DOiu8RnmMYu+XIJBjhSZjiGzYgom1Xu1L7nxSRbfJwtsVnKzJSiMmisOTHexrcO3y82exufmLsOiT88Z9usbYe7hsabEfybc+8MU8TDmOlXP1g85eftK9eNtfTeXWBfNap6/o3MD7raA9dCcOL8gjW5a4bv9fa9vjH7vfeYHCa9rX2ub8LkTM528y479NHm+pCzZ+uDYfwgDn2QeVecwZn6oI+24ERZeft50sbQN42hvzSuV69fjtC90hrz9j7mvU9Xd1m8SFlCKUHfr/u+B5/rCK1eWjcra4UupPNaCYffnZE348Tfuz5Z3UQHDrf4Pl2xY/enqxqBvdMmfLZy57KzJ76/cffkF2eatlWn3Zxx58zXMJ23Q98cH5m+3Zw5auaciCuOIReTuhvEL2b3T48a1id30F9N3Yen2qLObumRWLqhKkKuHZV1qbBenBEwY8xHHcGHV1wb0+1lz9cvOiTponXFXQOmhseOnqof8qMuN2OTkDJq/o87nPHj87dK1rnGdYWG8Lq9ffepB04BBjAAAMCKBCcCwGa3U/fVnBAY+5uKV74E4J3z++cBwHvdsnfC/5uEKuUsgAIcAICAd65AeAFApU1AiLUGCg/xsV/oLKEIiAv+WNbYP+qfRXuZcYDtlBWrgOHBTv7OOiYEIBAF/KEGDaHBr9JgBh7E1ACcFI1xCL2iLYdDAKk4PJLc5DAMtYwjoCOZo0CkMewADOUnQYAfAHzXBxWyqFCmnC0pV4x60QbvZTWFrEI9G8cPYjVUw5eqIyszWKYEadJNUktWp55FvEY2sknSOk8OlMPC/HbVxX5ZQQByp3vXqSUaapBh9LCLP1tkMsT2Tj7rGLmSpEsWM7lOdPAJganS+ibyFA2RrDZCFFGe8g66dghZGLISpjQMGtgPAOq2JirSHYbZqoNlKir67Saz9BR1ag4JyiesVP6pJhVM1gz4h+E4AAB82xAptrBAAL08Ig6xwRMjgRSkJBV4UpOGtArDZNnisKjz/4Y5zDalSWmvrdAPNRrgdvGS2l4Ba4QoWqI36DlTjt0vUm8cepF8n2TLAQAAAA==) format("woff2"),url(data:font/woff;base64,d09GRgABAAAAAAiYAA4AAAAAC0AAAQABAAAAAAAAAAAAAAAAAAAAAAAAAABHUE9TAAABRAAAAJsAAAD8joyVK0dTVUIAAAHgAAAALQAAAC4jdiSCT1MvMgAAAhAAAABNAAAAYHI5reNTVEFUAAACYAAAACoAAAAueZJs3WNtYXAAAAKMAAAAZAAAAIpNu/P9Z2FzcAAAAvAAAAAIAAAACAAAABBnbHlmAAAC+AAAA3cAAASWUYkMm2hlYWQAAAZwAAAANgAAADYgWsSPaGhlYQAABqgAAAAfAAAAJBDBBppobXR4AAAGyAAAADYAAAA8QYMDamxvY2EAAAcAAAAAIAAAACAI1wpebWF4cAAAByAAAAAcAAAAIAAmAL9uYW1lAAAHPAAAAPcAAAIEKtNM/3Bvc3QAAAg0AAAAYwAAAI7B1Xo4eNpVzqdZRFEQBeD/ZZJ8VVABCkMOEk825Bw0sQ4qWL2aInCorePu/UbtTs5zFJizaEm1ur67pz9+vbvQXxw+XOnVkJICxfnp3ZUZRKZW5qzIWrqJyR/v2piAnnSQ/gByJP1OZFuk7zQg5gbpIf1bTsNcEbMj0tAUxT5F/C1VGq0uZ3OKzAuqYBqUWrVCl7kQOIPFXqEeA9KNKLwAeNpjYGRgYOBi0AFCZhc3nxAGieTKohwGkZzEkjwGEQYQYAER//+DSACWTwc8AAAAeNpjYGGZwDiBgZWBgVWEZQMDA8M0CM20h8GIqYMBBTCxgwgYL9jD15PhAAOvwhaOkL8rGBg4QpgiFBgY54PkWCxYFwApBQYWADWRDDIAAAB42mNgBEIOIGZgEAGTMgxM5ekZJSAmAxMDM4hkZGacAKT2MDAAADlZA1QAAHjaVcg1AUIBFAXQg7vLTJVfgwzM5KEAzoJDEkYsBK7vXUcYEVRFhB4e1UZdUURcTkOgYxsKR/vRefTYmFwu/O296Cx6eO6X82VzaV2CSwjYNYXUfS8rrwhyYpLiUhLSV1I9F+YAAQAB//8AD3jalFMDlBxLFH1VrbV71qOejFfT2J6NtT5/Y9vGrDe2bfvbtm3bPPjxUdA1qY5xlGbp3vsIifA+AAfcQWBAgHhIhnTIhFywgQQeCEARyACy6hDb3eX7PqtfuMs3EtnYq1dDdXWkvLyhW7eGDh0iut5UXBwJBiN+f5PLFbHbG3JyADB4AdiPqM0CxAHISEYS42BisRedegud2tVqLGzB75OT5DR38PwoNJwcQf8x6RePAzBXkMLCy8ikm7CMlC7fAUcj5jfPv5WDHYu0GzzOyzw5N/M43W5VzrDIIY3+eP5OzrTKxp5tvfRwXVt5423c4fLGCr1XL72isRwAAb3YQdTLGABHKhWgTyr6i+Q/iJagRQ8SGx7K9DTG4n0XXzKOQTR67TzmgQUTv5bOF1F8Fp2kaqWaHLJYxFQpVSnEklPgBTpENr1q3LyXfx+31T1w07Rlo3oEtnAHjWfsFuvqs/vIbDRs/JGmzt48h1F7uUI6RP/kpnKvQiLllAA4pwunpLnkEJuWgllPiGqUOiyZFgtye9xuj5OnIvir78j3O3ci73ffI8+uXeSH7x/4MxyMu5gYW6D/iY/EBcN/PvDA73pBnPU11Ae9iPq89hp5lJSTR19bt3v1tJUrpq3agx9YMX3VbrR71fSVgMBLdjKfs7oZFXQ9Ko+Tlh2oI+q8jbSQnWgSq19cybRceJ/sNONwHwAbTxE5l+NghiHTikVRUhXTTMl5JRJl2v6hRz+xxsbgrWxMxtqFvTaEtrA6mZHn2bzaN7Qm5cQJZz/P9Fk+yfibcjHQK/onW8cOgGTIBqcZCzdWlTSTPU3MwHw65ZYuRwCFroYF1x36u23uP/sP/9Pa9u+bCdWDn2rd586PQaOfHlSVYHROqBli3YzwfQ8i2LCJRO97kEQ34tZdoxumj6oaRDJaxmzdMboJAEN3qtybZqEd1F7WpU6oCtWQaNs5qKKYYeZANtOhlco8XeGR6jBnNzymqxLdvRINWhYZ1FBZxD36HdDL77FjZiKbZIj2dItHKm7TahqDOZKwXQh5Or9obMC/Mkx+js/pGN+h83iX3cft5DsXlx0a0X+vHu4ucsJca0lA3j+0rgvHzZ9vdXjCaj+b31mm5lZ6OtQX9R7EtJ/PsvYqb9kQm81f4nL0K6pZXtK5PKwolzY13NGCn6+OAQDG7UKdAAABAAAABIEGEYo73l8PPPUAAwfQAAAAANu2poQAAAAA3R9Rn/6W/doJ1AjBAAAABgACAAAAAAAAeNpjYGRg4Aj5u4KBgcvn37R/0zivAEVQAT8AnI0GbgB42mM9wXCC5TODBhxHMDCAMCsvQw/reQYb5h9AsWiG5SzzGQIYJzAwgDDLBiCewuAMALUKDSYAAAAAAFoAdwCbAM0A5QDtARYBWAFxAZwB2QHZAdkB2QJLeNpjYGRgYOBnSGHgZYhkYAPzEICZgREAFJ0A5njafZBDekVBEEZP7MzjO4tt2/Yotm0sI+OsJyvICrKO/F+9fkbzdHUZyOaTFJJSs8Tf/DlOIp9fx8niHx/rbOHLcSqlPDtOo5BNx+mSDzjOl36uj5MQ4TjFfK5zwAlHHHOPtuY1XdRr3rHHLSd634vruBOf676S9Ej/84wxwyw7XHIr6TUH1LKk84gHae5IuqbXrVleScujRLine truncated
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s<e.observers.length;s+=1){const i=e.observers[s],o=xe&&xe.running;o&&xe.disposed.has(i),(o?!i.tState:!i.state)&&(i.pure?I.push(i):q.push(i),i.observers&&Ke(i)),o||(i.state=J)}if(I.length>1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspenLine truncated
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en" class="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>APwhy</title>
|
|
||||||
<script type="module" crossorigin src="/assets/index-DwfYiTMH.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DRUelTBf.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/docker"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type upgradeStatusResponse struct {
|
||||||
|
ImageRef string `json:"image_ref"`
|
||||||
|
Registry string `json:"registry"`
|
||||||
|
DockerAvailable bool `json:"docker_available"`
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
Digest string `json:"digest,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
AuthConfigured bool `json:"auth_configured"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hostMonitoringResponse struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
CPU hostCPU `json:"cpu"`
|
||||||
|
Memory hostMemory `json:"memory"`
|
||||||
|
Storage hostStorage `json:"storage"`
|
||||||
|
Load SystemLoad `json:"load"`
|
||||||
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||||
|
DockerAvailable bool `json:"docker_available"`
|
||||||
|
Docker map[string]any `json:"docker,omitempty"`
|
||||||
|
CollectedAt string `json:"collected_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hostCPU struct {
|
||||||
|
Cores int `json:"cores"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hostMemory struct {
|
||||||
|
Total uint64 `json:"total"`
|
||||||
|
Used uint64 `json:"used"`
|
||||||
|
Available uint64 `json:"available"`
|
||||||
|
UsagePct float64 `json:"usage_percent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hostStorage struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Total uint64 `json:"total"`
|
||||||
|
Used uint64 `json:"used"`
|
||||||
|
Available uint64 `json:"available"`
|
||||||
|
UsagePct float64 `json:"usage_percent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetUpgradeStatus(c *gin.Context) {
|
||||||
|
imageRef := configuredUpgradeImageRef()
|
||||||
|
dockerClient, _ := c.Get("docker_client")
|
||||||
|
client, _ := dockerClient.(*docker.Client)
|
||||||
|
|
||||||
|
response := upgradeStatusResponse{
|
||||||
|
ImageRef: imageRef,
|
||||||
|
Registry: registryHost(imageRef),
|
||||||
|
DockerAvailable: client != nil,
|
||||||
|
AuthConfigured: upgradeRegistryAuthConfigured(),
|
||||||
|
Message: "Upgrade image auto-detected",
|
||||||
|
}
|
||||||
|
|
||||||
|
if imageRef == "" {
|
||||||
|
response.Message = "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF to enable image pulls"
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
response.Message = "Docker client unavailable"
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
info, err := client.GetImageInfo(ctx, imageRef)
|
||||||
|
if err != nil {
|
||||||
|
response.Message = "Image not installed locally"
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Installed = true
|
||||||
|
response.Digest = info.Digest
|
||||||
|
response.Size = info.Size
|
||||||
|
response.Message = "Image installed locally"
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePullUpgradeImage(c *gin.Context) {
|
||||||
|
imageRef := configuredUpgradeImageRef()
|
||||||
|
if imageRef == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF before pulling"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerClient, _ := c.Get("docker_client")
|
||||||
|
client, ok := dockerClient.(*docker.Client)
|
||||||
|
if !ok || client == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker client unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reader, err := client.PullImage(ctx, imageRef, upgradeRegistryAuth())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, reader)
|
||||||
|
|
||||||
|
info, infoErr := client.GetImageInfo(ctx, imageRef)
|
||||||
|
response := gin.H{
|
||||||
|
"image_ref": imageRef,
|
||||||
|
"registry": registryHost(imageRef),
|
||||||
|
"message": "Image pull completed",
|
||||||
|
}
|
||||||
|
if infoErr == nil {
|
||||||
|
response["digest"] = info.Digest
|
||||||
|
response["size"] = info.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleGetHostMonitoring(c *gin.Context) {
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
memory := readHostMemory()
|
||||||
|
storage := readHostStorage("/")
|
||||||
|
load := readSystemLoad()
|
||||||
|
uptime := readSystemUptime()
|
||||||
|
|
||||||
|
response := hostMonitoringResponse{
|
||||||
|
Hostname: hostname,
|
||||||
|
OS: runtime.GOOS,
|
||||||
|
Architecture: runtime.GOARCH,
|
||||||
|
CPU: hostCPU{Cores: runtime.NumCPU()},
|
||||||
|
Memory: memory,
|
||||||
|
Storage: storage,
|
||||||
|
Load: load,
|
||||||
|
UptimeSeconds: uptime,
|
||||||
|
CollectedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
if dockerClient, ok := c.Get("docker_client"); ok {
|
||||||
|
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if info, err := client.GetSystemInfo(ctx); err == nil {
|
||||||
|
response.DockerAvailable = true
|
||||||
|
response.Docker = map[string]any{
|
||||||
|
"containers": info.Containers,
|
||||||
|
"images": info.Images,
|
||||||
|
"driver": info.Driver,
|
||||||
|
"server": info.ServerVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configuredUpgradeImageRef() string {
|
||||||
|
for _, key := range []string{"CONTAINR_UPGRADE_IMAGE_REF", "CONTAINR_IMAGE_REF", "DOCKER_IMAGE_REF"} {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryHost(imageRef string) string {
|
||||||
|
parts := strings.Split(imageRef, "/")
|
||||||
|
if len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(imageRef, "ghcr.io/") {
|
||||||
|
return "ghcr.io"
|
||||||
|
}
|
||||||
|
return "docker.io"
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeRegistryAuthConfigured() bool {
|
||||||
|
auth := upgradeRegistryAuth()
|
||||||
|
return strings.TrimSpace(auth.Username) != "" || strings.TrimSpace(auth.Password) != "" || strings.TrimSpace(auth.Auth) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeRegistryAuth() registry.AuthConfig {
|
||||||
|
username := firstConfigured("GITHUB_CONTAINER_REGISTRY_USER", "GHCR_USERNAME", "DOCKER_REGISTRY_USERNAME")
|
||||||
|
password := firstConfigured("GITHUB_CONTAINER_REGISTRY_TOKEN", "GHCR_TOKEN", "DOCKER_REGISTRY_PASSWORD")
|
||||||
|
return registry.AuthConfig{
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstConfigured(keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHostMemory() hostMemory {
|
||||||
|
values := map[string]uint64{}
|
||||||
|
file, err := os.Open("/proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return hostMemory{}
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
fields := strings.Fields(scanner.Text())
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSuffix(fields[0], ":")
|
||||||
|
value, parseErr := strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
if parseErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values[key] = value * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
total := values["MemTotal"]
|
||||||
|
available := values["MemAvailable"]
|
||||||
|
used := uint64(0)
|
||||||
|
if total > available {
|
||||||
|
used = total - available
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := 0.0
|
||||||
|
if total > 0 {
|
||||||
|
usage = float64(used) / float64(total) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostMemory{
|
||||||
|
Total: total,
|
||||||
|
Used: used,
|
||||||
|
Available: available,
|
||||||
|
UsagePct: usage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHostStorage(path string) hostStorage {
|
||||||
|
var stat syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(path, &stat); err != nil {
|
||||||
|
return hostStorage{Path: path}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := stat.Blocks * uint64(stat.Bsize)
|
||||||
|
available := stat.Bavail * uint64(stat.Bsize)
|
||||||
|
used := total - available
|
||||||
|
usage := 0.0
|
||||||
|
if total > 0 {
|
||||||
|
usage = float64(used) / float64(total) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostStorage{
|
||||||
|
Path: path,
|
||||||
|
Total: total,
|
||||||
|
Used: used,
|
||||||
|
Available: available,
|
||||||
|
UsagePct: usage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSystemLoad() SystemLoad {
|
||||||
|
data, err := os.ReadFile("/proc/loadavg")
|
||||||
|
if err != nil {
|
||||||
|
return SystemLoad{}
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(data))
|
||||||
|
if len(fields) < 3 {
|
||||||
|
return SystemLoad{}
|
||||||
|
}
|
||||||
|
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||||
|
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||||
|
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||||
|
return SystemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSystemUptime() int64 {
|
||||||
|
data, err := os.ReadFile("/proc/uptime")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(data))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
uptime, err := strconv.ParseFloat(fields[0], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(uptime)
|
||||||
|
}
|
||||||
@@ -279,9 +279,16 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
|
|
||||||
serviceImage := resolveTemplateRuntimeImage(config.Runtime)
|
serviceImage := resolveTemplateRuntimeImage(config.Runtime)
|
||||||
serviceCommand := strings.TrimSpace(config.StartCommand)
|
serviceCommand := strings.TrimSpace(config.StartCommand)
|
||||||
|
buildCommand := strings.TrimSpace(config.BuildCommand)
|
||||||
cpu, memory := defaultTemplateResources(serviceType)
|
cpu, memory := defaultTemplateResources(serviceType)
|
||||||
serviceEnvironment := "production"
|
serviceEnvironment := "production"
|
||||||
|
|
||||||
|
environmentID, err := getProjectEnvironmentID(db, projectID, serviceEnvironment)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
serviceID := uuid.New()
|
serviceID := uuid.New()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
@@ -294,18 +301,24 @@ func handleCreateFromTemplate(c *gin.Context) {
|
|||||||
|
|
||||||
txQueries := queries.WithTx(tx)
|
txQueries := queries.WithTx(tx)
|
||||||
err = txQueries.CreateServiceFromTemplate(ctx, sqlcdb.CreateServiceFromTemplateParams{
|
err = txQueries.CreateServiceFromTemplate(ctx, sqlcdb.CreateServiceFromTemplateParams{
|
||||||
ID: serviceID,
|
ID: serviceID,
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Type: serviceType,
|
EnvironmentID: environmentID,
|
||||||
Status: "stopped",
|
ServiceType: serviceType,
|
||||||
Image: sql.NullString{String: serviceImage, Valid: true},
|
SourceType: "template",
|
||||||
Command: sql.NullString{String: serviceCommand, Valid: true},
|
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||||
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
|
||||||
Cpu: sql.NullString{String: cpu, Valid: true},
|
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||||
Memory: sql.NullString{String: memory, Valid: true},
|
Type: sql.NullString{String: serviceType, Valid: true},
|
||||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
Status: sql.NullString{String: "stopped", Valid: true},
|
||||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||||
|
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||||
|
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
||||||
|
Cpu: sql.NullString{String: cpu, Valid: true},
|
||||||
|
Memory: sql.NullString{String: memory, Valid: true},
|
||||||
|
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
|
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maskedSecretValue is the placeholder returned for secret values; on update
|
||||||
|
// it means "keep the stored value" rather than overwriting with asterisks.
|
||||||
|
const maskedSecretValue = "********"
|
||||||
|
|
||||||
type EnvironmentVariable struct {
|
type EnvironmentVariable struct {
|
||||||
ID uuid.UUID `json:"id" db:"id"`
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
|
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
|
||||||
@@ -91,7 +95,7 @@ func handleGetVariables(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if v.IsSecret {
|
if v.IsSecret {
|
||||||
v.Value = "********"
|
v.Value = maskedSecretValue
|
||||||
}
|
}
|
||||||
variables = append(variables, v)
|
variables = append(variables, v)
|
||||||
}
|
}
|
||||||
@@ -143,6 +147,25 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot stored secrets so a client echoing back the masked placeholder
|
||||||
|
// does not destroy the real value.
|
||||||
|
storedSecrets := map[string]string{}
|
||||||
|
secretRows, err := db.(*database.DB).Query(
|
||||||
|
`SELECT key, value FROM environment_variables WHERE service_id = $1 AND is_secret = TRUE`,
|
||||||
|
serviceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for secretRows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := secretRows.Scan(&k, &v); err == nil {
|
||||||
|
storedSecrets[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secretRows.Close()
|
||||||
|
|
||||||
tx, err := db.(*database.DB).Begin()
|
tx, err := db.(*database.DB).Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
||||||
@@ -158,11 +181,17 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, v := range req.Variables {
|
for _, v := range req.Variables {
|
||||||
|
value := v.Value
|
||||||
|
if v.IsSecret && value == maskedSecretValue {
|
||||||
|
if stored, ok := storedSecrets[v.Key]; ok {
|
||||||
|
value = stored
|
||||||
|
}
|
||||||
|
}
|
||||||
varID := uuid.New()
|
varID := uuid.New()
|
||||||
_, err = tx.Exec(
|
_, err = tx.Exec(
|
||||||
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
varID, serviceID, v.Key, v.Value, v.IsSecret, now, now,
|
varID, serviceID, v.Key, value, v.IsSecret, now, now,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
|
||||||
@@ -198,7 +227,7 @@ func handleUpdateVariables(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if v.IsSecret {
|
if v.IsSecret {
|
||||||
v.Value = "********"
|
v.Value = maskedSecretValue
|
||||||
}
|
}
|
||||||
variables = append(variables, v)
|
variables = append(variables, v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package analytics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
BaseURL string
|
|
||||||
APIKey string
|
|
||||||
WebsiteID string
|
|
||||||
HTTP *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(baseURL, apiKey, websiteID string) *Client {
|
|
||||||
return &Client{
|
|
||||||
BaseURL: baseURL,
|
|
||||||
APIKey: apiKey,
|
|
||||||
WebsiteID: websiteID,
|
|
||||||
HTTP: &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Enabled() bool {
|
|
||||||
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
|
|
||||||
if !c.Enabled() {
|
|
||||||
return map[string]any{
|
|
||||||
"enabled": false,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
query := u.Query()
|
|
||||||
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
|
|
||||||
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
|
|
||||||
u.RawQuery = query.Encode()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
res, err := c.HTTP.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode >= 400 {
|
|
||||||
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]any
|
|
||||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
payload["enabled"] = true
|
|
||||||
return payload, nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,321 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/cookiejar"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"containr/internal/apwhy/config"
|
|
||||||
"containr/internal/apwhy/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
type apiErrorPayload struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type apiResponsePayload struct {
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
Data any `json:"data"`
|
|
||||||
Error apiErrorPayload `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestServer(t *testing.T) (*httptest.Server, *http.Client) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
cfg := config.Load()
|
|
||||||
cfg.SQLitePath = ":memory:"
|
|
||||||
cfg.CookieSecure = false
|
|
||||||
|
|
||||||
store, err := storage.Open(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to open test storage: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
server := NewServer(store, cfg)
|
|
||||||
ts := httptest.NewServer(server.Handler())
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
ts.Close()
|
|
||||||
_ = store.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
jar, err := cookiejar.New(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create cookie jar: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ts, &http.Client{Jar: jar}
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestJSON(t *testing.T, client *http.Client, method string, url string, body string) (int, apiResponsePayload) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var bodyReader *bytes.Reader
|
|
||||||
if body == "" {
|
|
||||||
bodyReader = bytes.NewReader(nil)
|
|
||||||
} else {
|
|
||||||
bodyReader = bytes.NewReader([]byte(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(method, url, bodyReader)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
if body != "" {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var payload apiResponsePayload
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
||||||
t.Fatalf("failed to decode response body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.StatusCode, payload
|
|
||||||
}
|
|
||||||
|
|
||||||
func bootstrapAndLoginOwner(t *testing.T, client *http.Client, baseURL string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
status, payload := requestJSON(
|
|
||||||
t,
|
|
||||||
client,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/bootstrap/register-owner",
|
|
||||||
`{"email":"[email protected]","password":"owner-pass-123"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusCreated || !payload.OK {
|
|
||||||
t.Fatalf("owner bootstrap failed: status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
status, payload = requestJSON(
|
|
||||||
t,
|
|
||||||
client,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/auth/login",
|
|
||||||
`{"email":"[email protected]","password":"owner-pass-123"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("owner login failed: status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerBootstrapLoginAndProtectedRoutes(t *testing.T) {
|
|
||||||
ts, authClient := newTestServer(t)
|
|
||||||
baseURL := ts.URL
|
|
||||||
|
|
||||||
// Bootstrap should be open before any user exists.
|
|
||||||
status, payload := requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("unexpected bootstrap status response: status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
bootstrapData, ok := payload.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
if hasUsers, _ := bootstrapData["hasUsers"].(bool); hasUsers {
|
|
||||||
t.Fatalf("expected hasUsers=false before owner bootstrap")
|
|
||||||
}
|
|
||||||
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); !registrationOpen {
|
|
||||||
t.Fatalf("expected registrationOpen=true before owner bootstrap")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protected route without auth should fail.
|
|
||||||
anonClient := &http.Client{}
|
|
||||||
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
|
||||||
if status != http.StatusUnauthorized || payload.OK {
|
|
||||||
t.Fatalf("expected unauthorized /auth/me response, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register owner and login.
|
|
||||||
bootstrapAndLoginOwner(t, authClient, baseURL)
|
|
||||||
|
|
||||||
// Bootstrap should close once owner exists.
|
|
||||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("unexpected bootstrap status after owner creation: status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
bootstrapData, ok = payload.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
if hasUsers, _ := bootstrapData["hasUsers"].(bool); !hasUsers {
|
|
||||||
t.Fatalf("expected hasUsers=true after owner bootstrap")
|
|
||||||
}
|
|
||||||
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); registrationOpen {
|
|
||||||
t.Fatalf("expected registrationOpen=false after owner bootstrap")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Owner session cookie should authorize /auth/me.
|
|
||||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("expected successful /auth/me response, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
meData, ok := payload.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected object payload for auth/me, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
if email, _ := meData["email"].(string); email != "[email protected]" {
|
|
||||||
t.Fatalf("unexpected /auth/me email: %q", email)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout should revoke auth session.
|
|
||||||
status, payload = requestJSON(t, authClient, http.MethodPost, baseURL+"/api/v1/auth/logout", "")
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("expected successful logout response, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
|
|
||||||
if status != http.StatusUnauthorized || payload.OK {
|
|
||||||
t.Fatalf("expected unauthorized /auth/me after logout, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerServicesCreateValidationRules(t *testing.T) {
|
|
||||||
ts, client := newTestServer(t)
|
|
||||||
baseURL := ts.URL
|
|
||||||
|
|
||||||
bootstrapAndLoginOwner(t, client, baseURL)
|
|
||||||
|
|
||||||
// Route prefix under /api must be rejected.
|
|
||||||
status, payload := requestJSON(
|
|
||||||
t,
|
|
||||||
client,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/services",
|
|
||||||
`{"name":"Bad Service","upstreamUrl":"http://example.com","routePrefix":"/api/conflict"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusBadRequest || payload.OK {
|
|
||||||
t.Fatalf("expected route conflict validation failure, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
if payload.Error.Code != "ROUTE_CONFLICT" {
|
|
||||||
t.Fatalf("expected ROUTE_CONFLICT error code, got %q", payload.Error.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid service should be created successfully.
|
|
||||||
status, payload = requestJSON(
|
|
||||||
t,
|
|
||||||
client,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/services",
|
|
||||||
`{"name":"Web Frontend","upstreamUrl":"http://example.com","routePrefix":"/web"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusCreated || !payload.OK {
|
|
||||||
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
createData, ok := payload.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected object payload for service creation, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
if _, ok := createData["id"].(string); !ok {
|
|
||||||
t.Fatalf("expected created service id in response")
|
|
||||||
}
|
|
||||||
if slug, _ := createData["slug"].(string); !strings.Contains(slug, "web-frontend") {
|
|
||||||
t.Fatalf("expected normalized service slug, got %q", slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List should contain the newly created service.
|
|
||||||
status, payload = requestJSON(t, client, http.MethodGet, baseURL+"/api/v1/services", "")
|
|
||||||
if status != http.StatusOK || !payload.OK {
|
|
||||||
t.Fatalf("expected successful services list response, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
rows, ok := payload.Data.([]any)
|
|
||||||
if !ok || len(rows) == 0 {
|
|
||||||
t.Fatalf("expected non-empty services list, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerProxyWithAPIKey(t *testing.T) {
|
|
||||||
ts, authClient := newTestServer(t)
|
|
||||||
baseURL := ts.URL
|
|
||||||
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Header.Get("x-api-key") != "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
_, _ = io.WriteString(w, "unexpected-api-key-header")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(w, "ok:"+r.URL.Path+"?"+r.URL.RawQuery)
|
|
||||||
}))
|
|
||||||
t.Cleanup(upstream.Close)
|
|
||||||
|
|
||||||
bootstrapAndLoginOwner(t, authClient, baseURL)
|
|
||||||
|
|
||||||
// Create service exposed at /web/* and proxied to the upstream test server.
|
|
||||||
status, payload := requestJSON(
|
|
||||||
t,
|
|
||||||
authClient,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/services",
|
|
||||||
`{"name":"Web Proxy","upstreamUrl":"`+upstream.URL+`","routePrefix":"/web"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusCreated || !payload.OK {
|
|
||||||
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mint API key for gateway access.
|
|
||||||
status, payload = requestJSON(
|
|
||||||
t,
|
|
||||||
authClient,
|
|
||||||
http.MethodPost,
|
|
||||||
baseURL+"/api/v1/keys",
|
|
||||||
`{"name":"integration-key","plan":"free"}`,
|
|
||||||
)
|
|
||||||
if status != http.StatusCreated || !payload.OK {
|
|
||||||
t.Fatalf("expected successful key creation, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
keyData, ok := payload.Data.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected object payload for key creation, got %#v", payload.Data)
|
|
||||||
}
|
|
||||||
rawKey, _ := keyData["key"].(string)
|
|
||||||
if strings.TrimSpace(rawKey) == "" {
|
|
||||||
t.Fatalf("expected non-empty raw API key")
|
|
||||||
}
|
|
||||||
|
|
||||||
// No API key should be rejected.
|
|
||||||
anonClient := &http.Client{}
|
|
||||||
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/web/ping?x=1", "")
|
|
||||||
if status != http.StatusUnauthorized || payload.OK {
|
|
||||||
t.Fatalf("expected unauthorized proxy request without key, got status=%d payload=%+v", status, payload)
|
|
||||||
}
|
|
||||||
if payload.Error.Code != "API_KEY_MISSING" {
|
|
||||||
t.Fatalf("expected API_KEY_MISSING error code, got %q", payload.Error.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid API key should proxy request and strip the key before forwarding.
|
|
||||||
req, err := http.NewRequest(http.MethodGet, baseURL+"/web/ping?x=1", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create proxy request: %v", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("x-api-key", rawKey)
|
|
||||||
|
|
||||||
resp, err := anonClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("proxy request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to read proxy response body: %v", err)
|
|
||||||
}
|
|
||||||
body := string(bodyBytes)
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected proxy status 200, got %d body=%q", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
if body != "ok:/ping?x=1" {
|
|
||||||
t.Fatalf("unexpected proxy body: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Loaded 100 of 738 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user