Merge pull request #1 from Dvorinka/feat/platform-overhaul
CI / Infra (Compose + Scripts) (push) Failing after 5s
CI / Backend (Go) (push) Successful in 1m2s
CI / Frontend (React) (push) Failing after 5m14s
CI / Compose boot smoke (push) Failing after 5s
CI / Publish images to GHCR (push) Skipped

feat: platform overhaul — databases, cron, backups, previews, scaling, HA, security center
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-17 20:49:29 +02:00
commit 8bf73e910a
738 files changed
+30583 -103390

No files matched your search

-118
View File
@@ -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=
+69
View File
@@ -75,3 +75,72 @@ jobs:
- name: 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
View File
@@ -13,7 +13,7 @@ build/
*.tsbuildinfo
# Keep internal build system code
!internal/build/
!**/internal/build/
# Environment variables
.env
@@ -21,6 +21,11 @@ build/
.env.development.local
.env.test.local
.env.production.local
.env.prod
.env.production
.env.*.local
!.env.example
!.env.production.example
# Logs
logs/
@@ -69,7 +74,7 @@ jspm_packages/
# Gatsby files
.cache/
public
/public/
# Storybook build outputs
.out
@@ -109,4 +114,10 @@ test-results.xml
.opencode
.claude
.desloppify
bin
bin
# Tool session dirs
.jj/
.playwright-cli/
# Local reference checkouts (not project code)
ref/
-47
View File
@@ -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
View File
@@ -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
```
+69
View File
@@ -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.
-54
View File
@@ -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"]
-53
View File
@@ -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;"]
+21
View File
@@ -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.
+78 -309
View File
@@ -1,354 +1,123 @@
<p align="center">
<img src="./containr.svg" alt="Containr Logo" width="200">
<img src="./containr.svg" alt="Containr" width="120">
</p>
<h1 align="center">
Containr - Modern Container Management Platform
</h1>
<h1 align="center">Containr</h1>
<p align="center">
🚧 <strong>Work in Progress</strong> - Not yet fully tested
</p>
<p align="center">
Deploy, manage, and scale containers with built-in Cloudflare tunnel support.
Self-hosted platform for deploying and managing containerized services.
Railway-style project canvases on your own hardware.
</p>
<p align="center">
<a href="#quick-start">Quick Start</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="#features">Features</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="#tech-stack">Tech Stack</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="#documentation">Documentation</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="#contributing">Contributing</a>
<a href="#quick-start">Quick Start</a> •
<a href="#features">Features</a> •
<a href="#architecture">Architecture</a> •
<a href="docs/guides/">Guides</a> •
<a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<img src="./docs/archive/research/scorecard.png" alt="Code Quality Scorecard" width="100%">
</p>
## What is Containr?
> **🚀 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
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.
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.
The UI is inspired by Railway's project canvas: services appear as nodes,
connections are inferred automatically, and everything — logs, metrics,
deployments, builds — is one click away.
## Features
### Core Functionality
- **Container Management**: Deploy, start, stop, and restart containers with ease
- **Service Orchestration**: Manage multi-container applications with Docker Compose integration
- **Automatic SSL**: Built-in Let's Encrypt certificate management with Traefik
- **Cloudflare Tunnel**: Zero-config deployment with automatic public URLs
- **Web Dashboard**: Modern React-based interface for container management
- **Real-time Logs**: View and search container logs in real-time
- **Project canvas** — visual service topology with groups, drag/drop layout,
and auto-inferred connections
- **Deployments** — Docker-based deploys with history, logs, and rollback
- **Git integration** — deploy from GitHub, GitLab, Bitbucket, or Gitea;
repository and branch pickers, webhooks, GitHub App support
- **Builds** — build pipeline with live status over WebSocket
- **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
- **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
## Architecture
### Deployment & Infrastructure
- **Multi-environment Support**: Development, staging, and production configurations
- **Load Balancing**: Intelligent traffic distribution with Traefik
- **Service Discovery**: Automatic service registration and discovery
- **Network Management**: Isolated networks and secure inter-service communication
- **Volume Management**: Persistent storage with automated backups
- **CI/CD Integration**: GitHub Actions and GitLab CI integration
```
app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow
app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar
infra/ Self-hosted compose: Traefik + Cloudflare Tunnel
docs/ Guides, OpenAPI spec, design documents
```
### Security & Compliance
- **Authentication**: JWT-based authentication with OAuth2 support
- **Role-based Access Control**: Granular permissions for users and teams
- **Audit Logging**: Comprehensive audit trails for all actions
- **Data Encryption**: Encrypted storage for sensitive data
- **Security Scanning**: Automated vulnerability assessment
- **Compliance Reporting**: GDPR and SOC2 compliance frameworks
## 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
| Component | Default port |
| ------------ | ------------ |
| Frontend | 3000 (prod) / 5173 (dev) |
| API | 8082 |
| Auth sidecar | 3001 |
| PostgreSQL | 5432 |
| Redis | 6379 |
## Quick Start
### Prerequisites
- 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
### Docker Compose (everything)
```bash
# Frontend -> Vercel
cd app/frontend
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
cp .env.example .env # fill in secrets
docker compose up -d
```
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
./start-unified.sh dev # Start local full stack
./start-unified.sh prod # Start self-hosted production stack from infra/
./start-unified.sh cloudflare # Start self-hosted production stack with cloudflared
./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
docker compose up -d postgres redis # data services only
cd app/backend && go run ./cmd/server # API + embedded auth sidecar
cd app/frontend && npm install && npm run dev
```
## Development
Frontend dev server at `http://localhost:5173`.
### Project Structure
```
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
```
### Self-hosted with Traefik + Cloudflare Tunnel
### Local Development
```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
See `infra/docker-compose.yml` and
[docs/guides/CLOUDFLARE_SETUP.md](docs/guides/CLOUDFLARE_SETUP.md).
## 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
# Domain Configuration (optional if using Cloudflare)
DOMAIN=yourdomain.com
[email protected]
## Documentation
# Database Configuration
POSTGRES_DB=containr
POSTGRES_USER=containr_user
POSTGRES_PASSWORD=your_secure_postgres_password
MAX_CONNECTIONS=25
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
```
- [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling
- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract
- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding
- [docs/design/](docs/design/) — UI design references
- `make help` — common tasks
## 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.
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!
See [SECURITY.md](SECURITY.md). Report vulnerabilities privately — do not
open public issues.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 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.
[MIT](LICENSE) © 2026 Tomas Dvorak
+492
View File
@@ -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
View File
@@ -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`).
-1
View File
@@ -32,7 +32,6 @@ RUN addgroup -g 1001 -S appgroup && \
WORKDIR /app
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=auth-builder /auth ./auth
+4 -5
View File
@@ -170,8 +170,7 @@ internal/
├── config/ - Configuration management
├── database/ - Database connections and migrations
└── middleware/ - HTTP middleware
migrations/ - SQL migration files
migrations_goose/ - Goose-managed migrations for new schema changes
migrations_goose/ - Goose-managed migrations (single migration path)
```
### Adding New Endpoints
@@ -179,9 +178,9 @@ migrations_goose/ - Goose-managed migrations for new schema changes
1. Create handler functions in `internal/api/`
2. Add routes in `internal/api/routes.go`
3. Update database schema:
- Legacy chain lives in `migrations/` and is executed first.
- New migrations must be added to `migrations_goose/` using goose format (`-- +goose Up/Down`).
- Keep legacy files immutable after they are shipped.
- All migrations live in `migrations_goose/` using goose format (`-- +goose Up/Down`).
- `20260401163000_baseline.sql` carries the consolidated schema; append new files after it.
- Never edit the baseline in place; always add a new migration file.
### SQLC (Type-Safe Queries)
+16 -183
View File
@@ -1,6 +1,4 @@
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 { PostgresDialect } from 'kysely';
import { getMigrations } from 'better-auth/db/migration';
@@ -50,41 +48,22 @@ const database = new PostgresDialect({
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
const gitlabClientId = env.GITLAB_CLIENT_ID?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_ID';
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 googleClientId = env.GOOGLE_CLIENT_ID?.trim() || '';
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || '';
const socialProviders = {};
if (githubClientId && githubClientSecret) {
socialProviders.github = {
clientId: githubClientId,
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:8082',
'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())
.filter(Boolean);
@@ -130,158 +115,6 @@ export const auth = betterAuth({
maxPasswordLength: 128,
},
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() {
+566
View File
@@ -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
}
-29
View File
@@ -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)
}
}
+4 -19
View File
@@ -25,11 +25,6 @@ func main() {
}
defer db.Close()
legacyDir := os.Getenv("LEGACY_MIGRATIONS_DIR")
if legacyDir == "" {
legacyDir = "migrations"
}
gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR")
if gooseDir == "" {
gooseDir = "migrations_goose"
@@ -41,24 +36,14 @@ func main() {
}
switch command {
case "up":
case "up", "goose-up":
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()
log.Fatalf("Migration failed: %v", err)
}
migrationCancel()
log.Println("Legacy + goose 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")
log.Println("Migrations completed successfully")
case "goose-status":
if err := db.GooseStatus(gooseDir); err != nil {
log.Fatalf("Goose status failed: %v", err)
@@ -73,6 +58,6 @@ func main() {
}
log.Printf("Created goose migration %q in %q", name, gooseDir)
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)
}
}
+3 -3
View File
@@ -39,7 +39,7 @@ func main() {
// Run startup migrations unless explicitly disabled.
if cfg.AutoMigrate {
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()
log.Fatalf("Failed to run database migrations: %v", err)
}
@@ -48,8 +48,8 @@ func main() {
log.Println("AUTO_MIGRATE disabled; skipping startup migrations")
}
// Seed demo data in development (or when explicitly requested).
if cfg.IsDevelopment() || cfg.SeedDataOnStart {
// Seed demo data only when explicitly requested.
if cfg.SeedDataOnStart {
if err := db.SeedData(); err != nil {
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!")
}
-32
View File
@@ -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
View File
@@ -8,11 +8,9 @@ require (
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.30.1
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/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.1
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.72
github.com/pressly/goose/v3 v3.24.1
@@ -22,13 +20,9 @@ require (
github.com/sqlc-dev/pqtype v0.3.0
github.com/stretchr/testify v1.11.1
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 (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/sonic v1.9.1 // 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/distribution/reference v0.6.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/fsnotify/fsnotify v1.9.0 // 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/goccy/go-json v0.10.2 // 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/klauspost/cpuid/v2 v2.2.4 // 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/reflect2 v1.0.2 // 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/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // 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/sethvargo/go-retry v0.3.0 // 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.yaml.in/yaml/v3 v3.0.4 // 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/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
@@ -106,7 +92,5 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.0 // indirect
)
+2 -40
View File
@@ -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/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
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-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-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/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/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/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
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/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/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=
-74
View File
@@ -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
+128 -72
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strings"
@@ -13,21 +14,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
// Constants for validation and limits
// Default quota limits applied when the request omits them.
const (
MaxServiceNameLength = 100
MaxRoutePrefixLength = 200
MaxUpstreamURLLength = 500
MaxAPIKeyNameLength = 100
MinAPIKeyLength = 20
MaxAPIKeyLength = 100
DefaultRPMLimit = 60
DefaultMonthlyQuota = 1000
MaxRPMLimit = 10000
MaxMonthlyQuota = 10000000
DefaultRPMLimit = 60
DefaultMonthlyQuota = 1000
)
// Validator instance for request validation
@@ -75,15 +69,6 @@ type APIKeyRequest struct {
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
func generateAPIKey() (string, error) {
bytes := make([]byte, 32)
@@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error {
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
func handleAPwhyServicesList(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
@@ -170,7 +233,7 @@ func handleAPwhyServicesList(c *gin.Context) {
// Query services from database
rows, err := db.QueryContext(ctx, `
SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at
FROM api_services
FROM api_services
ORDER BY created_at DESC
`)
if err != nil {
@@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) {
}
defer rows.Close()
var services []map[string]interface{}
services := make([]map[string]interface{}, 0)
for rows.Next() {
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)
if err != nil {
continue // Skip malformed rows
@@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) {
"slug": slug,
"upstreamUrl": upstreamURL,
"routePrefix": routePrefix,
"enabled": enabled,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
})
@@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
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, " ", "-"))
// 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
if req.RPMLimit != nil {
rpmLimit = *req.RPMLimit
@@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) {
monthlyQuota = DefaultMonthlyQuota
}
_, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL,
req.RoutePrefix, rpmLimit, monthlyQuota)
// id defaults to gen_random_uuid(); enabled defaults to 1.
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 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",
"Failed to create service", err.Error())
return
@@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, serviceID)
enabled, serviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) {
}
defer rows.Close()
var keys []map[string]interface{}
keys := make([]map[string]interface{}, 0)
for rows.Next() {
var id, name, keyPrefix, plan, createdAt, updatedAt string
var enabled bool
var enabled int
var rpmLimit, monthlyQuota sql.NullInt64
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
@@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) {
"name": name,
"keyPrefix": keyPrefix,
"plan": plan,
"enabled": enabled,
"enabled": enabled != 0,
"createdAt": createdAt,
"updatedAt": updatedAt,
}
@@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) {
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]
// Set default limits based on plan
var rpmLimit, monthlyQuota int
switch input.Plan {
case "free":
rpmLimit, monthlyQuota = 60, 1000
case "pro":
rpmLimit, monthlyQuota = 600, 50000
case "business":
rpmLimit, monthlyQuota = 3000, 300000
default:
rpmLimit, monthlyQuota = 60, 1000
plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan)
if err != nil {
sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
"Invalid plan", err.Error())
return
}
// Insert key into database
_, err = db.ExecContext(context.Background(), `
// id defaults to gen_random_uuid(); enabled defaults to 1.
var id string
err = db.QueryRowContext(context.Background(), `
INSERT INTO api_keys (
id, name, key_hash, key_prefix, plan, allowed_service_ids,
enabled, rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW())
`, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota)
name, key_hash, key_prefix, plan, allowed_service_ids,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
RETURNING id
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
"data": gin.H{
"id": id,
"name": input.Name,
"plan": input.Plan,
"plan": plan,
"key": apiKey, // Only return the actual key once
"keyPrefix": keyPrefix,
"enabled": true,
@@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
if input.Enabled != nil {
enabled := 0
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(),
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, keyID)
enabled, keyID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
+42 -19
View File
@@ -93,10 +93,11 @@ func SetAuditDB(db *database.DB) {
func handleGetAuditLogs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
resource := strings.TrimSpace(c.Query("resource"))
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)
limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50)
if limit > 500 {
@@ -104,35 +105,57 @@ func handleGetAuditLogs(c *gin.Context) {
}
offset := (page - 1) * limit
conditions := []string{"user_id::text = $1"}
args := []interface{}{userID}
nextArg := 2
conditions := []string{"TRUE"}
args := []interface{}{}
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 != "" {
conditions = append(conditions, fmt.Sprintf("resource = $%d", nextArg))
conditions = append(conditions, fmt.Sprintf("a.resource = $%d", nextArg))
args = append(args, resource)
nextArg++
}
if action != "" {
conditions = append(conditions, fmt.Sprintf("action = $%d", nextArg))
conditions = append(conditions, fmt.Sprintf("a.action = $%d", nextArg))
args = append(args, action)
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 ")
query := fmt.Sprintf(`SELECT
id,
COALESCE(user_id::text, ''),
resource,
COALESCE(resource_id::text, ''),
action,
COALESCE(details::text, '{}'),
COALESCE(ip_address::text, ''),
COALESCE(user_agent, ''),
created_at
FROM audit_logs
a.id,
COALESCE(a.user_id::text, ''),
COALESCE(u.email, ''),
a.resource,
COALESCE(a.resource_id::text, ''),
a.action,
COALESCE(a.details::text, '{}'),
COALESCE(a.ip_address::text, ''),
COALESCE(a.user_agent, ''),
a.created_at
FROM audit_logs a
LEFT JOIN users u ON u.id = a.user_id
WHERE %s
ORDER BY created_at DESC
ORDER BY a.created_at DESC
LIMIT $%d OFFSET $%d`, whereClause, nextArg, nextArg+1)
args = append(args, limit, offset)
@@ -146,7 +169,7 @@ func handleGetAuditLogs(c *gin.Context) {
var logs []AuditLog
for rows.Next() {
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 {
continue
}
+328 -12
View File
@@ -1,13 +1,21 @@
package api
import (
"bytes"
"containr/internal/database"
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
@@ -18,10 +26,16 @@ type LoginRequest struct {
type RegisterRequest struct {
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"`
}
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 {
Token string `json:"token"`
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)
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// Check password
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
} else {
// Check password
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"})
return
}
}
}
// 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) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -91,9 +264,18 @@ func handleRegister(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
jwtSecret := c.MustGet("jwt_secret").(string)
// Check if user already exists
var count int
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&count)
count, err := countLocalUsers(db)
if err != nil {
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
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) {
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
+35 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"containr/internal/database"
"log"
"net/http"
"net/http/httputil"
@@ -12,7 +13,7 @@ import (
"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)
if err != nil {
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) {
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)
c.Abort()
}
@@ -36,6 +43,33 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
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) {
trimmed := strings.TrimSpace(rawTarget)
if trimmed == "" {
+200 -23
View File
@@ -2,12 +2,19 @@ package api
import (
"containr/internal/database"
"encoding/json"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"context"
"database/sql"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/robfig/cron/v3"
)
type CronJob struct {
@@ -62,6 +69,7 @@ func handleGetCronJobs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
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,
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}
if projectID != "" {
query += " AND cj.project_id = $2"
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"
@@ -130,7 +142,11 @@ func handleCreateCronJob(c *gin.Context) {
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{
ID: uuid.New().String(),
@@ -227,8 +243,13 @@ func handleUpdateCronJob(c *gin.Context) {
updates["name"] = req.Name
}
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["next_run_at"] = calculateNextRun(req.Schedule, "UTC")
updates["next_run_at"] = next
}
if req.Command != "" {
updates["command"] = req.Command
@@ -352,11 +373,12 @@ func handleTriggerCronJob(c *gin.Context) {
var job CronJob
var ownerCheck string
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
WHERE cj.id = $1`,
jobID,
).Scan(&job.Command, &ownerCheck)
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
@@ -372,7 +394,9 @@ func handleTriggerCronJob(c *gin.Context) {
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{}{
"execution_id": execID,
@@ -384,33 +408,186 @@ func handleTriggerCronJob(c *gin.Context) {
})
}
func calculateNextRun(schedule, timezone string) *time.Time {
now := time.Now()
next := now.Add(1 * time.Hour)
return &next
// cronScheduleParser parses standard 5-field cron expressions plus
// descriptors (@daily etc.) and CRON_TZ= prefixes.
var cronScheduleParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
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) {
db := auditDB
if db == nil {
// StartCronScheduler ticks every minute and executes enabled cron jobs whose
// next_run_at is due. Each run is recorded in cron_executions and output is
// 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
}
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()
db.Exec(
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3 WHERE id = $4`,
now, "success", "Job completed successfully", execID,
`UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5`,
now, status, output, errText, execID,
)
next, _ := calculateNextRun(schedule, timezone)
db.Exec(
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, next_run_at = $3 WHERE id = $4`,
now, "success", time.Now().Add(1*time.Hour), jobID,
`UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5`,
now, status, output, next, jobID,
)
}
func init() {
cronJobsData, _ := json.Marshal([]CronJob{})
_ = cronJobsData
if retention > 0 {
db.Exec(
`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,
)
}
}
+194 -75
View File
@@ -1,6 +1,7 @@
package api
import (
"archive/tar"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"context"
@@ -11,6 +12,7 @@ import (
"errors"
"fmt"
"io"
"log"
"math"
"net"
"net/http"
@@ -38,19 +40,21 @@ const (
// DatabaseService represents a managed database service
type DatabaseService struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
Status string `json:"status" db:"status"` // running, stopped, building, error
Version string `json:"version" db:"version"`
Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business
Region string `json:"region" db:"region"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ConnectionURL string `json:"connection_url"`
Metrics DatabaseMetrics `json:"metrics"`
Backups DatabaseBackupConfig `json:"backups"`
Settings DatabaseSettings `json:"settings"`
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
Status string `json:"status" db:"status"` // running, stopped, building, error
Version string `json:"version" db:"version"`
Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business
Region string `json:"region" db:"region"`
BackupSchedule string `json:"backup_schedule,omitempty"`
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ConnectionURL string `json:"connection_url"`
Metrics DatabaseMetrics `json:"metrics"`
Backups DatabaseBackupConfig `json:"backups"`
Settings DatabaseSettings `json:"settings"`
}
// DatabaseMetrics represents database performance metrics
@@ -100,8 +104,9 @@ type DatabaseCreateRequest struct {
// DatabaseUpdateRequest represents a request to update a database
type DatabaseUpdateRequest struct {
Name string `json:"name,omitempty"`
Plan string `json:"plan,omitempty"`
Name string `json:"name,omitempty"`
Plan string `json:"plan,omitempty"`
BackupSchedule *string `json:"backup_schedule,omitempty"`
}
// 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.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
} else {
db.Backups = h.generateMockBackupConfig()
@@ -218,7 +223,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
db = h.reconcileManagedDatabaseState(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
} else {
db.Backups = h.generateMockBackupConfig()
@@ -362,40 +367,67 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
req.Name = strings.TrimSpace(req.Name)
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"})
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 req.BackupSchedule != nil {
var schedule sql.NullString
var nextBackup sql.NullTime
if expr := strings.TrimSpace(*req.BackupSchedule); expr != "" {
next, err := calculateNextRun(expr, "UTC")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cron schedule"})
return
}
schedule = sql.NullString{String: expr, Valid: true}
nextBackup = sql.NullTime{Time: *next, Valid: true}
}
err := h.queries.SetDatabaseBackupScheduleByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupScheduleByIDAndUserParams{
BackupSchedule: schedule,
NextBackupAt: nextBackup,
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
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"})
return
if req.Name != "" || req.Plan != "" {
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"})
@@ -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 {
if h.dockerClient == nil {
return h.generateMockMetrics()
@@ -756,9 +869,9 @@ func roundFloat(value float64, places int) float64 {
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{
DatabaseID: databaseID,
DatabaseID: db.ID,
UserID: userID,
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{
Enabled: true,
Enabled: db.BackupSchedule != "",
LastBackup: lastBackup,
Retention: 30,
NextBackup: nextBackup,
NextBackup: db.NextBackupAt,
Backups: backups,
}, nil
}
@@ -933,31 +1040,35 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
return DatabaseService{
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
}
}
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
return DatabaseService{
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
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{}
}
func databaseNullTimePtr(value sql.NullTime) *time.Time {
if value.Valid {
t := value.Time
return &t
}
return nil
}
func databaseNullString(value sql.NullString) string {
if value.Valid {
return value.String
+16 -8
View File
@@ -4,6 +4,7 @@ import (
"containr/internal/database"
"containr/internal/deployment"
"context"
"fmt"
"net/http"
"strings"
"time"
@@ -198,10 +199,10 @@ func handleCreateDeployment(c *gin.Context) {
}
_, err = db.(*database.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,
`INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
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 {
@@ -368,12 +369,19 @@ func runDeploymentAndSync(
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
time.Now(), service.ID,
)
insertUserNotification(db, userID, "deployment", "Deployment succeeded",
fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String())
return
case "failed":
_, _ = db.Exec(
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
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
}
}
@@ -525,10 +533,10 @@ func handleRollbackDeployment(c *gin.Context) {
}
_, err = db.(*database.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)`,
rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status,
`INSERT INTO deployments
(id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
rollback.ID, rollback.ServiceID, fmt.Sprintf("rollback-%d", now.Unix()), rollback.CommitHash, rollback.Status,
rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt,
)
+79
View File
@@ -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,
})
}
+205
View File
@@ -1306,3 +1306,208 @@ func generateWebhookSecret() (string, error) {
}
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
}
+214
View File
@@ -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)
}
})
}
}
+17 -2
View File
@@ -64,11 +64,15 @@ func handleGetLogs(c *gin.Context) {
dockerClient, exists := c.Get("docker_client")
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
}
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)
logOpts := docker.LogOptions{
@@ -82,6 +86,10 @@ func handleGetLogs(c *gin.Context) {
ctx := context.Background()
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
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.")
return
}
@@ -156,6 +164,13 @@ func handleGetLogs(c *gin.Context) {
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) {
db, exists := c.Get("db")
if !exists {
+207
View File
@@ -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
}
+162
View File
@@ -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"})
}
+15 -1
View File
@@ -1,6 +1,10 @@
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.
func firstPathParam(c *gin.Context, names ...string) string {
@@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string {
}
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
var service Service
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,
).Scan(&service.ID, &service.Name, &service.Type)
@@ -586,10 +586,11 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
}
deploymentID := uuid.New()
promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix())
_, err = db.(*database.DB).Exec(
`INSERT INTO deployments (id, service_id, status, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())`,
deploymentID, env.ServiceID, "pending",
`INSERT INTO deployments (id, service_id, version, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())`,
deploymentID, env.ServiceID, promotionVersion, "pending",
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"})
+52 -47
View File
@@ -17,13 +17,11 @@ import (
"containr/internal/scaling"
"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) {
// 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)
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)
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)
haAPIManager := NewHAManager(haManager)
// Initialize scaling handler
scalingHandler := NewScalingHandler(autoScaler)
// Initialize GORM for agent system
gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{})
if err != nil {
panic("Failed to initialize GORM: " + err.Error())
}
// Initialize agent handler (sqlc-backed)
agentHandler := NewNodeAgentHandler(db)
// Initialize agent handler
agentHandler := NewNodeAgentHandler(gormDB)
// Start the cron scheduler — executes due cron_jobs via docker exec.
StartCronScheduler(context.Background(), db, dockerClient)
// Initialize database handler
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("ha_manager", haManager)
c.Set("scaling_handler", scalingHandler)
c.Set("gorm_db", gormDB)
c.Next()
})
@@ -158,11 +158,19 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
router.HEAD("/ready", healthHandler)
// 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")
{
// Public routes (no authentication required)
public := v1.Group("/")
{
public.GET("/auth/bootstrap", handleAuthBootstrap)
public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister)
}
@@ -174,6 +182,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// User routes
protected.GET("/user/profile", handleGetProfile)
protected.PUT("/user/profile", handleUpdateProfile)
protected.POST("/users", handleCreateUser)
// Project routes
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.PUT("/services/:id", handleUpdateService)
protected.DELETE("/services/:id", handleDeleteService)
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
// Deployment routes
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("/deployments/:id/logs", handleGetDeploymentLogs)
// One-off exec console (docker exec, 30s ceiling)
protected.POST("/services/:id/exec", handleExecInService)
// Git integration routes
protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
protected.GET("/git/providers", handleGetGitProviders)
protected.POST("/git/providers", handleCreateGitProvider)
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
protected.POST("/git/repositories/connect", handleConnectGitRepository)
protected.GET("/git/repositories", handleGetConnectedRepositories)
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.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
scalingHandler.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/backup", databaseHandler.CreateBackup)
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
api := router.Group("/api")
api.Use(middleware.Auth(cfg.JWTSecret))
agentHandler.SetupRoutes(api)
agentHandler.SetupRoutes(protected)
// Preview Environments routes
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
protected.GET("/audit-logs", handleGetAuditLogs)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
}
// APwhy Gateway routes
apwhy := router.Group("/api/v1")
{
// Health check (no auth required)
apwhy.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"ok": true,
"data": gin.H{
"status": "ok",
"name": "Containr + APwhy",
"database": "postgresql",
"generatedAt": time.Now().UTC().Format(time.RFC3339),
},
})
})
}
// API Gateway routes (merged APwhy) - namespaced to avoid
// colliding with Containr's own /services paths.
gateway := protected.Group("/gateway")
{
gateway.GET("/services", handleAPwhyServicesList)
gateway.POST("/services", handleAPwhyServicesCreate)
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
// Protected APwhy routes (authentication required)
protectedAPwhy := router.Group("/api/v1")
protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret))
{
// Service management
protectedAPwhy.GET("/services", handleAPwhyServicesList)
protectedAPwhy.POST("/services", handleAPwhyServicesCreate)
protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/keys", handleAPwhyKeysList)
gateway.POST("/keys", handleAPwhyKeysCreate)
gateway.PATCH("/keys/:id", handleAPwhyKeysPatch)
// API Keys
protectedAPwhy.GET("/keys", handleAPwhyKeysList)
protectedAPwhy.POST("/keys", handleAPwhyKeysCreate)
protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch)
// Analytics
protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps)
protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps)
gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
}
}
}
}
+4 -4
View File
@@ -394,10 +394,10 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
}
err = sh.db.QueryRow(`
SELECT id, score, started_at as scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
SELECT id, COALESCE((summary->>'score')::int, 0), started_at as scanned_at, status
FROM security_scans
WHERE project_id = $1
ORDER BY started_at DESC
LIMIT 1
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
File diff suppressed because it is too large. Load diff
+69 -11
View File
@@ -99,8 +99,18 @@ func handleGetServices(c *gin.Context) {
// Get services for the project
rows, err := db.(*database.DB).Query(
`SELECT id, project_id, name, type, status, image, command, environment,
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at
`SELECT id, project_id, name,
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
WHERE project_id = $1
ORDER BY created_at DESC`,
@@ -228,15 +238,25 @@ func handleCreateService(c *gin.Context) {
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
_, err = db.(*database.DB).Exec(
`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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
service.ID, service.ProjectID, service.Name, service.Type, service.Status,
service.Image, service.Command, service.Environment, service.GitRepo,
service.GitBranch, service.BuildPath, service.CPU, service.Memory,
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, environmentID, service.Type,
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
"", 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,
)
@@ -248,6 +268,26 @@ func handleCreateService(c *gin.Context) {
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
func handleGetService(c *gin.Context) {
db, exists := c.Get("db")
@@ -273,8 +313,17 @@ func handleGetService(c *gin.Context) {
// Get service with project ownership check
var service Service
err = db.(*database.DB).QueryRow(
`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,
`SELECT s.id, s.project_id, s.name,
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
FROM services s
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
var existingService Service
err = db.(*database.DB).QueryRow(
`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,
`SELECT s.id, s.project_id, s.name,
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
FROM services s
JOIN projects p ON s.project_id = p.id
@@ -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
@@ -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>
+321
View File
@@ -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)
}
+25 -12
View File
@@ -279,9 +279,16 @@ func handleCreateFromTemplate(c *gin.Context) {
serviceImage := resolveTemplateRuntimeImage(config.Runtime)
serviceCommand := strings.TrimSpace(config.StartCommand)
buildCommand := strings.TrimSpace(config.BuildCommand)
cpu, memory := defaultTemplateResources(serviceType)
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()
now := time.Now()
@@ -294,18 +301,24 @@ func handleCreateFromTemplate(c *gin.Context) {
txQueries := queries.WithTx(tx)
err = txQueries.CreateServiceFromTemplate(ctx, sqlcdb.CreateServiceFromTemplateParams{
ID: serviceID,
ProjectID: projectID,
Name: req.Name,
Type: serviceType,
Status: "stopped",
Image: sql.NullString{String: serviceImage, Valid: true},
Command: sql.NullString{String: serviceCommand, Valid: true},
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},
ID: serviceID,
ProjectID: projectID,
Name: req.Name,
EnvironmentID: environmentID,
ServiceType: serviceType,
SourceType: "template",
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
Type: sql.NullString{String: serviceType, Valid: true},
Status: sql.NullString{String: "stopped", 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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
+32 -3
View File
@@ -9,6 +9,10 @@ import (
"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 {
ID uuid.UUID `json:"id" db:"id"`
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
@@ -91,7 +95,7 @@ func handleGetVariables(c *gin.Context) {
return
}
if v.IsSecret {
v.Value = "********"
v.Value = maskedSecretValue
}
variables = append(variables, v)
}
@@ -143,6 +147,25 @@ func handleUpdateVariables(c *gin.Context) {
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()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
@@ -158,11 +181,17 @@ func handleUpdateVariables(c *gin.Context) {
now := time.Now()
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()
_, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
@@ -198,7 +227,7 @@ func handleUpdateVariables(c *gin.Context) {
continue
}
if v.IsSecret {
v.Value = "********"
v.Value = maskedSecretValue
}
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)
}
}
Loaded 100 of 738 files, more files were not shown because too many files have changed in this diff. Show more