Initial commit with server startup infrastructure

Core infrastructure:
- Uber fx dependency injection
- Chi router with middleware stack
- SQLite database with embedded migrations
- Embedded templates and static assets
- Structured logging with slog

Features implemented:
- Authentication (login, logout, session management, argon2id hashing)
- App management (create, edit, delete, list)
- Deployment pipeline (clone, build, deploy, health check)
- Webhook processing for Gitea
- Notifications (ntfy, Slack)
- Environment variables, labels, volumes per app
- SSH key generation for deploy keys

Server startup:
- Server.Run() starts HTTP server on configured port
- Server.Shutdown() for graceful shutdown
- SetupRoutes() wires all handlers with chi router
This commit is contained in:
Jeffrey Paul 2025-12-29 15:46:03 +07:00
commit 3f9d83c436
59 changed files with 11707 additions and 0 deletions

31
.golangci.yml Normal file
View File

@ -0,0 +1,31 @@
version: "2"
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

1225
CONVENTIONS.md Normal file

File diff suppressed because it is too large Load Diff

39
Dockerfile Normal file
View File

@ -0,0 +1,39 @@
# Build stage
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git make gcc musl-dev
# Install golangci-lint
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
RUN go install golang.org/x/tools/cmd/goimports@latest
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Run all checks - build fails if any check fails
RUN make check
# Build the binary
RUN make build
# Runtime stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli
WORKDIR /app
COPY --from=builder /src/bin/upaasd /app/upaasd
# Create data directory
RUN mkdir -p /data
ENV UPAAS_DATA_DIR=/data
ENV UPAAS_PORT=8080
EXPOSE 8080
ENTRYPOINT ["/app/upaasd"]

37
Makefile Normal file
View File

@ -0,0 +1,37 @@
.PHONY: all build lint fmt test check clean
BINARY := upaasd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILDARCH := $(shell go env GOARCH)
LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/upaasd
lint:
golangci-lint run --config .golangci.yml ./...
fmt:
gofmt -s -w .
goimports -w .
test:
go test -v -race -cover ./...
# Check runs all validation without making changes
# Used by CI and Docker build - fails if anything is wrong
check:
@echo "==> Checking formatting..."
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
@echo "==> Running linter..."
golangci-lint run --config .golangci.yml ./...
@echo "==> Running tests..."
go test -v -race ./...
@echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/upaasd
@echo "==> All checks passed!"
clean:
rm -rf bin/

179
README.md Normal file
View File

@ -0,0 +1,179 @@
# upaas
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via Gitea webhooks.
## Features
- Single admin user with argon2id password hashing
- Per-app SSH keypairs for read-only deploy keys
- Per-app UUID-based webhook URLs for Gitea integration
- Branch filtering - only deploy on configured branch changes
- Environment variables, labels, and volume mounts per app
- Docker builds via socket access
- Notifications via ntfy and Slack-compatible webhooks
- Simple server-rendered UI with Tailwind CSS
## Non-Goals
- Multi-user support
- Complex CI pipelines
- Multiple container orchestration
- SPA/API-first design
- Support for non-Gitea webhooks
## Architecture
### Project Structure
```
upaas/
├── cmd/upaasd/ # Application entry point
├── internal/
│ ├── config/ # Configuration via Viper
│ ├── database/ # SQLite database with migrations
│ ├── docker/ # Docker client for builds/deploys
│ ├── globals/ # Build-time variables (version, etc.)
│ ├── handlers/ # HTTP request handlers
│ ├── healthcheck/ # Health status service
│ ├── logger/ # Structured logging (slog)
│ ├── middleware/ # HTTP middleware (auth, logging, CORS)
│ ├── models/ # Active Record style database models
│ ├── server/ # HTTP server and routes
│ ├── service/
│ │ ├── app/ # App management service
│ │ ├── auth/ # Authentication service
│ │ ├── deploy/ # Deployment orchestration
│ │ ├── notify/ # Notifications (ntfy, Slack)
│ │ └── webhook/ # Gitea webhook processing
│ └── ssh/ # SSH key generation
├── static/ # Embedded CSS/JS assets
└── templates/ # Embedded HTML templates
```
### Dependency Injection
Uses Uber fx for dependency injection. Components are wired in this order:
1. `globals` - Build-time variables
2. `logger` - Structured logging
3. `config` - Configuration loading
4. `database` - SQLite connection + migrations
5. `healthcheck` - Health status
6. `auth` - Authentication service
7. `app` - App management
8. `docker` - Docker client
9. `notify` - Notification service
10. `deploy` - Deployment service
11. `webhook` - Webhook processing
12. `middleware` - HTTP middleware
13. `handlers` - HTTP handlers
14. `server` - HTTP server
### Request Flow
```
HTTP Request
chi Router ──► Middleware Stack ──► Handler
(Logging, Auth, CORS, etc.)
Handler Function
Service Layer (app, auth, deploy, etc.)
Models (Active Record)
Database
```
### Key Patterns
- **Closure-based handlers**: Handlers return `http.HandlerFunc` allowing one-time initialization
- **Active Record models**: Models encapsulate database operations (`Save()`, `Delete()`, `Reload()`)
- **Async deployments**: Webhook triggers deploy via goroutine with `context.WithoutCancel()`
- **Embedded assets**: Templates and static files embedded via `//go:embed`
## Development
### Prerequisites
- Go 1.23+
- golangci-lint
- Docker (for running)
### Commands
```bash
make fmt # Format code
make lint # Run comprehensive linting
make test # Run tests with race detection
make check # Verify everything passes (lint, test, build, format)
make build # Build binary
```
### Commit Requirements
**All commits must pass `make check` before being committed.**
Before every commit:
1. **Format**: Run `make fmt` to format all code
2. **Lint**: Run `make lint` and fix all errors/warnings
- Do not disable linters or add nolint comments without good reason
- Fix the code, don't hide the problem
3. **Test**: Run `make test` and ensure all tests pass
- Fix failing tests by fixing the code, not by modifying tests to pass
- Add tests for new functionality
4. **Verify**: Run `make check` to confirm everything passes
```bash
# Standard workflow before commit:
make fmt
make lint # Fix any issues
make test # Fix any failures
make check # Final verification
git add .
git commit -m "Your message"
```
The Docker build runs `make check` and will fail if:
- Code is not formatted
- Linting errors exist
- Tests fail
- Code doesn't compile
This ensures the main branch always contains clean, tested, working code.
## Configuration
Environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `UPAAS_PORT` | HTTP listen port | 8080 |
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | ./data |
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
| `DEBUG` | Enable debug logging | false |
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
| `METRICS_USERNAME` | Basic auth for /metrics | "" |
| `METRICS_PASSWORD` | Basic auth for /metrics | "" |
## Running with Docker
```bash
docker run -d \
-p 8080:8080 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v upaas-data:/data \
upaas
```
## License
MIT

312
TODO.md Normal file
View File

@ -0,0 +1,312 @@
# UPAAS Implementation Plan
## Feature Roadmap
### Core Infrastructure
- [x] Uber fx dependency injection
- [x] Chi router integration
- [x] Structured logging (slog) with TTY detection
- [x] Configuration via Viper (env vars, config files)
- [x] SQLite database with embedded migrations
- [x] Embedded templates (html/template)
- [x] Embedded static assets (Tailwind CSS, JS)
- [ ] Server startup (`Server.Run()`)
- [ ] Graceful shutdown (`Server.Shutdown()`)
- [ ] Route wiring (`SetupRoutes()`)
### Authentication & Authorization
- [x] Single admin user model
- [x] Argon2id password hashing
- [x] Initial setup flow (create admin on first run)
- [x] Cookie-based session management (gorilla/sessions)
- [x] Session middleware for protected routes
- [x] Login/logout handlers
- [ ] API token authentication (for JSON API)
### App Management
- [x] Create apps with name, repo URL, branch, Dockerfile path
- [x] Edit app configuration
- [x] Delete apps (cascades to related entities)
- [x] List all apps on dashboard
- [x] View app details
- [x] Per-app SSH keypair generation (Ed25519)
- [x] Per-app webhook secret (UUID)
### Container Configuration
- [x] Environment variables (add, delete per app)
- [x] Docker labels (add, delete per app)
- [x] Volume mounts (add, delete per app, with read-only option)
- [x] Docker network configuration per app
- [ ] Edit existing environment variables
- [ ] Edit existing labels
- [ ] Edit existing volume mounts
- [ ] CPU/memory resource limits
### Deployment Pipeline
- [x] Manual deploy trigger from UI
- [x] Repository cloning via Docker git container
- [x] SSH key authentication for private repos
- [x] Docker image building with configurable Dockerfile
- [x] Container creation with env vars, labels, volumes
- [x] Old container removal before new deployment
- [x] Deployment status tracking (building, deploying, success, failed)
- [x] Deployment logs storage
- [x] View deployment history per app
- [ ] Container logs viewing
- [ ] Deployment rollback to previous image
- [ ] Deployment cancellation
### Manual Container Controls
- [ ] Restart container
- [ ] Stop container
- [ ] Start stopped container
### Webhook Integration
- [x] Gitea webhook endpoint (`/webhook/:secret`)
- [x] Push event parsing
- [x] Branch extraction from refs
- [x] Branch matching (only deploy configured branch)
- [x] Webhook event audit log
- [x] Automatic deployment on matching webhook
- [ ] Webhook event history UI
- [ ] GitHub webhook support
- [ ] GitLab webhook support
### Health Monitoring
- [x] Health check endpoint (`/health`)
- [x] Application uptime tracking
- [x] Docker container health status checking
- [x] Post-deployment health verification (60s delay)
- [ ] Custom health check commands per app
### Notifications
- [x] ntfy integration (HTTP POST)
- [x] Slack-compatible webhook integration
- [x] Build start/success/failure notifications
- [x] Deploy success/failure notifications
- [x] Priority mapping for notification urgency
### Observability
- [x] Request logging middleware
- [x] Request ID generation
- [x] Sentry error reporting (optional)
- [x] Prometheus metrics endpoint (optional, with basic auth)
- [ ] Structured logging for all operations
- [ ] Deployment count/duration metrics
- [ ] Container health status metrics
- [ ] Webhook event metrics
- [ ] Audit log table for user actions
### API
- [ ] JSON API (`/api/v1/*`)
- [ ] List apps endpoint
- [ ] Get app details endpoint
- [ ] Create app endpoint
- [ ] Delete app endpoint
- [ ] Trigger deploy endpoint
- [ ] List deployments endpoint
- [ ] API documentation
### UI Features
- [x] Server-rendered HTML templates
- [x] Dashboard with app list
- [x] App creation form
- [x] App detail view with all configurations
- [x] App edit form
- [x] Deployment history page
- [x] Login page
- [x] Setup page
- [ ] Container logs page
- [ ] Webhook event history page
- [ ] Settings page (webhook secret, SSH public key)
- [ ] Real-time deployment log streaming (WebSocket/SSE)
### Future Considerations
- [ ] Multi-user support with roles
- [ ] Private Docker registry authentication
- [ ] Scheduled deployments
- [ ] Backup/restore of app configurations
---
## Phase 1: Critical (Application Cannot Start)
### 1.1 Server Startup Infrastructure
- [ ] Implement `Server.Run()` in `internal/server/server.go`
- Start HTTP server with configured address/port
- Handle TLS if configured
- Block until shutdown signal received
- [ ] Implement `Server.Shutdown()` in `internal/server/server.go`
- Graceful shutdown with context timeout
- Close database connections
- Stop running containers gracefully (optional)
- [ ] Implement `SetupRoutes()` in `internal/server/routes.go`
- Wire up chi router with all handlers
- Apply middleware (logging, auth, CORS, metrics)
- Define public vs protected route groups
- Serve static assets and templates
### 1.2 Route Configuration
```
Public Routes:
GET /health
GET /setup, POST /setup
GET /login, POST /login
POST /webhook/:secret
Protected Routes (require auth):
GET /logout
GET /dashboard
GET /apps/new, POST /apps
GET /apps/:id, POST /apps/:id, DELETE /apps/:id
GET /apps/:id/edit, POST /apps/:id/edit
GET /apps/:id/deployments
GET /apps/:id/logs
POST /apps/:id/env-vars, DELETE /apps/:id/env-vars/:id
POST /apps/:id/labels, DELETE /apps/:id/labels/:id
POST /apps/:id/volumes, DELETE /apps/:id/volumes/:id
POST /apps/:id/deploy
```
## Phase 2: High Priority (Core Functionality Gaps)
### 2.1 Container Logs
- [ ] Implement `HandleAppLogs()` in `internal/handlers/app.go`
- Fetch logs via Docker API (`ContainerLogs`)
- Support tail parameter (last N lines)
- Stream logs with SSE or chunked response
- [ ] Add Docker client method `GetContainerLogs(containerID, tail int) (io.Reader, error)`
### 2.2 Manual Container Controls
- [ ] Add `POST /apps/:id/restart` endpoint
- Stop and start container
- Record restart in deployment log
- [ ] Add `POST /apps/:id/stop` endpoint
- Stop container without deleting
- Update app status
- [ ] Add `POST /apps/:id/start` endpoint
- Start stopped container
- Run health check
## Phase 3: Medium Priority (UX Improvements)
### 3.1 Edit Operations for Related Entities
- [ ] Add `PUT /apps/:id/env-vars/:id` endpoint
- Update existing environment variable value
- Trigger container restart with new env
- [ ] Add `PUT /apps/:id/labels/:id` endpoint
- Update existing Docker label
- [ ] Add `PUT /apps/:id/volumes/:id` endpoint
- Update volume mount paths
- Validate paths before saving
### 3.2 Deployment Rollback
- [ ] Add `previous_image_id` column to apps table
- Store last successful image ID before new deploy
- [ ] Add `POST /apps/:id/rollback` endpoint
- Stop current container
- Start container with previous image
- Create deployment record for rollback
- [ ] Update deploy service to save previous image before building new one
### 3.3 Deployment Cancellation
- [ ] Add cancellation context to deploy service
- [ ] Add `POST /apps/:id/deployments/:id/cancel` endpoint
- [ ] Handle cleanup of partial builds/containers
## Phase 4: Lower Priority (Nice to Have)
### 4.1 JSON API
- [ ] Add `/api/v1` route group with JSON responses
- [ ] Implement API endpoints mirroring web routes:
- `GET /api/v1/apps` - list apps
- `POST /api/v1/apps` - create app
- `GET /api/v1/apps/:id` - get app details
- `DELETE /api/v1/apps/:id` - delete app
- `POST /api/v1/apps/:id/deploy` - trigger deploy
- `GET /api/v1/apps/:id/deployments` - list deployments
- [ ] Add API token authentication (separate from session auth)
- [ ] Document API in README
### 4.2 Resource Limits
- [ ] Add `cpu_limit` and `memory_limit` columns to apps table
- [ ] Add fields to app edit form
- [ ] Pass limits to Docker container create
### 4.3 UI Improvements
- [ ] Add webhook event history page
- Show received webhooks per app
- Display match/no-match status
- [ ] Add settings page
- View/regenerate webhook secret
- View SSH public key
- [ ] Add real-time deployment log streaming
- WebSocket or SSE for live build output
### 4.4 Observability
- [ ] Add structured logging for all operations
- [ ] Add Prometheus metrics for:
- Deployment count/duration
- Container health status
- Webhook events received
- [ ] Add audit log table for user actions
## Phase 5: Future Considerations
- [ ] Multi-user support with roles
- [ ] Private Docker registry authentication
- [ ] Custom health check commands per app
- [ ] Scheduled deployments
- [ ] Backup/restore of app configurations
- [ ] GitHub/GitLab webhook support (in addition to Gitea)
---
## Implementation Notes
### Server.Run() Example
```go
func (s *Server) Run() error {
s.SetupRoutes()
srv := &http.Server{
Addr: s.config.ListenAddr,
Handler: s.router,
}
go func() {
<-s.shutdownCh
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
return srv.ListenAndServe()
}
```
### SetupRoutes() Structure
```go
func (s *Server) SetupRoutes() {
r := chi.NewRouter()
// Global middleware
r.Use(s.middleware.RequestID)
r.Use(s.middleware.Logger)
r.Use(s.middleware.Recoverer)
// Public routes
r.Get("/health", s.handlers.HandleHealthCheck())
r.Get("/login", s.handlers.HandleLoginPage())
// ...
// Protected routes
r.Group(func(r chi.Router) {
r.Use(s.middleware.SessionAuth)
r.Get("/dashboard", s.handlers.HandleDashboard())
// ...
})
s.router = r
}
```

57
cmd/upaasd/main.go Normal file
View File

@ -0,0 +1,57 @@
// Package main is the entry point for upaasd.
package main
import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/server"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/notify"
"git.eeqj.de/sneak/upaas/internal/service/webhook"
_ "github.com/joho/godotenv/autoload"
)
// Build-time variables injected by linker flags (-ldflags).
// These must be exported package-level variables for the build system.
var (
Appname = "upaas" //nolint:gochecknoglobals // build-time variable
Version string //nolint:gochecknoglobals // build-time variable
Buildarch string //nolint:gochecknoglobals // build-time variable
)
func main() {
globals.SetAppname(Appname)
globals.SetVersion(Version)
globals.SetBuildarch(Buildarch)
fx.New(
fx.Provide(
globals.New,
logger.New,
config.New,
database.New,
healthcheck.New,
auth.New,
app.New,
docker.New,
notify.New,
deploy.New,
webhook.New,
middleware.New,
handlers.New,
server.New,
),
fx.Invoke(func(*server.Server) {}),
).Run()
}

79
go.mod Normal file
View File

@ -0,0 +1,79 @@
module git.eeqj.de/sneak/upaas
go 1.25.4
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/docker/docker v27.3.1+incompatible
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.32
github.com/prometheus/client_golang v1.23.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0
golang.org/x/crypto v0.46.0
)
require (
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
)

219
go.sum Normal file
View File

@ -0,0 +1,219 @@
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
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=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
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.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
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/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

139
internal/config/config.go Normal file
View File

@ -0,0 +1,139 @@
// Package config provides application configuration via Viper.
package config
import (
"errors"
"fmt"
"log/slog"
"github.com/spf13/viper"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
)
// defaultPort is the default HTTP server port.
const defaultPort = 8080
// Params contains dependencies for Config.
type Params struct {
fx.In
Globals *globals.Globals
Logger *logger.Logger
}
// Config holds application configuration.
type Config struct {
Port int
Debug bool
DataDir string
DockerHost string
SentryDSN string
MaintenanceMode bool
MetricsUsername string
MetricsPassword string
SessionSecret string
params *Params
log *slog.Logger
}
// New creates a new Config instance from environment and config files.
func New(_ fx.Lifecycle, params Params) (*Config, error) {
log := params.Logger.Get()
name := params.Globals.Appname
if name == "" {
name = "upaas"
}
setupViper(name)
cfg, err := buildConfig(log, &params)
if err != nil {
return nil, err
}
configureDebugLogging(cfg, params)
return cfg, nil
}
func setupViper(name string) {
// Config file settings
viper.SetConfigName(name)
viper.SetConfigType("yaml")
viper.AddConfigPath("/etc/" + name)
viper.AddConfigPath("$HOME/.config/" + name)
viper.AddConfigPath(".")
// Environment variables override everything
viper.SetEnvPrefix("UPAAS")
viper.AutomaticEnv()
// Defaults
viper.SetDefault("PORT", defaultPort)
viper.SetDefault("DEBUG", false)
viper.SetDefault("DATA_DIR", "./data")
viper.SetDefault("DOCKER_HOST", "unix:///var/run/docker.sock")
viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("MAINTENANCE_MODE", false)
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("SESSION_SECRET", "")
}
func buildConfig(log *slog.Logger, params *Params) (*Config, error) {
// Read config file (optional)
err := viper.ReadInConfig()
if err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
if !errors.As(err, &configFileNotFoundError) {
log.Error("config file malformed", "error", err)
return nil, fmt.Errorf("config file malformed: %w", err)
}
// Config file not found is OK
}
// Build config struct
cfg := &Config{
Port: viper.GetInt("PORT"),
Debug: viper.GetBool("DEBUG"),
DataDir: viper.GetString("DATA_DIR"),
DockerHost: viper.GetString("DOCKER_HOST"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SessionSecret: viper.GetString("SESSION_SECRET"),
params: params,
log: log,
}
// Generate session secret if not set
if cfg.SessionSecret == "" {
cfg.SessionSecret = "change-me-in-production-please"
log.Warn(
"using default session secret, " +
"set UPAAS_SESSION_SECRET in production",
)
}
return cfg, nil
}
func configureDebugLogging(cfg *Config, params Params) {
// Enable debug logging if configured
if cfg.Debug {
params.Logger.EnableDebugLogging()
cfg.log = params.Logger.Get()
}
}
// DatabasePath returns the full path to the SQLite database file.
func (c *Config) DatabasePath() string {
return c.DataDir + "/upaas.db"
}

View File

@ -0,0 +1,175 @@
// Package database provides SQLite database access with logging.
package database
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger"
)
// dataDirPermissions is the file permission for the data directory.
const dataDirPermissions = 0o750
// Params contains dependencies for Database.
type Params struct {
fx.In
Logger *logger.Logger
Config *config.Config
}
// Database wraps sql.DB with logging and helper methods.
type Database struct {
database *sql.DB
log *slog.Logger
params *Params
}
// New creates a new Database instance.
func New(lifecycle fx.Lifecycle, params Params) (*Database, error) {
database := &Database{
log: params.Logger.Get(),
params: &params,
}
// For testing, if lifecycle is nil, connect immediately
if lifecycle == nil {
err := database.connect(context.Background())
if err != nil {
return nil, err
}
return database, nil
}
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return database.connect(ctx)
},
OnStop: func(_ context.Context) error {
return database.close()
},
})
return database, nil
}
// DB returns the underlying sql.DB for direct access.
func (d *Database) DB() *sql.DB {
return d.database
}
// Exec executes a query with logging.
func (d *Database) Exec(
ctx context.Context,
query string,
args ...any,
) (sql.Result, error) {
d.log.Debug("database exec", "query", query, "args", args)
result, err := d.database.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("exec failed: %w", err)
}
return result, nil
}
// QueryRow executes a query that returns a single row.
func (d *Database) QueryRow(ctx context.Context, query string, args ...any) *sql.Row {
d.log.Debug("database query row", "query", query, "args", args)
return d.database.QueryRowContext(ctx, query, args...)
}
// Query executes a query that returns multiple rows.
func (d *Database) Query(
ctx context.Context,
query string,
args ...any,
) (*sql.Rows, error) {
d.log.Debug("database query", "query", query, "args", args)
rows, err := d.database.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
return rows, nil
}
// BeginTx starts a new transaction.
func (d *Database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
d.log.Debug("database begin transaction")
transaction, err := d.database.BeginTx(ctx, opts)
if err != nil {
return nil, fmt.Errorf("begin transaction failed: %w", err)
}
return transaction, nil
}
// Path returns the database file path.
func (d *Database) Path() string {
return d.params.Config.DatabasePath()
}
func (d *Database) connect(ctx context.Context) error {
dbPath := d.params.Config.DatabasePath()
// Ensure data directory exists
dir := filepath.Dir(dbPath)
err := os.MkdirAll(dir, dataDirPermissions)
if err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
// Open database with WAL mode and foreign keys
dsn := dbPath + "?_journal_mode=WAL&_foreign_keys=on"
database, err := sql.Open("sqlite3", dsn)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
// Test connection
err = database.PingContext(ctx)
if err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
d.database = database
d.log.Info("database connected", "path", dbPath)
// Run migrations
err = d.migrate(ctx)
if err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
return nil
}
func (d *Database) close() error {
if d.database != nil {
d.log.Info("closing database connection")
err := d.database.Close()
if err != nil {
return fmt.Errorf("failed to close database: %w", err)
}
}
return nil
}

View File

@ -0,0 +1,122 @@
package database
import (
"context"
"embed"
"fmt"
"io/fs"
"sort"
"strings"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func (d *Database) migrate(ctx context.Context) error {
// Create migrations table if not exists
_, err := d.database.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Get list of migration files
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("failed to read migrations directory: %w", err)
}
// Sort migrations by name
migrations := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
migrations = append(migrations, entry.Name())
}
}
sort.Strings(migrations)
// Apply each migration
for _, migration := range migrations {
applied, err := d.isMigrationApplied(ctx, migration)
if err != nil {
return fmt.Errorf("failed to check migration %s: %w", migration, err)
}
if applied {
d.log.Debug("migration already applied", "migration", migration)
continue
}
err = d.applyMigration(ctx, migration)
if err != nil {
return fmt.Errorf("failed to apply migration %s: %w", migration, err)
}
d.log.Info("migration applied", "migration", migration)
}
return nil
}
func (d *Database) isMigrationApplied(ctx context.Context, version string) (bool, error) {
var count int
err := d.database.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return false, fmt.Errorf("failed to query migration status: %w", err)
}
return count > 0, nil
}
func (d *Database) applyMigration(ctx context.Context, filename string) error {
content, err := migrationsFS.ReadFile("migrations/" + filename)
if err != nil {
return fmt.Errorf("failed to read migration file: %w", err)
}
transaction, err := d.database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err != nil {
_ = transaction.Rollback()
}
}()
// Execute migration
_, err = transaction.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to execute migration: %w", err)
}
// Record migration
_, err = transaction.ExecContext(
ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
filename,
)
if err != nil {
return fmt.Errorf("failed to record migration: %w", err)
}
commitErr := transaction.Commit()
if commitErr != nil {
return fmt.Errorf("failed to commit migration: %w", commitErr)
}
return nil
}

View File

@ -0,0 +1,94 @@
-- Initial schema for upaas
-- Users table (single admin user)
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Apps table
CREATE TABLE apps (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
repo_url TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
dockerfile_path TEXT DEFAULT 'Dockerfile',
webhook_secret TEXT NOT NULL,
ssh_private_key TEXT NOT NULL,
ssh_public_key TEXT NOT NULL,
container_id TEXT,
image_id TEXT,
status TEXT DEFAULT 'pending',
docker_network TEXT,
ntfy_topic TEXT,
slack_webhook TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- App environment variables
CREATE TABLE app_env_vars (
id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
UNIQUE(app_id, key)
);
-- App labels
CREATE TABLE app_labels (
id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
UNIQUE(app_id, key)
);
-- App volume mounts
CREATE TABLE app_volumes (
id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
host_path TEXT NOT NULL,
container_path TEXT NOT NULL,
readonly INTEGER DEFAULT 0
);
-- Webhook events log
CREATE TABLE webhook_events (
id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
branch TEXT NOT NULL,
commit_sha TEXT,
payload TEXT,
matched INTEGER NOT NULL,
processed INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Deployments log
CREATE TABLE deployments (
id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
webhook_event_id INTEGER REFERENCES webhook_events(id),
commit_sha TEXT,
image_id TEXT,
container_id TEXT,
status TEXT NOT NULL,
logs TEXT,
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME
);
-- Indexes
CREATE INDEX idx_apps_status ON apps(status);
CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret);
CREATE INDEX idx_app_env_vars_app_id ON app_env_vars(app_id);
CREATE INDEX idx_app_labels_app_id ON app_labels(app_id);
CREATE INDEX idx_app_volumes_app_id ON app_volumes(app_id);
CREATE INDEX idx_webhook_events_app_id ON webhook_events(app_id);
CREATE INDEX idx_webhook_events_created_at ON webhook_events(created_at);
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
CREATE INDEX idx_deployments_started_at ON deployments(started_at);

523
internal/docker/client.go Normal file
View File

@ -0,0 +1,523 @@
// Package docker provides Docker client functionality.
package docker
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger"
)
// sshKeyPermissions is the file permission for SSH private keys.
const sshKeyPermissions = 0o600
// stopTimeoutSeconds is the timeout for stopping containers.
const stopTimeoutSeconds = 10
// ErrNotConnected is returned when Docker client is not connected.
var ErrNotConnected = errors.New("docker client not connected")
// ErrGitCloneFailed is returned when git clone fails.
var ErrGitCloneFailed = errors.New("git clone failed")
// Params contains dependencies for Client.
type Params struct {
fx.In
Logger *logger.Logger
Config *config.Config
}
// Client wraps the Docker client.
type Client struct {
docker *client.Client
log *slog.Logger
params *Params
}
// New creates a new Docker Client.
func New(lifecycle fx.Lifecycle, params Params) (*Client, error) {
dockerClient := &Client{
log: params.Logger.Get(),
params: &params,
}
// For testing, if lifecycle is nil, skip connection (tests mock Docker)
if lifecycle == nil {
return dockerClient, nil
}
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return dockerClient.connect(ctx)
},
OnStop: func(_ context.Context) error {
return dockerClient.close()
},
})
return dockerClient, nil
}
// IsConnected returns true if the Docker client is connected.
func (c *Client) IsConnected() bool {
return c.docker != nil
}
// BuildImageOptions contains options for building an image.
type BuildImageOptions struct {
ContextDir string
DockerfilePath string
Tags []string
}
// BuildImage builds a Docker image from a context directory.
func (c *Client) BuildImage(
ctx context.Context,
opts BuildImageOptions,
) (string, error) {
if c.docker == nil {
return "", ErrNotConnected
}
c.log.Info(
"building docker image",
"context", opts.ContextDir,
"dockerfile", opts.DockerfilePath,
)
imageID, err := c.performBuild(ctx, opts)
if err != nil {
return "", err
}
return imageID, nil
}
// CreateContainerOptions contains options for creating a container.
type CreateContainerOptions struct {
Name string
Image string
Env map[string]string
Labels map[string]string
Volumes []VolumeMount
Network string
}
// VolumeMount represents a volume mount.
type VolumeMount struct {
HostPath string
ContainerPath string
ReadOnly bool
}
// CreateContainer creates a new container.
func (c *Client) CreateContainer(
ctx context.Context,
opts CreateContainerOptions,
) (string, error) {
if c.docker == nil {
return "", ErrNotConnected
}
c.log.Info("creating container", "name", opts.Name, "image", opts.Image)
// Convert env map to slice
envSlice := make([]string, 0, len(opts.Env))
for key, val := range opts.Env {
envSlice = append(envSlice, key+"="+val)
}
// Convert volumes to mounts
mounts := make([]mount.Mount, 0, len(opts.Volumes))
for _, vol := range opts.Volumes {
mounts = append(mounts, mount.Mount{
Type: mount.TypeBind,
Source: vol.HostPath,
Target: vol.ContainerPath,
ReadOnly: vol.ReadOnly,
})
}
// Create container
resp, err := c.docker.ContainerCreate(ctx,
&container.Config{
Image: opts.Image,
Env: envSlice,
Labels: opts.Labels,
},
&container.HostConfig{
Mounts: mounts,
NetworkMode: container.NetworkMode(opts.Network),
RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyUnlessStopped,
},
},
&network.NetworkingConfig{},
nil,
opts.Name,
)
if err != nil {
return "", fmt.Errorf("failed to create container: %w", err)
}
return resp.ID, nil
}
// StartContainer starts a container.
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
if c.docker == nil {
return ErrNotConnected
}
c.log.Info("starting container", "id", containerID)
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
if err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
return nil
}
// StopContainer stops a container.
func (c *Client) StopContainer(ctx context.Context, containerID string) error {
if c.docker == nil {
return ErrNotConnected
}
c.log.Info("stopping container", "id", containerID)
timeout := stopTimeoutSeconds
err := c.docker.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
if err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
return nil
}
// RemoveContainer removes a container.
func (c *Client) RemoveContainer(
ctx context.Context,
containerID string,
force bool,
) error {
if c.docker == nil {
return ErrNotConnected
}
c.log.Info("removing container", "id", containerID, "force", force)
err := c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
if err != nil {
return fmt.Errorf("failed to remove container: %w", err)
}
return nil
}
// ContainerLogs returns the logs for a container.
func (c *Client) ContainerLogs(
ctx context.Context,
containerID string,
tail string,
) (string, error) {
if c.docker == nil {
return "", ErrNotConnected
}
opts := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Tail: tail,
}
reader, err := c.docker.ContainerLogs(ctx, containerID, opts)
if err != nil {
return "", fmt.Errorf("failed to get container logs: %w", err)
}
defer func() {
closeErr := reader.Close()
if closeErr != nil {
c.log.Error("failed to close log reader", "error", closeErr)
}
}()
logs, err := io.ReadAll(reader)
if err != nil {
return "", fmt.Errorf("failed to read container logs: %w", err)
}
return string(logs), nil
}
// IsContainerRunning checks if a container is running.
func (c *Client) IsContainerRunning(
ctx context.Context,
containerID string,
) (bool, error) {
if c.docker == nil {
return false, ErrNotConnected
}
inspect, err := c.docker.ContainerInspect(ctx, containerID)
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
return inspect.State.Running, nil
}
// IsContainerHealthy checks if a container is healthy.
func (c *Client) IsContainerHealthy(
ctx context.Context,
containerID string,
) (bool, error) {
if c.docker == nil {
return false, ErrNotConnected
}
inspect, err := c.docker.ContainerInspect(ctx, containerID)
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
// If no health check defined, consider running as healthy
if inspect.State.Health == nil {
return inspect.State.Running, nil
}
return inspect.State.Health.Status == "healthy", nil
}
// cloneConfig holds configuration for a git clone operation.
type cloneConfig struct {
repoURL string
branch string
sshPrivateKey string
destDir string
keyFile string
}
// CloneRepo clones a git repository using SSH.
func (c *Client) CloneRepo(
ctx context.Context,
repoURL, branch, sshPrivateKey, destDir string,
) error {
if c.docker == nil {
return ErrNotConnected
}
c.log.Info("cloning repository", "url", repoURL, "branch", branch, "dest", destDir)
cfg := &cloneConfig{
repoURL: repoURL,
branch: branch,
sshPrivateKey: sshPrivateKey,
destDir: destDir,
keyFile: filepath.Join(destDir, ".deploy_key"),
}
return c.performClone(ctx, cfg)
}
func (c *Client) performBuild(
ctx context.Context,
opts BuildImageOptions,
) (string, error) {
// Create tar archive of build context
tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{})
if err != nil {
return "", fmt.Errorf("failed to create build context: %w", err)
}
defer func() {
closeErr := tarArchive.Close()
if closeErr != nil {
c.log.Error("failed to close tar archive", "error", closeErr)
}
}()
// Build image
resp, err := c.docker.ImageBuild(ctx, tarArchive, types.ImageBuildOptions{
Dockerfile: opts.DockerfilePath,
Tags: opts.Tags,
Remove: true,
NoCache: false,
})
if err != nil {
return "", fmt.Errorf("failed to build image: %w", err)
}
defer func() {
closeErr := resp.Body.Close()
if closeErr != nil {
c.log.Error("failed to close response body", "error", closeErr)
}
}()
// Read build output (logs to stdout for now)
_, err = io.Copy(os.Stdout, resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read build output: %w", err)
}
// Get image ID
if len(opts.Tags) > 0 {
inspect, _, inspectErr := c.docker.ImageInspectWithRaw(ctx, opts.Tags[0])
if inspectErr != nil {
return "", fmt.Errorf("failed to inspect image: %w", inspectErr)
}
return inspect.ID, nil
}
return "", nil
}
func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) error {
// Write SSH key to temp file
err := os.WriteFile(cfg.keyFile, []byte(cfg.sshPrivateKey), sshKeyPermissions)
if err != nil {
return fmt.Errorf("failed to write SSH key: %w", err)
}
defer func() {
removeErr := os.Remove(cfg.keyFile)
if removeErr != nil {
c.log.Error("failed to remove SSH key file", "error", removeErr)
}
}()
containerID, err := c.createGitContainer(ctx, cfg)
if err != nil {
return err
}
defer func() {
_ = c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true})
}()
return c.runGitClone(ctx, containerID)
}
func (c *Client) createGitContainer(
ctx context.Context,
cfg *cloneConfig,
) (string, error) {
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
resp, err := c.docker.ContainerCreate(ctx,
&container.Config{
Image: "alpine/git:latest",
Cmd: []string{
"clone", "--depth", "1", "--branch", cfg.branch, cfg.repoURL, "/repo",
},
Env: []string{"GIT_SSH_COMMAND=" + gitSSHCmd},
WorkingDir: "/",
},
&container.HostConfig{
Mounts: []mount.Mount{
{Type: mount.TypeBind, Source: cfg.destDir, Target: "/repo"},
{
Type: mount.TypeBind,
Source: cfg.keyFile,
Target: "/keys/deploy_key",
ReadOnly: true,
},
},
},
nil,
nil,
"",
)
if err != nil {
return "", fmt.Errorf("failed to create git container: %w", err)
}
return resp.ID, nil
}
func (c *Client) runGitClone(ctx context.Context, containerID string) error {
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
if err != nil {
return fmt.Errorf("failed to start git container: %w", err)
}
statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
select {
case err := <-errCh:
return fmt.Errorf("error waiting for git container: %w", err)
case status := <-statusCh:
if status.StatusCode != 0 {
logs, _ := c.ContainerLogs(ctx, containerID, "100")
return fmt.Errorf(
"%w with status %d: %s",
ErrGitCloneFailed,
status.StatusCode,
logs,
)
}
}
return nil
}
func (c *Client) connect(ctx context.Context) error {
opts := []client.Opt{
client.FromEnv,
client.WithAPIVersionNegotiation(),
}
if c.params.Config.DockerHost != "" {
opts = append(opts, client.WithHost(c.params.Config.DockerHost))
}
docker, err := client.NewClientWithOpts(opts...)
if err != nil {
return fmt.Errorf("failed to create Docker client: %w", err)
}
// Test connection
_, err = docker.Ping(ctx)
if err != nil {
return fmt.Errorf("failed to ping Docker: %w", err)
}
c.docker = docker
c.log.Info("docker client connected")
return nil
}
func (c *Client) close() error {
if c.docker != nil {
err := c.docker.Close()
if err != nil {
return fmt.Errorf("failed to close docker client: %w", err)
}
}
return nil
}

View File

@ -0,0 +1,62 @@
// Package globals provides build-time variables and application-wide constants.
package globals
import (
"sync"
"go.uber.org/fx"
)
// Package-level variables set from main via ldflags.
// These are intentionally global to allow build-time injection using -ldflags.
//
//nolint:gochecknoglobals // Required for ldflags injection at build time
var (
mu sync.RWMutex
appname string
version string
buildarch string
)
// Globals holds build-time variables for dependency injection.
type Globals struct {
Appname string
Version string
Buildarch string
}
// New creates a new Globals instance from package-level variables.
func New(_ fx.Lifecycle) (*Globals, error) {
mu.RLock()
defer mu.RUnlock()
return &Globals{
Appname: appname,
Version: version,
Buildarch: buildarch,
}, nil
}
// SetAppname sets the application name (used for testing and main initialization).
func SetAppname(name string) {
mu.Lock()
defer mu.Unlock()
appname = name
}
// SetVersion sets the version (used for testing and main initialization).
func SetVersion(ver string) {
mu.Lock()
defer mu.Unlock()
version = ver
}
// SetBuildarch sets the build architecture (used for testing and main init).
func SetBuildarch(arch string) {
mu.Lock()
defer mu.Unlock()
buildarch = arch
}

572
internal/handlers/app.go Normal file
View File

@ -0,0 +1,572 @@
package handlers
import (
"context"
"database/sql"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/templates"
)
const (
// recentDeploymentsLimit is the number of recent deployments to show.
recentDeploymentsLimit = 5
// deploymentsHistoryLimit is the number of deployments to show in history.
deploymentsHistoryLimit = 50
)
// HandleAppNew returns the new app form handler.
func (h *Handlers) HandleAppNew() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) {
data := map[string]any{}
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// HandleAppCreate handles app creation.
func (h *Handlers) HandleAppCreate() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
name := request.FormValue("name")
repoURL := request.FormValue("repo_url")
branch := request.FormValue("branch")
dockerfilePath := request.FormValue("dockerfile_path")
data := map[string]any{
"Name": name,
"RepoURL": repoURL,
"Branch": branch,
"DockerfilePath": dockerfilePath,
}
if name == "" || repoURL == "" {
data["Error"] = "Name and repository URL are required"
_ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
return
}
if branch == "" {
branch = "main"
}
if dockerfilePath == "" {
dockerfilePath = "Dockerfile"
}
createdApp, createErr := h.appService.CreateApp(
request.Context(),
app.CreateAppInput{
Name: name,
RepoURL: repoURL,
Branch: branch,
DockerfilePath: dockerfilePath,
},
)
if createErr != nil {
h.log.Error("failed to create app", "error", createErr)
data["Error"] = "Failed to create app: " + createErr.Error()
_ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
return
}
http.Redirect(writer, request, "/apps/"+createdApp.ID, http.StatusSeeOther)
}
}
// HandleAppDetail returns the app detail handler.
func (h *Handlers) HandleAppDetail() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil {
h.log.Error("failed to find app", "error", findErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
if application == nil {
http.NotFound(writer, request)
return
}
envVars, _ := application.GetEnvVars(request.Context())
labels, _ := application.GetLabels(request.Context())
volumes, _ := application.GetVolumes(request.Context())
deployments, _ := application.GetDeployments(
request.Context(),
recentDeploymentsLimit,
)
webhookURL := "/webhook/" + application.WebhookSecret
data := map[string]any{
"App": application,
"EnvVars": envVars,
"Labels": labels,
"Volumes": volumes,
"Deployments": deployments,
"WebhookURL": webhookURL,
"Success": request.URL.Query().Get("success"),
}
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// HandleAppEdit returns the app edit form handler.
func (h *Handlers) HandleAppEdit() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil {
h.log.Error("failed to find app", "error", findErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
if application == nil {
http.NotFound(writer, request)
return
}
data := map[string]any{
"App": application,
}
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// HandleAppUpdate handles app updates.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
application.Name = request.FormValue("name")
application.RepoURL = request.FormValue("repo_url")
application.Branch = request.FormValue("branch")
application.DockerfilePath = request.FormValue("dockerfile_path")
if network := request.FormValue("docker_network"); network != "" {
application.DockerNetwork = sql.NullString{String: network, Valid: true}
} else {
application.DockerNetwork = sql.NullString{}
}
if ntfy := request.FormValue("ntfy_topic"); ntfy != "" {
application.NtfyTopic = sql.NullString{String: ntfy, Valid: true}
} else {
application.NtfyTopic = sql.NullString{}
}
if slack := request.FormValue("slack_webhook"); slack != "" {
application.SlackWebhook = sql.NullString{String: slack, Valid: true}
} else {
application.SlackWebhook = sql.NullString{}
}
saveErr := application.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update app", "error", saveErr)
data := map[string]any{
"App": application,
"Error": "Failed to update app",
}
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
return
}
redirectURL := "/apps/" + application.ID + "?success=updated"
http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
}
}
// HandleAppDelete handles app deletion.
func (h *Handlers) HandleAppDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
deleteErr := application.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete app", "error", deleteErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
http.Redirect(writer, request, "/", http.StatusSeeOther)
}
}
// HandleAppDeploy triggers a manual deployment.
func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
// Trigger deployment in background with a detached context
// so the deployment continues even if the HTTP request is cancelled
deployCtx := context.WithoutCancel(request.Context())
go func(ctx context.Context, appToDeploy *models.App) {
deployErr := h.deploy.Deploy(ctx, appToDeploy, nil)
if deployErr != nil {
h.log.Error(
"deployment failed",
"error", deployErr,
"app", appToDeploy.Name,
)
}
}(deployCtx, application)
http.Redirect(
writer,
request,
"/apps/"+application.ID+"/deployments",
http.StatusSeeOther,
)
}
}
// HandleAppDeployments returns the deployments history handler.
func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
deployments, _ := application.GetDeployments(
request.Context(),
deploymentsHistoryLimit,
)
data := map[string]any{
"App": application,
"Deployments": deployments,
}
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// HandleAppLogs returns the container logs handler.
func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
// Container logs fetching not yet implemented
writer.Header().Set("Content-Type", "text/plain")
if !application.ContainerID.Valid {
_, _ = writer.Write([]byte("No container running"))
return
}
_, _ = writer.Write([]byte("Container logs not implemented yet"))
}
}
// addKeyValueToApp is a helper for adding key-value pairs (env vars or labels).
func (h *Handlers) addKeyValueToApp(
writer http.ResponseWriter,
request *http.Request,
createAndSave func(
ctx context.Context,
application *models.App,
key, value string,
) error,
) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
saveErr := createAndSave(request.Context(), application, key, value)
if saveErr != nil {
h.log.Error("failed to add key-value pair", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
// HandleEnvVarAdd handles adding an environment variable.
func (h *Handlers) HandleEnvVarAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.addKeyValueToApp(
writer,
request,
func(ctx context.Context, application *models.App, key, value string) error {
envVar := models.NewEnvVar(h.db)
envVar.AppID = application.ID
envVar.Key = key
envVar.Value = value
return envVar.Save(ctx)
},
)
}
}
// HandleEnvVarDelete handles deleting an environment variable.
func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "envID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil {
http.NotFound(writer, request)
return
}
deleteErr := envVar.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete env var", "error", deleteErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleLabelAdd handles adding a label.
func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.addKeyValueToApp(
writer,
request,
func(ctx context.Context, application *models.App, key, value string) error {
label := models.NewLabel(h.db)
label.AppID = application.ID
label.Key = key
label.Value = value
return label.Save(ctx)
},
)
}
}
// HandleLabelDelete handles deleting a label.
func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
labelIDStr := chi.URLParam(request, "labelID")
labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
label, findErr := models.FindLabel(request.Context(), h.db, labelID)
if findErr != nil || label == nil {
http.NotFound(writer, request)
return
}
deleteErr := label.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete label", "error", deleteErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleVolumeAdd handles adding a volume mount.
func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
hostPath := request.FormValue("host_path")
containerPath := request.FormValue("container_path")
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
return
}
volume := models.NewVolume(h.db)
volume.AppID = application.ID
volume.HostPath = hostPath
volume.ContainerPath = containerPath
volume.ReadOnly = readOnly
saveErr := volume.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to add volume", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
}
// HandleVolumeDelete handles deleting a volume mount.
func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
volumeIDStr := chi.URLParam(request, "volumeID")
volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
if findErr != nil || volume == nil {
http.NotFound(writer, request)
return
}
deleteErr := volume.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete volume", "error", deleteErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}

82
internal/handlers/auth.go Normal file
View File

@ -0,0 +1,82 @@
package handlers
import (
"net/http"
"git.eeqj.de/sneak/upaas/templates"
)
// HandleLoginGET returns the login page handler.
func (h *Handlers) HandleLoginGET() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) {
data := map[string]any{}
err := tmpl.ExecuteTemplate(writer, "login.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// HandleLoginPOST handles the login form submission.
func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
username := request.FormValue("username")
password := request.FormValue("password")
data := map[string]any{
"Username": username,
}
if username == "" || password == "" {
data["Error"] = "Username and password are required"
_ = tmpl.ExecuteTemplate(writer, "login.html", data)
return
}
user, authErr := h.auth.Authenticate(request.Context(), username, password)
if authErr != nil {
data["Error"] = "Invalid username or password"
_ = tmpl.ExecuteTemplate(writer, "login.html", data)
return
}
sessionErr := h.auth.CreateSession(writer, request, user)
if sessionErr != nil {
h.log.Error("failed to create session", "error", sessionErr)
data["Error"] = "Failed to create session"
_ = tmpl.ExecuteTemplate(writer, "login.html", data)
return
}
http.Redirect(writer, request, "/", http.StatusSeeOther)
}
}
// HandleLogout handles logout requests.
func (h *Handlers) HandleLogout() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
destroyErr := h.auth.DestroySession(writer, request)
if destroyErr != nil {
h.log.Error("failed to destroy session", "error", destroyErr)
}
http.Redirect(writer, request, "/login", http.StatusSeeOther)
}
}

View File

@ -0,0 +1,33 @@
package handlers
import (
"net/http"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/templates"
)
// HandleDashboard returns the dashboard handler.
func (h *Handlers) HandleDashboard() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
apps, fetchErr := models.AllApps(request.Context(), h.db)
if fetchErr != nil {
h.log.Error("failed to fetch apps", "error", fetchErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
data := map[string]any{
"Apps": apps,
}
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
if execErr != nil {
h.log.Error("template execution failed", "error", execErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}

View File

@ -0,0 +1,76 @@
// Package handlers provides HTTP request handlers.
package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/webhook"
)
// Params contains dependencies for Handlers.
type Params struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Database *database.Database
Healthcheck *healthcheck.Healthcheck
Auth *auth.Service
App *app.Service
Deploy *deploy.Service
Webhook *webhook.Service
}
// Handlers provides HTTP request handlers.
type Handlers struct {
log *slog.Logger
params *Params
db *database.Database
hc *healthcheck.Healthcheck
auth *auth.Service
appService *app.Service
deploy *deploy.Service
webhook *webhook.Service
}
// New creates a new Handlers instance.
func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
return &Handlers{
log: params.Logger.Get(),
params: &params,
db: params.Database,
hc: params.Healthcheck,
auth: params.Auth,
appService: params.App,
deploy: params.Deploy,
webhook: params.Webhook,
}, nil
}
func (h *Handlers) respondJSON(
writer http.ResponseWriter,
_ *http.Request,
data any,
status int,
) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(status)
if data != nil {
err := json.NewEncoder(writer).Encode(data)
if err != nil {
h.log.Error("json encode error", "error", err)
}
}
}

View File

@ -0,0 +1,486 @@
package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/notify"
"git.eeqj.de/sneak/upaas/internal/service/webhook"
)
type testContext struct {
handlers *handlers.Handlers
database *database.Database
authSvc *auth.Service
appSvc *app.Service
}
func createTestConfig(t *testing.T) *config.Config {
t.Helper()
return &config.Config{
Port: 8080,
DataDir: t.TempDir(),
SessionSecret: "test-secret-key-at-least-32-characters-long",
}
}
func createCoreServices(
t *testing.T,
cfg *config.Config,
) (*globals.Globals, *logger.Logger, *database.Database, *healthcheck.Healthcheck) {
t.Helper()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalInstance, globErr := globals.New(fx.Lifecycle(nil))
require.NoError(t, globErr)
logInstance, logErr := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalInstance},
)
require.NoError(t, logErr)
dbInstance, dbErr := database.New(fx.Lifecycle(nil), database.Params{
Logger: logInstance,
Config: cfg,
})
require.NoError(t, dbErr)
hcInstance, hcErr := healthcheck.New(
fx.Lifecycle(nil),
healthcheck.Params{
Logger: logInstance,
Globals: globalInstance,
Config: cfg,
},
)
require.NoError(t, hcErr)
return globalInstance, logInstance, dbInstance, hcInstance
}
func createAppServices(
t *testing.T,
logInstance *logger.Logger,
dbInstance *database.Database,
cfg *config.Config,
) (*auth.Service, *app.Service, *deploy.Service, *webhook.Service) {
t.Helper()
authSvc, authErr := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
Logger: logInstance,
Config: cfg,
Database: dbInstance,
})
require.NoError(t, authErr)
appSvc, appErr := app.New(fx.Lifecycle(nil), app.ServiceParams{
Logger: logInstance,
Database: dbInstance,
})
require.NoError(t, appErr)
dockerClient, dockerErr := docker.New(fx.Lifecycle(nil), docker.Params{
Logger: logInstance,
Config: cfg,
})
require.NoError(t, dockerErr)
notifySvc, notifyErr := notify.New(fx.Lifecycle(nil), notify.ServiceParams{
Logger: logInstance,
})
require.NoError(t, notifyErr)
deploySvc, deployErr := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
Logger: logInstance,
Database: dbInstance,
Docker: dockerClient,
Notify: notifySvc,
})
require.NoError(t, deployErr)
webhookSvc, webhookErr := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{
Logger: logInstance,
Database: dbInstance,
Deploy: deploySvc,
})
require.NoError(t, webhookErr)
return authSvc, appSvc, deploySvc, webhookSvc
}
func setupTestHandlers(t *testing.T) *testContext {
t.Helper()
cfg := createTestConfig(t)
globalInstance, logInstance, dbInstance, hcInstance := createCoreServices(t, cfg)
authSvc, appSvc, deploySvc, webhookSvc := createAppServices(
t,
logInstance,
dbInstance,
cfg,
)
handlersInstance, handlerErr := handlers.New(
fx.Lifecycle(nil),
handlers.Params{
Logger: logInstance,
Globals: globalInstance,
Database: dbInstance,
Healthcheck: hcInstance,
Auth: authSvc,
App: appSvc,
Deploy: deploySvc,
Webhook: webhookSvc,
},
)
require.NoError(t, handlerErr)
return &testContext{
handlers: handlersInstance,
database: dbInstance,
authSvc: authSvc,
appSvc: appSvc,
}
}
func TestHandleHealthCheck(t *testing.T) {
t.Parallel()
t.Run("returns health check response", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(
http.MethodGet,
"/.well-known/healthcheck.json",
nil,
)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleHealthCheck()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Header().Get("Content-Type"), "application/json")
assert.Contains(t, recorder.Body.String(), "status")
assert.Contains(t, recorder.Body.String(), "ok")
})
}
func TestHandleSetupGET(t *testing.T) {
t.Parallel()
t.Run("renders setup page", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupGET()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "setup")
})
}
func createSetupFormRequest(
username, password, confirm string,
) *http.Request {
form := url.Values{}
form.Set("username", username)
form.Set("password", password)
form.Set("password_confirm", confirm)
request := httptest.NewRequest(
http.MethodPost,
"/setup",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return request
}
func TestHandleSetupPOSTCreatesUserAndRedirects(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "password123", "password123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Equal(t, "/", recorder.Header().Get("Location"))
}
func TestHandleSetupPOSTRejectsEmptyUsername(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("", "password123", "password123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "required")
}
func TestHandleSetupPOSTRejectsShortPassword(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "short", "short")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "8 characters")
}
func TestHandleSetupPOSTRejectsMismatchedPasswords(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := createSetupFormRequest("admin", "password123", "different123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "do not match")
}
func TestHandleLoginGET(t *testing.T) {
t.Parallel()
t.Run("renders login page", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/login", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginGET()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "login")
})
}
func createLoginFormRequest(username, password string) *http.Request {
form := url.Values{}
form.Set("username", username)
form.Set("password", password)
request := httptest.NewRequest(
http.MethodPost,
"/login",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return request
}
func TestHandleLoginPOSTAuthenticatesValidCredentials(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create user first
_, createErr := testCtx.authSvc.CreateUser(
context.Background(),
"testuser",
"testpass123",
)
require.NoError(t, createErr)
request := createLoginFormRequest("testuser", "testpass123")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Equal(t, "/", recorder.Header().Get("Location"))
}
func TestHandleLoginPOSTRejectsInvalidCredentials(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create user first
_, createErr := testCtx.authSvc.CreateUser(
context.Background(),
"testuser",
"testpass123",
)
require.NoError(t, createErr)
request := createLoginFormRequest("testuser", "wrongpassword")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginPOST()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Invalid")
}
func TestHandleDashboard(t *testing.T) {
t.Parallel()
t.Run("renders dashboard with app list", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Applications")
})
}
func TestHandleAppNew(t *testing.T) {
t.Parallel()
t.Run("renders new app form", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/apps/new", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleAppNew()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
})
}
// addChiURLParams adds chi URL parameters to a request for testing.
func addChiURLParams(
request *http.Request,
params map[string]string,
) *http.Request {
routeContext := chi.NewRouteContext()
for key, value := range params {
routeContext.URLParams.Add(key, value)
}
return request.WithContext(
context.WithValue(request.Context(), chi.RouteCtxKey, routeContext),
)
}
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
webhookURL := "/webhook/unknown-secret"
payload := `{"ref": "refs/heads/main"}`
request := httptest.NewRequest(
http.MethodPost,
webhookURL,
strings.NewReader(payload),
)
request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"})
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleWebhook()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create an app first
createdApp, createErr := testCtx.appSvc.CreateApp(
context.Background(),
app.CreateAppInput{
Name: "webhook-test-app",
RepoURL: "git@example.com:user/repo.git",
Branch: "main",
},
)
require.NoError(t, createErr)
payload := `{"ref": "refs/heads/main", "after": "abc123"}`
webhookURL := "/webhook/" + createdApp.WebhookSecret
request := httptest.NewRequest(
http.MethodPost,
webhookURL,
strings.NewReader(payload),
)
request = addChiURLParams(
request,
map[string]string{"secret": createdApp.WebhookSecret},
)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Gitea-Event", "push")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleWebhook()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
}

View File

@ -0,0 +1,12 @@
package handlers
import (
"net/http"
)
// HandleHealthCheck returns the health check handler.
func (h *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.respondJSON(writer, request, h.hc.Check(), http.StatusOK)
}
}

118
internal/handlers/setup.go Normal file
View File

@ -0,0 +1,118 @@
package handlers
import (
"net/http"
"git.eeqj.de/sneak/upaas/templates"
)
const (
// minPasswordLength is the minimum required password length.
minPasswordLength = 8
)
// HandleSetupGET returns the setup page handler.
func (h *Handlers) HandleSetupGET() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) {
data := map[string]any{}
err := tmpl.ExecuteTemplate(writer, "setup.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// setupFormData holds form data for the setup page.
type setupFormData struct {
username string
password string
passwordConfirm string
}
// validateSetupForm validates the setup form and returns an error message if invalid.
func validateSetupForm(formData setupFormData) string {
if formData.username == "" || formData.password == "" {
return "Username and password are required"
}
if len(formData.password) < minPasswordLength {
return "Password must be at least 8 characters"
}
if formData.password != formData.passwordConfirm {
return "Passwords do not match"
}
return ""
}
// renderSetupError renders the setup page with an error message.
func renderSetupError(
tmpl *templates.TemplateExecutor,
writer http.ResponseWriter,
username string,
errorMsg string,
) {
data := map[string]any{
"Username": username,
"Error": errorMsg,
}
_ = tmpl.ExecuteTemplate(writer, "setup.html", data)
}
// HandleSetupPOST handles the setup form submission.
func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
formData := setupFormData{
username: request.FormValue("username"),
password: request.FormValue("password"),
passwordConfirm: request.FormValue("password_confirm"),
}
if validationErr := validateSetupForm(formData); validationErr != "" {
renderSetupError(tmpl, writer, formData.username, validationErr)
return
}
user, createErr := h.auth.CreateUser(
request.Context(),
formData.username,
formData.password,
)
if createErr != nil {
h.log.Error("failed to create user", "error", createErr)
renderSetupError(tmpl, writer, formData.username, "Failed to create user")
return
}
sessionErr := h.auth.CreateSession(writer, request, user)
if sessionErr != nil {
h.log.Error("failed to create session", "error", sessionErr)
renderSetupError(
tmpl,
writer,
formData.username,
"Failed to create session",
)
return
}
http.Redirect(writer, request, "/", http.StatusSeeOther)
}
}

View File

@ -0,0 +1,72 @@
package handlers
import (
"io"
"net/http"
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models"
)
// HandleWebhook handles incoming Gitea webhooks.
func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
secret := chi.URLParam(request, "secret")
if secret == "" {
http.NotFound(writer, request)
return
}
// Find app by webhook secret
application, findErr := models.FindAppByWebhookSecret(
request.Context(),
h.db,
secret,
)
if findErr != nil {
h.log.Error("failed to find app by webhook secret", "error", findErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
if application == nil {
http.NotFound(writer, request)
return
}
// Read request body
body, readErr := io.ReadAll(request.Body)
if readErr != nil {
h.log.Error("failed to read webhook body", "error", readErr)
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
// Get event type from header
eventType := request.Header.Get("X-Gitea-Event")
if eventType == "" {
eventType = "push"
}
// Process webhook
webhookErr := h.webhook.HandleWebhook(
request.Context(),
application,
eventType,
body,
)
if webhookErr != nil {
h.log.Error("failed to process webhook", "error", webhookErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
writer.WriteHeader(http.StatusOK)
}
}

View File

@ -0,0 +1,85 @@
// Package healthcheck provides application health status.
package healthcheck
import (
"context"
"log/slog"
"time"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
)
// Params contains dependencies for Healthcheck.
type Params struct {
fx.In
Globals *globals.Globals
Config *config.Config
Logger *logger.Logger
Database *database.Database
}
// Healthcheck provides health status information.
type Healthcheck struct {
StartupTime time.Time
log *slog.Logger
params *Params
}
// Response is the health check response structure.
type Response struct {
Status string `json:"status"`
Now string `json:"now"`
UptimeSeconds int64 `json:"uptimeSeconds"`
UptimeHuman string `json:"uptimeHuman"`
Version string `json:"version"`
Appname string `json:"appname"`
Maintenance bool `json:"maintenanceMode"`
}
// New creates a new Healthcheck instance.
func New(lifecycle fx.Lifecycle, params Params) (*Healthcheck, error) {
healthcheck := &Healthcheck{
log: params.Logger.Get(),
params: &params,
}
// For testing, if lifecycle is nil, initialize immediately
if lifecycle == nil {
healthcheck.StartupTime = time.Now()
return healthcheck, nil
}
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
healthcheck.StartupTime = time.Now()
return nil
},
})
return healthcheck, nil
}
// Check returns the current health status.
func (h *Healthcheck) Check() *Response {
return &Response{
Status: "ok",
Now: time.Now().UTC().Format(time.RFC3339Nano),
UptimeSeconds: int64(h.uptime().Seconds()),
UptimeHuman: h.uptime().String(),
Appname: h.params.Globals.Appname,
Version: h.params.Globals.Version,
Maintenance: h.params.Config.MaintenanceMode,
}
}
func (h *Healthcheck) uptime() time.Duration {
return time.Since(h.StartupTime)
}

86
internal/logger/logger.go Normal file
View File

@ -0,0 +1,86 @@
// Package logger provides structured logging with slog.
package logger
import (
"log/slog"
"os"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/globals"
)
// Params contains dependencies for Logger.
type Params struct {
fx.In
Globals *globals.Globals
}
// Logger wraps slog.Logger with level control.
type Logger struct {
log *slog.Logger
level *slog.LevelVar
params Params
}
// New creates a new Logger with TTY detection for output format.
func New(_ fx.Lifecycle, params Params) (*Logger, error) {
loggerInstance := &Logger{
level: new(slog.LevelVar),
params: params,
}
loggerInstance.level.Set(slog.LevelInfo)
// TTY detection for dev vs prod output
isTTY := detectTTY()
var handler slog.Handler
if isTTY {
// Text output for development
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: loggerInstance.level,
AddSource: true,
})
} else {
// JSON output for production
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: loggerInstance.level,
AddSource: true,
})
}
loggerInstance.log = slog.New(handler)
return loggerInstance, nil
}
func detectTTY() bool {
fileInfo, err := os.Stdout.Stat()
if err != nil {
return false
}
return (fileInfo.Mode() & os.ModeCharDevice) != 0
}
// Get returns the underlying slog.Logger.
func (l *Logger) Get() *slog.Logger {
return l.log
}
// EnableDebugLogging sets the log level to debug.
func (l *Logger) EnableDebugLogging() {
l.level.Set(slog.LevelDebug)
l.log.Debug("debug logging enabled", "debug", true)
}
// Identify logs application startup information.
func (l *Logger) Identify() {
l.log.Info("starting",
"appname", l.params.Globals.Appname,
"version", l.params.Globals.Version,
"buildarch", l.params.Globals.Buildarch,
)
}

View File

@ -0,0 +1,197 @@
// Package middleware provides HTTP middleware.
package middleware
import (
"log/slog"
"net"
"net/http"
"time"
"github.com/99designs/basicauth-go"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/auth"
)
// corsMaxAge is the maximum age for CORS preflight responses in seconds.
const corsMaxAge = 300
// Params contains dependencies for Middleware.
type Params struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Auth *auth.Service
}
// Middleware provides HTTP middleware.
type Middleware struct {
log *slog.Logger
params *Params
}
// New creates a new Middleware instance.
func New(_ fx.Lifecycle, params Params) (*Middleware, error) {
return &Middleware{
log: params.Logger.Get(),
params: &params,
}, nil
}
// loggingResponseWriter wraps http.ResponseWriter to capture status code.
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func newLoggingResponseWriter(
writer http.ResponseWriter,
) *loggingResponseWriter {
return &loggingResponseWriter{writer, http.StatusOK}
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
// Logging returns a request logging middleware.
func (m *Middleware) Logging() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
start := time.Now()
lrw := newLoggingResponseWriter(writer)
ctx := request.Context()
defer func() {
latency := time.Since(start)
reqID := middleware.GetReqID(ctx)
m.log.InfoContext(ctx, "request",
"request_start", start,
"method", request.Method,
"url", request.URL.String(),
"useragent", request.UserAgent(),
"request_id", reqID,
"referer", request.Referer(),
"proto", request.Proto,
"remoteIP", ipFromHostPort(request.RemoteAddr),
"status", lrw.statusCode,
"latency_ms", latency.Milliseconds(),
)
}()
next.ServeHTTP(lrw, request)
})
}
}
func ipFromHostPort(hostPort string) string {
host, _, err := net.SplitHostPort(hostPort)
if err != nil {
return hostPort
}
return host
}
// CORS returns CORS middleware.
func (m *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: corsMaxAge,
})
}
// MetricsAuth returns basic auth middleware for metrics endpoint.
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
if m.params.Config.MetricsUsername == "" {
return func(next http.Handler) http.Handler {
return next
}
}
return basicauth.New(
"metrics",
map[string][]string{
m.params.Config.MetricsUsername: {m.params.Config.MetricsPassword},
},
)
}
// SessionAuth returns middleware that requires authentication.
func (m *Middleware) SessionAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
user, err := m.params.Auth.GetCurrentUser(request.Context(), request)
if err != nil || user == nil {
http.Redirect(writer, request, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(writer, request)
})
}
}
// SetupRequired returns middleware that redirects to setup if no user exists.
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
setupRequired, err := m.params.Auth.IsSetupRequired(request.Context())
if err != nil {
m.log.Error("failed to check setup status", "error", err)
http.Error(
writer,
"Internal Server Error",
http.StatusInternalServerError,
)
return
}
if setupRequired {
// Allow access to setup page
if request.URL.Path == "/setup" {
next.ServeHTTP(writer, request)
return
}
http.Redirect(writer, request, "/setup", http.StatusSeeOther)
return
}
// Block setup page if already set up
if request.URL.Path == "/setup" {
http.Redirect(writer, request, "/", http.StatusSeeOther)
return
}
next.ServeHTTP(writer, request)
})
}
}

290
internal/models/app.go Normal file
View File

@ -0,0 +1,290 @@
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
)
// AppStatus represents the status of an app.
type AppStatus string
// App status constants.
const (
AppStatusPending AppStatus = "pending"
AppStatusBuilding AppStatus = "building"
AppStatusRunning AppStatus = "running"
AppStatusStopped AppStatus = "stopped"
AppStatusError AppStatus = "error"
)
// App represents an application managed by upaas.
type App struct {
db *database.Database
ID string
Name string
RepoURL string
Branch string
DockerfilePath string
WebhookSecret string
SSHPrivateKey string
SSHPublicKey string
ContainerID sql.NullString
ImageID sql.NullString
Status AppStatus
DockerNetwork sql.NullString
NtfyTopic sql.NullString
SlackWebhook sql.NullString
CreatedAt time.Time
UpdatedAt time.Time
}
// NewApp creates a new App with a database reference.
func NewApp(db *database.Database) *App {
return &App{
db: db,
Status: AppStatusPending,
Branch: "main",
}
}
// Save inserts or updates the app in the database.
func (a *App) Save(ctx context.Context) error {
if a.exists(ctx) {
return a.update(ctx)
}
return a.insert(ctx)
}
// Delete removes the app from the database.
func (a *App) Delete(ctx context.Context) error {
_, err := a.db.Exec(ctx, "DELETE FROM apps WHERE id = ?", a.ID)
return err
}
// Reload refreshes the app from the database.
func (a *App) Reload(ctx context.Context) error {
row := a.db.QueryRow(ctx, `
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, container_id, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps WHERE id = ?`,
a.ID,
)
return a.scan(row)
}
// GetEnvVars returns all environment variables for this app.
func (a *App) GetEnvVars(ctx context.Context) ([]*EnvVar, error) {
return FindEnvVarsByAppID(ctx, a.db, a.ID)
}
// GetLabels returns all labels for this app.
func (a *App) GetLabels(ctx context.Context) ([]*Label, error) {
return FindLabelsByAppID(ctx, a.db, a.ID)
}
// GetVolumes returns all volume mounts for this app.
func (a *App) GetVolumes(ctx context.Context) ([]*Volume, error) {
return FindVolumesByAppID(ctx, a.db, a.ID)
}
// GetDeployments returns recent deployments for this app.
func (a *App) GetDeployments(ctx context.Context, limit int) ([]*Deployment, error) {
return FindDeploymentsByAppID(ctx, a.db, a.ID, limit)
}
// GetWebhookEvents returns recent webhook events for this app.
func (a *App) GetWebhookEvents(
ctx context.Context,
limit int,
) ([]*WebhookEvent, error) {
return FindWebhookEventsByAppID(ctx, a.db, a.ID, limit)
}
func (a *App) exists(ctx context.Context) bool {
if a.ID == "" {
return false
}
var count int
row := a.db.QueryRow(ctx, "SELECT COUNT(*) FROM apps WHERE id = ?", a.ID)
err := row.Scan(&count)
if err != nil {
return false
}
return count > 0
}
func (a *App) insert(ctx context.Context) error {
query := `
INSERT INTO apps (
id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, container_id, image_id, status,
docker_network, ntfy_topic, slack_webhook
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := a.db.Exec(ctx, query,
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
a.SSHPrivateKey, a.SSHPublicKey, a.ContainerID, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
)
if err != nil {
return err
}
return a.Reload(ctx)
}
func (a *App) update(ctx context.Context) error {
query := `
UPDATE apps SET
name = ?, repo_url = ?, branch = ?, dockerfile_path = ?,
container_id = ?, image_id = ?, status = ?,
docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
_, err := a.db.Exec(ctx, query,
a.Name, a.RepoURL, a.Branch, a.DockerfilePath,
a.ContainerID, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.ID,
)
return err
}
func (a *App) scan(row *sql.Row) error {
return row.Scan(
&a.ID, &a.Name, &a.RepoURL, &a.Branch,
&a.DockerfilePath, &a.WebhookSecret,
&a.SSHPrivateKey, &a.SSHPublicKey,
&a.ContainerID, &a.ImageID, &a.Status,
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
&a.CreatedAt, &a.UpdatedAt,
)
}
func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
var apps []*App
for rows.Next() {
app := NewApp(appDB)
scanErr := rows.Scan(
&app.ID, &app.Name, &app.RepoURL, &app.Branch,
&app.DockerfilePath, &app.WebhookSecret,
&app.SSHPrivateKey, &app.SSHPublicKey,
&app.ContainerID, &app.ImageID, &app.Status,
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
&app.CreatedAt, &app.UpdatedAt,
)
if scanErr != nil {
return nil, fmt.Errorf("scanning app row: %w", scanErr)
}
apps = append(apps, app)
}
rowsErr := rows.Err()
if rowsErr != nil {
return nil, fmt.Errorf("iterating app rows: %w", rowsErr)
}
return apps, nil
}
// FindApp finds an app by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindApp(
ctx context.Context,
appDB *database.Database,
appID string,
) (*App, error) {
app := NewApp(appDB)
app.ID = appID
row := appDB.QueryRow(ctx, `
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, container_id, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps WHERE id = ?`,
appID,
)
err := app.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning app: %w", err)
}
return app, nil
}
// FindAppByWebhookSecret finds an app by webhook secret.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindAppByWebhookSecret(
ctx context.Context,
appDB *database.Database,
secret string,
) (*App, error) {
app := NewApp(appDB)
row := appDB.QueryRow(ctx, `
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, container_id, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps WHERE webhook_secret = ?`,
secret,
)
err := app.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning app by webhook secret: %w", err)
}
return app, nil
}
// AllApps returns all apps ordered by name.
func AllApps(ctx context.Context, appDB *database.Database) ([]*App, error) {
rows, err := appDB.Query(ctx, `
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, container_id, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("querying all apps: %w", err)
}
defer func() { _ = rows.Close() }()
result, scanErr := scanApps(appDB, rows)
if scanErr != nil {
return nil, scanErr
}
return result, nil
}

View File

@ -0,0 +1,241 @@
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
)
// DeploymentStatus represents the status of a deployment.
type DeploymentStatus string
// Deployment status constants.
const (
DeploymentStatusBuilding DeploymentStatus = "building"
DeploymentStatusDeploying DeploymentStatus = "deploying"
DeploymentStatusSuccess DeploymentStatus = "success"
DeploymentStatusFailed DeploymentStatus = "failed"
)
// Deployment represents a deployment attempt for an app.
type Deployment struct {
db *database.Database
ID int64
AppID string
WebhookEventID sql.NullInt64
CommitSHA sql.NullString
ImageID sql.NullString
ContainerID sql.NullString
Status DeploymentStatus
Logs sql.NullString
StartedAt time.Time
FinishedAt sql.NullTime
}
// NewDeployment creates a new Deployment with a database reference.
func NewDeployment(db *database.Database) *Deployment {
return &Deployment{
db: db,
Status: DeploymentStatusBuilding,
}
}
// Save inserts or updates the deployment in the database.
func (d *Deployment) Save(ctx context.Context) error {
if d.ID == 0 {
return d.insert(ctx)
}
return d.update(ctx)
}
// Reload refreshes the deployment from the database.
func (d *Deployment) Reload(ctx context.Context) error {
query := `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE id = ?`
row := d.db.QueryRow(ctx, query, d.ID)
return d.scan(row)
}
// AppendLog appends a log line to the deployment logs.
func (d *Deployment) AppendLog(ctx context.Context, line string) error {
var currentLogs string
if d.Logs.Valid {
currentLogs = d.Logs.String
}
d.Logs = sql.NullString{String: currentLogs + line + "\n", Valid: true}
return d.Save(ctx)
}
// MarkFinished marks the deployment as finished with the given status.
func (d *Deployment) MarkFinished(
ctx context.Context,
status DeploymentStatus,
) error {
d.Status = status
d.FinishedAt = sql.NullTime{Time: time.Now(), Valid: true}
return d.Save(ctx)
}
func (d *Deployment) insert(ctx context.Context) error {
query := `
INSERT INTO deployments (
app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs
) VALUES (?, ?, ?, ?, ?, ?, ?)`
result, err := d.db.Exec(ctx, query,
d.AppID, d.WebhookEventID, d.CommitSHA, d.ImageID,
d.ContainerID, d.Status, d.Logs,
)
if err != nil {
return err
}
insertID, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("getting last insert id: %w", err)
}
d.ID = insertID
return d.Reload(ctx)
}
func (d *Deployment) update(ctx context.Context) error {
query := `
UPDATE deployments SET
image_id = ?, container_id = ?, status = ?, logs = ?, finished_at = ?
WHERE id = ?`
_, err := d.db.Exec(ctx, query,
d.ImageID, d.ContainerID, d.Status, d.Logs, d.FinishedAt, d.ID,
)
return err
}
func (d *Deployment) scan(row *sql.Row) error {
return row.Scan(
&d.ID, &d.AppID, &d.WebhookEventID, &d.CommitSHA, &d.ImageID,
&d.ContainerID, &d.Status, &d.Logs, &d.StartedAt, &d.FinishedAt,
)
}
// FindDeployment finds a deployment by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindDeployment(
ctx context.Context,
deployDB *database.Database,
deployID int64,
) (*Deployment, error) {
deploy := NewDeployment(deployDB)
deploy.ID = deployID
row := deployDB.QueryRow(ctx, `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE id = ?`,
deployID,
)
err := deploy.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning deployment: %w", err)
}
return deploy, nil
}
// FindDeploymentsByAppID finds recent deployments for an app.
func FindDeploymentsByAppID(
ctx context.Context,
deployDB *database.Database,
appID string,
limit int,
) ([]*Deployment, error) {
query := `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE app_id = ?
ORDER BY started_at DESC, id DESC LIMIT ?`
rows, err := deployDB.Query(ctx, query, appID, limit)
if err != nil {
return nil, fmt.Errorf("querying deployments by app: %w", err)
}
defer func() { _ = rows.Close() }()
var deployments []*Deployment
for rows.Next() {
deploy := NewDeployment(deployDB)
scanErr := rows.Scan(
&deploy.ID, &deploy.AppID, &deploy.WebhookEventID,
&deploy.CommitSHA, &deploy.ImageID, &deploy.ContainerID,
&deploy.Status, &deploy.Logs, &deploy.StartedAt, &deploy.FinishedAt,
)
if scanErr != nil {
return nil, fmt.Errorf("scanning deployment row: %w", scanErr)
}
deployments = append(deployments, deploy)
}
rowsErr := rows.Err()
if rowsErr != nil {
return nil, fmt.Errorf("iterating deployment rows: %w", rowsErr)
}
return deployments, nil
}
// LatestDeploymentForApp finds the most recent deployment for an app.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func LatestDeploymentForApp(
ctx context.Context,
deployDB *database.Database,
appID string,
) (*Deployment, error) {
deploy := NewDeployment(deployDB)
row := deployDB.QueryRow(ctx, `
SELECT id, app_id, webhook_event_id, commit_sha, image_id,
container_id, status, logs, started_at, finished_at
FROM deployments WHERE app_id = ?
ORDER BY started_at DESC, id DESC LIMIT 1`,
appID,
)
err := deploy.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning latest deployment: %w", err)
}
return deploy, nil
}

141
internal/models/env_var.go Normal file
View File

@ -0,0 +1,141 @@
//nolint:dupl // Active Record pattern - similar structure to label.go is intentional
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"git.eeqj.de/sneak/upaas/internal/database"
)
// EnvVar represents an environment variable for an app.
type EnvVar struct {
db *database.Database
ID int64
AppID string
Key string
Value string
}
// NewEnvVar creates a new EnvVar with a database reference.
func NewEnvVar(db *database.Database) *EnvVar {
return &EnvVar{db: db}
}
// Save inserts or updates the env var in the database.
func (e *EnvVar) Save(ctx context.Context) error {
if e.ID == 0 {
return e.insert(ctx)
}
return e.update(ctx)
}
// Delete removes the env var from the database.
func (e *EnvVar) Delete(ctx context.Context) error {
_, err := e.db.Exec(ctx, "DELETE FROM app_env_vars WHERE id = ?", e.ID)
return err
}
func (e *EnvVar) insert(ctx context.Context) error {
query := "INSERT INTO app_env_vars (app_id, key, value) VALUES (?, ?, ?)"
result, err := e.db.Exec(ctx, query, e.AppID, e.Key, e.Value)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
e.ID = id
return nil
}
func (e *EnvVar) update(ctx context.Context) error {
query := "UPDATE app_env_vars SET key = ?, value = ? WHERE id = ?"
_, err := e.db.Exec(ctx, query, e.Key, e.Value, e.ID)
return err
}
// FindEnvVar finds an env var by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindEnvVar(
ctx context.Context,
db *database.Database,
id int64,
) (*EnvVar, error) {
envVar := NewEnvVar(db)
row := db.QueryRow(ctx,
"SELECT id, app_id, key, value FROM app_env_vars WHERE id = ?",
id,
)
err := row.Scan(&envVar.ID, &envVar.AppID, &envVar.Key, &envVar.Value)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning env var: %w", err)
}
return envVar, nil
}
// FindEnvVarsByAppID finds all env vars for an app.
func FindEnvVarsByAppID(
ctx context.Context,
db *database.Database,
appID string,
) ([]*EnvVar, error) {
query := `
SELECT id, app_id, key, value FROM app_env_vars
WHERE app_id = ? ORDER BY key`
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying env vars by app: %w", err)
}
defer func() { _ = rows.Close() }()
var envVars []*EnvVar
for rows.Next() {
envVar := NewEnvVar(db)
scanErr := rows.Scan(
&envVar.ID, &envVar.AppID, &envVar.Key, &envVar.Value,
)
if scanErr != nil {
return nil, scanErr
}
envVars = append(envVars, envVar)
}
return envVars, rows.Err()
}
// DeleteEnvVarsByAppID deletes all env vars for an app.
func DeleteEnvVarsByAppID(
ctx context.Context,
db *database.Database,
appID string,
) error {
_, err := db.Exec(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
return err
}

139
internal/models/label.go Normal file
View File

@ -0,0 +1,139 @@
//nolint:dupl // Active Record pattern - similar structure to env_var.go is intentional
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"git.eeqj.de/sneak/upaas/internal/database"
)
// Label represents a Docker label for an app container.
type Label struct {
db *database.Database
ID int64
AppID string
Key string
Value string
}
// NewLabel creates a new Label with a database reference.
func NewLabel(db *database.Database) *Label {
return &Label{db: db}
}
// Save inserts or updates the label in the database.
func (l *Label) Save(ctx context.Context) error {
if l.ID == 0 {
return l.insert(ctx)
}
return l.update(ctx)
}
// Delete removes the label from the database.
func (l *Label) Delete(ctx context.Context) error {
_, err := l.db.Exec(ctx, "DELETE FROM app_labels WHERE id = ?", l.ID)
return err
}
func (l *Label) insert(ctx context.Context) error {
query := "INSERT INTO app_labels (app_id, key, value) VALUES (?, ?, ?)"
result, err := l.db.Exec(ctx, query, l.AppID, l.Key, l.Value)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
l.ID = id
return nil
}
func (l *Label) update(ctx context.Context) error {
query := "UPDATE app_labels SET key = ?, value = ? WHERE id = ?"
_, err := l.db.Exec(ctx, query, l.Key, l.Value, l.ID)
return err
}
// FindLabel finds a label by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindLabel(
ctx context.Context,
db *database.Database,
id int64,
) (*Label, error) {
label := NewLabel(db)
row := db.QueryRow(ctx,
"SELECT id, app_id, key, value FROM app_labels WHERE id = ?",
id,
)
err := row.Scan(&label.ID, &label.AppID, &label.Key, &label.Value)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning label: %w", err)
}
return label, nil
}
// FindLabelsByAppID finds all labels for an app.
func FindLabelsByAppID(
ctx context.Context,
db *database.Database,
appID string,
) ([]*Label, error) {
query := `
SELECT id, app_id, key, value FROM app_labels
WHERE app_id = ? ORDER BY key`
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying labels by app: %w", err)
}
defer func() { _ = rows.Close() }()
var labels []*Label
for rows.Next() {
label := NewLabel(db)
scanErr := rows.Scan(&label.ID, &label.AppID, &label.Key, &label.Value)
if scanErr != nil {
return nil, scanErr
}
labels = append(labels, label)
}
return labels, rows.Err()
}
// DeleteLabelsByAppID deletes all labels for an app.
func DeleteLabelsByAppID(
ctx context.Context,
db *database.Database,
appID string,
) error {
_, err := db.Exec(ctx, "DELETE FROM app_labels WHERE app_id = ?", appID)
return err
}

View File

@ -0,0 +1,801 @@
package models_test
import (
"context"
"database/sql"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
)
// Test constants to satisfy goconst linter.
const (
testHash = "hash"
testBranch = "main"
testValue = "value"
testEventType = "push"
)
func setupTestDB(t *testing.T) (*database.Database, func()) {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalVars, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
logr, err := logger.New(fx.Lifecycle(nil), logger.Params{
Globals: globalVars,
})
require.NoError(t, err)
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
}
testDB, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: logr,
Config: cfg,
})
require.NoError(t, err)
// t.TempDir() automatically cleans up after test
cleanup := func() {}
return testDB, cleanup
}
// User Tests.
func TestUserCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "testuser"
user.PasswordHash = "hashed_password"
err := user.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, user.ID)
assert.NotZero(t, user.CreatedAt)
found, err := models.FindUser(context.Background(), testDB, user.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, "testuser", found.Username)
}
func TestUserUpdate(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "original"
user.PasswordHash = "hash1"
err := user.Save(context.Background())
require.NoError(t, err)
user.Username = "updated"
user.PasswordHash = "hash2"
err = user.Save(context.Background())
require.NoError(t, err)
found, err := models.FindUser(context.Background(), testDB, user.ID)
require.NoError(t, err)
assert.Equal(t, "updated", found.Username)
assert.Equal(t, "hash2", found.PasswordHash)
}
func TestUserDelete(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "todelete"
user.PasswordHash = testHash
err := user.Save(context.Background())
require.NoError(t, err)
err = user.Delete(context.Background())
require.NoError(t, err)
found, err := models.FindUser(context.Background(), testDB, user.ID)
require.NoError(t, err)
assert.Nil(t, found)
}
func TestUserFindByUsername(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "findme"
user.PasswordHash = testHash
err := user.Save(context.Background())
require.NoError(t, err)
found, err := models.FindUserByUsername(
context.Background(), testDB, "findme",
)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, user.ID, found.ID)
}
func TestUserFindByUsernameNotFound(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
found, err := models.FindUserByUsername(
context.Background(), testDB, "nonexistent",
)
require.NoError(t, err)
assert.Nil(t, found)
}
func TestUserExists(t *testing.T) {
t.Parallel()
t.Run("returns false when no users", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
exists, err := models.UserExists(context.Background(), testDB)
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("returns true when user exists", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "admin"
user.PasswordHash = testHash
err := user.Save(context.Background())
require.NoError(t, err)
exists, err := models.UserExists(context.Background(), testDB)
require.NoError(t, err)
assert.True(t, exists)
})
}
// App Tests.
func TestAppCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
assert.NotZero(t, app.CreatedAt)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, "test-app", found.Name)
assert.Equal(t, models.AppStatusPending, found.Status)
}
func TestAppUpdate(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.Name = "updated"
app.Status = models.AppStatusRunning
app.ContainerID = sql.NullString{String: "container123", Valid: true}
err := app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
assert.Equal(t, "updated", found.Name)
assert.Equal(t, models.AppStatusRunning, found.Status)
assert.Equal(t, "container123", found.ContainerID.String)
}
func TestAppDelete(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
err := app.Delete(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
assert.Nil(t, found)
}
func TestAppFindByWebhookSecret(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
found, err := models.FindAppByWebhookSecret(
context.Background(), testDB, app.WebhookSecret,
)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, app.ID, found.ID)
}
func TestAllApps(t *testing.T) {
t.Parallel()
t.Run("returns empty list when no apps", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
apps, err := models.AllApps(context.Background(), testDB)
require.NoError(t, err)
assert.Empty(t, apps)
})
t.Run("returns apps ordered by name", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
names := []string{"zebra", "alpha", "mike"}
for idx, name := range names {
app := models.NewApp(testDB)
app.ID = name + "-id"
app.Name = name
app.RepoURL = "git@example.com:user/" + name + ".git"
app.Branch = testBranch
app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "secret-" + strconv.Itoa(idx)
app.SSHPrivateKey = "private"
app.SSHPublicKey = "public"
err := app.Save(context.Background())
require.NoError(t, err)
}
apps, err := models.AllApps(context.Background(), testDB)
require.NoError(t, err)
require.Len(t, apps, 3)
assert.Equal(t, "alpha", apps[0].Name)
assert.Equal(t, "mike", apps[1].Name)
assert.Equal(t, "zebra", apps[2].Name)
})
}
// EnvVar Tests.
func TestEnvVarCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds env vars", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
// Create app first.
app := createTestApp(t, testDB)
envVar := models.NewEnvVar(testDB)
envVar.AppID = app.ID
envVar.Key = "DATABASE_URL"
envVar.Value = "postgres://localhost/db"
err := envVar.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, envVar.ID)
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, envVars, 1)
assert.Equal(t, "DATABASE_URL", envVars[0].Key)
})
t.Run("deletes env var", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
envVar := models.NewEnvVar(testDB)
envVar.AppID = app.ID
envVar.Key = "TO_DELETE"
envVar.Value = testValue
err := envVar.Save(context.Background())
require.NoError(t, err)
err = envVar.Delete(context.Background())
require.NoError(t, err)
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
assert.Empty(t, envVars)
})
}
// Label Tests.
func TestLabelCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds labels", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "traefik.enable"
label.Value = "true"
err := label.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, label.ID)
labels, err := models.FindLabelsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, labels, 1)
assert.Equal(t, "traefik.enable", labels[0].Key)
})
}
// Volume Tests.
func TestVolumeCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds volumes", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
volume := models.NewVolume(testDB)
volume.AppID = app.ID
volume.HostPath = "/data/app"
volume.ContainerPath = "/app/data"
volume.ReadOnly = true
err := volume.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, volume.ID)
volumes, err := models.FindVolumesByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, volumes, 1)
assert.Equal(t, "/data/app", volumes[0].HostPath)
assert.True(t, volumes[0].ReadOnly)
})
}
// WebhookEvent Tests.
func TestWebhookEventCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds webhook events", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
event := models.NewWebhookEvent(testDB)
event.AppID = app.ID
event.EventType = testEventType
event.Branch = testBranch
event.CommitSHA = sql.NullString{String: "abc123", Valid: true}
event.Matched = true
err := event.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, event.ID)
events, err := models.FindWebhookEventsByAppID(
context.Background(), testDB, app.ID, 10,
)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, "push", events[0].EventType)
assert.True(t, events[0].Matched)
})
}
// Deployment Tests.
func TestDeploymentCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deployment := models.NewDeployment(testDB)
deployment.AppID = app.ID
deployment.CommitSHA = sql.NullString{String: "abc123def456", Valid: true}
deployment.Status = models.DeploymentStatusBuilding
err := deployment.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, deployment.ID)
assert.NotZero(t, deployment.StartedAt)
found, err := models.FindDeployment(context.Background(), testDB, deployment.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, models.DeploymentStatusBuilding, found.Status)
}
func TestDeploymentAppendLog(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deployment := models.NewDeployment(testDB)
deployment.AppID = app.ID
deployment.Status = models.DeploymentStatusBuilding
err := deployment.Save(context.Background())
require.NoError(t, err)
err = deployment.AppendLog(context.Background(), "Building image...")
require.NoError(t, err)
err = deployment.AppendLog(context.Background(), "Image built successfully")
require.NoError(t, err)
found, err := models.FindDeployment(context.Background(), testDB, deployment.ID)
require.NoError(t, err)
assert.Contains(t, found.Logs.String, "Building image...")
assert.Contains(t, found.Logs.String, "Image built successfully")
}
func TestDeploymentMarkFinished(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deployment := models.NewDeployment(testDB)
deployment.AppID = app.ID
deployment.Status = models.DeploymentStatusBuilding
err := deployment.Save(context.Background())
require.NoError(t, err)
err = deployment.MarkFinished(context.Background(), models.DeploymentStatusSuccess)
require.NoError(t, err)
found, err := models.FindDeployment(context.Background(), testDB, deployment.ID)
require.NoError(t, err)
assert.Equal(t, models.DeploymentStatusSuccess, found.Status)
assert.True(t, found.FinishedAt.Valid)
}
func TestDeploymentFindByAppID(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
for idx := range 5 {
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.Status = models.DeploymentStatusSuccess
deploy.CommitSHA = sql.NullString{
String: "commit" + strconv.Itoa(idx),
Valid: true,
}
err := deploy.Save(context.Background())
require.NoError(t, err)
}
deployments, err := models.FindDeploymentsByAppID(context.Background(), testDB, app.ID, 3)
require.NoError(t, err)
assert.Len(t, deployments, 3)
}
func TestDeploymentFindLatest(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
for idx := range 3 {
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.CommitSHA = sql.NullString{
String: "commit" + strconv.Itoa(idx),
Valid: true,
}
deploy.Status = models.DeploymentStatusSuccess
err := deploy.Save(context.Background())
require.NoError(t, err)
}
latest, err := models.LatestDeploymentForApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, latest)
assert.Equal(t, "commit2", latest.CommitSHA.String)
}
// App Helper Methods Tests.
func TestAppGetEnvVars(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
env1 := models.NewEnvVar(testDB)
env1.AppID = app.ID
env1.Key = "KEY1"
env1.Value = "value1"
_ = env1.Save(context.Background())
env2 := models.NewEnvVar(testDB)
env2.AppID = app.ID
env2.Key = "KEY2"
env2.Value = "value2"
_ = env2.Save(context.Background())
envVars, err := app.GetEnvVars(context.Background())
require.NoError(t, err)
assert.Len(t, envVars, 2)
}
func TestAppGetLabels(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "label.key"
label.Value = "label.value"
_ = label.Save(context.Background())
labels, err := app.GetLabels(context.Background())
require.NoError(t, err)
assert.Len(t, labels, 1)
}
func TestAppGetVolumes(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
vol := models.NewVolume(testDB)
vol.AppID = app.ID
vol.HostPath = "/host"
vol.ContainerPath = "/container"
_ = vol.Save(context.Background())
volumes, err := app.GetVolumes(context.Background())
require.NoError(t, err)
assert.Len(t, volumes, 1)
}
func TestAppGetDeployments(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.Status = models.DeploymentStatusSuccess
_ = deploy.Save(context.Background())
deployments, err := app.GetDeployments(context.Background(), 10)
require.NoError(t, err)
assert.Len(t, deployments, 1)
}
func TestAppGetWebhookEvents(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
event := models.NewWebhookEvent(testDB)
event.AppID = app.ID
event.EventType = testEventType
event.Branch = testBranch
event.Matched = true
_ = event.Save(context.Background())
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
assert.Len(t, events, 1)
}
// Cascade Delete Tests.
//nolint:funlen // Test function with many assertions - acceptable for integration tests
func TestCascadeDelete(t *testing.T) {
t.Parallel()
t.Run("deleting app cascades to related records", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
// Create related records.
env := models.NewEnvVar(testDB)
env.AppID = app.ID
env.Key = "KEY"
env.Value = "value"
_ = env.Save(context.Background())
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "key"
label.Value = "value"
_ = label.Save(context.Background())
vol := models.NewVolume(testDB)
vol.AppID = app.ID
vol.HostPath = "/host"
vol.ContainerPath = "/container"
_ = vol.Save(context.Background())
event := models.NewWebhookEvent(testDB)
event.AppID = app.ID
event.EventType = testEventType
event.Branch = testBranch
event.Matched = true
_ = event.Save(context.Background())
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.Status = models.DeploymentStatusSuccess
_ = deploy.Save(context.Background())
// Delete app.
err := app.Delete(context.Background())
require.NoError(t, err)
// Verify cascades.
envVars, _ := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
assert.Empty(t, envVars)
labels, _ := models.FindLabelsByAppID(
context.Background(), testDB, app.ID,
)
assert.Empty(t, labels)
volumes, _ := models.FindVolumesByAppID(
context.Background(), testDB, app.ID,
)
assert.Empty(t, volumes)
events, _ := models.FindWebhookEventsByAppID(
context.Background(), testDB, app.ID, 10,
)
assert.Empty(t, events)
deployments, _ := models.FindDeploymentsByAppID(
context.Background(), testDB, app.ID, 10,
)
assert.Empty(t, deployments)
})
}
// Helper function to create a test app.
func createTestApp(t *testing.T, testDB *database.Database) *models.App {
t.Helper()
app := models.NewApp(testDB)
app.ID = "test-app-" + t.Name()
app.Name = "test-app"
app.RepoURL = "git@example.com:user/repo.git"
app.Branch = testBranch
app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "secret-" + t.Name()
app.SSHPrivateKey = "private"
app.SSHPublicKey = "public"
err := app.Save(context.Background())
require.NoError(t, err)
return app
}

150
internal/models/user.go Normal file
View File

@ -0,0 +1,150 @@
// Package models provides Active Record style database models.
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
)
// User represents a user in the system.
type User struct {
db *database.Database
ID int64
Username string
PasswordHash string
CreatedAt time.Time
}
// NewUser creates a new User with a database reference.
func NewUser(db *database.Database) *User {
return &User{db: db}
}
// Save inserts or updates the user in the database.
func (u *User) Save(ctx context.Context) error {
if u.ID == 0 {
return u.insert(ctx)
}
return u.update(ctx)
}
// Delete removes the user from the database.
func (u *User) Delete(ctx context.Context) error {
_, err := u.db.Exec(ctx, "DELETE FROM users WHERE id = ?", u.ID)
return err
}
// Reload refreshes the user from the database.
func (u *User) Reload(ctx context.Context) error {
query := "SELECT id, username, password_hash, created_at FROM users WHERE id = ?"
row := u.db.QueryRow(ctx, query, u.ID)
return u.scan(row)
}
func (u *User) insert(ctx context.Context) error {
query := "INSERT INTO users (username, password_hash) VALUES (?, ?)"
result, err := u.db.Exec(ctx, query, u.Username, u.PasswordHash)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
u.ID = id
return u.Reload(ctx)
}
func (u *User) update(ctx context.Context) error {
query := "UPDATE users SET username = ?, password_hash = ? WHERE id = ?"
_, err := u.db.Exec(ctx, query, u.Username, u.PasswordHash, u.ID)
return err
}
func (u *User) scan(row *sql.Row) error {
return row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt)
}
// FindUser finds a user by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindUser(
ctx context.Context,
db *database.Database,
id int64,
) (*User, error) {
user := NewUser(db)
row := db.QueryRow(ctx,
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?",
id,
)
err := user.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning user: %w", err)
}
return user, nil
}
// FindUserByUsername finds a user by username.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindUserByUsername(
ctx context.Context,
db *database.Database,
username string,
) (*User, error) {
user := NewUser(db)
row := db.QueryRow(ctx,
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?",
username,
)
err := user.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning user by username: %w", err)
}
return user, nil
}
// UserExists checks if any user exists in the database.
func UserExists(ctx context.Context, db *database.Database) (bool, error) {
var count int
row := db.QueryRow(ctx, "SELECT COUNT(*) FROM users")
err := row.Scan(&count)
if err != nil {
return false, fmt.Errorf("counting users: %w", err)
}
return count > 0, nil
}

151
internal/models/volume.go Normal file
View File

@ -0,0 +1,151 @@
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"git.eeqj.de/sneak/upaas/internal/database"
)
// Volume represents a volume mount for an app container.
type Volume struct {
db *database.Database
ID int64
AppID string
HostPath string
ContainerPath string
ReadOnly bool
}
// NewVolume creates a new Volume with a database reference.
func NewVolume(db *database.Database) *Volume {
return &Volume{db: db}
}
// Save inserts or updates the volume in the database.
func (v *Volume) Save(ctx context.Context) error {
if v.ID == 0 {
return v.insert(ctx)
}
return v.update(ctx)
}
// Delete removes the volume from the database.
func (v *Volume) Delete(ctx context.Context) error {
_, err := v.db.Exec(ctx, "DELETE FROM app_volumes WHERE id = ?", v.ID)
return err
}
func (v *Volume) insert(ctx context.Context) error {
query := `
INSERT INTO app_volumes (app_id, host_path, container_path, readonly)
VALUES (?, ?, ?, ?)`
result, err := v.db.Exec(ctx, query,
v.AppID, v.HostPath, v.ContainerPath, v.ReadOnly,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
v.ID = id
return nil
}
func (v *Volume) update(ctx context.Context) error {
query := `
UPDATE app_volumes SET host_path = ?, container_path = ?, readonly = ?
WHERE id = ?`
_, err := v.db.Exec(ctx, query, v.HostPath, v.ContainerPath, v.ReadOnly, v.ID)
return err
}
// FindVolume finds a volume by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindVolume(
ctx context.Context,
db *database.Database,
id int64,
) (*Volume, error) {
vol := NewVolume(db)
query := `
SELECT id, app_id, host_path, container_path, readonly
FROM app_volumes WHERE id = ?`
row := db.QueryRow(ctx, query, id)
err := row.Scan(
&vol.ID, &vol.AppID, &vol.HostPath, &vol.ContainerPath, &vol.ReadOnly,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning volume: %w", err)
}
return vol, nil
}
// FindVolumesByAppID finds all volumes for an app.
func FindVolumesByAppID(
ctx context.Context,
db *database.Database,
appID string,
) ([]*Volume, error) {
query := `
SELECT id, app_id, host_path, container_path, readonly
FROM app_volumes WHERE app_id = ? ORDER BY container_path`
rows, err := db.Query(ctx, query, appID)
if err != nil {
return nil, fmt.Errorf("querying volumes by app: %w", err)
}
defer func() { _ = rows.Close() }()
var volumes []*Volume
for rows.Next() {
vol := NewVolume(db)
scanErr := rows.Scan(
&vol.ID, &vol.AppID, &vol.HostPath,
&vol.ContainerPath, &vol.ReadOnly,
)
if scanErr != nil {
return nil, scanErr
}
volumes = append(volumes, vol)
}
return volumes, rows.Err()
}
// DeleteVolumesByAppID deletes all volumes for an app.
func DeleteVolumesByAppID(
ctx context.Context,
db *database.Database,
appID string,
) error {
_, err := db.Exec(ctx, "DELETE FROM app_volumes WHERE app_id = ?", appID)
return err
}

View File

@ -0,0 +1,198 @@
package models
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
)
// WebhookEvent represents a received webhook event.
type WebhookEvent struct {
db *database.Database
ID int64
AppID string
EventType string
Branch string
CommitSHA sql.NullString
Payload sql.NullString
Matched bool
Processed bool
CreatedAt time.Time
}
// NewWebhookEvent creates a new WebhookEvent with a database reference.
func NewWebhookEvent(db *database.Database) *WebhookEvent {
return &WebhookEvent{db: db}
}
// Save inserts or updates the webhook event in the database.
func (w *WebhookEvent) Save(ctx context.Context) error {
if w.ID == 0 {
return w.insert(ctx)
}
return w.update(ctx)
}
// Reload refreshes the webhook event from the database.
func (w *WebhookEvent) Reload(ctx context.Context) error {
query := `
SELECT id, app_id, event_type, branch, commit_sha, payload,
matched, processed, created_at
FROM webhook_events WHERE id = ?`
row := w.db.QueryRow(ctx, query, w.ID)
return w.scan(row)
}
func (w *WebhookEvent) insert(ctx context.Context) error {
query := `
INSERT INTO webhook_events (
app_id, event_type, branch, commit_sha, payload, matched, processed
) VALUES (?, ?, ?, ?, ?, ?, ?)`
result, err := w.db.Exec(ctx, query,
w.AppID, w.EventType, w.Branch, w.CommitSHA,
w.Payload, w.Matched, w.Processed,
)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
w.ID = id
return w.Reload(ctx)
}
func (w *WebhookEvent) update(ctx context.Context) error {
query := "UPDATE webhook_events SET processed = ? WHERE id = ?"
_, err := w.db.Exec(ctx, query, w.Processed, w.ID)
return err
}
func (w *WebhookEvent) scan(row *sql.Row) error {
return row.Scan(
&w.ID, &w.AppID, &w.EventType, &w.Branch, &w.CommitSHA,
&w.Payload, &w.Matched, &w.Processed, &w.CreatedAt,
)
}
// FindWebhookEvent finds a webhook event by ID.
//
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindWebhookEvent(
ctx context.Context,
db *database.Database,
id int64,
) (*WebhookEvent, error) {
event := NewWebhookEvent(db)
event.ID = id
row := db.QueryRow(ctx, `
SELECT id, app_id, event_type, branch, commit_sha, payload,
matched, processed, created_at
FROM webhook_events WHERE id = ?`,
id,
)
err := event.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("scanning webhook event: %w", err)
}
return event, nil
}
// FindWebhookEventsByAppID finds recent webhook events for an app.
func FindWebhookEventsByAppID(
ctx context.Context,
db *database.Database,
appID string,
limit int,
) ([]*WebhookEvent, error) {
query := `
SELECT id, app_id, event_type, branch, commit_sha, payload,
matched, processed, created_at
FROM webhook_events WHERE app_id = ? ORDER BY created_at DESC LIMIT ?`
rows, err := db.Query(ctx, query, appID, limit)
if err != nil {
return nil, fmt.Errorf("querying webhook events by app: %w", err)
}
defer func() { _ = rows.Close() }()
var events []*WebhookEvent
for rows.Next() {
event := NewWebhookEvent(db)
scanErr := rows.Scan(
&event.ID, &event.AppID, &event.EventType, &event.Branch,
&event.CommitSHA, &event.Payload, &event.Matched,
&event.Processed, &event.CreatedAt,
)
if scanErr != nil {
return nil, scanErr
}
events = append(events, event)
}
return events, rows.Err()
}
// FindUnprocessedWebhookEvents finds unprocessed matched webhook events.
func FindUnprocessedWebhookEvents(
ctx context.Context,
db *database.Database,
) ([]*WebhookEvent, error) {
query := `
SELECT id, app_id, event_type, branch, commit_sha, payload,
matched, processed, created_at
FROM webhook_events
WHERE matched = 1 AND processed = 0 ORDER BY created_at ASC`
rows, err := db.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("querying unprocessed webhook events: %w", err)
}
defer func() { _ = rows.Close() }()
var events []*WebhookEvent
for rows.Next() {
event := NewWebhookEvent(db)
scanErr := rows.Scan(
&event.ID, &event.AppID, &event.EventType, &event.Branch,
&event.CommitSHA, &event.Payload, &event.Matched,
&event.Processed, &event.CreatedAt,
)
if scanErr != nil {
return nil, scanErr
}
events = append(events, event)
}
return events, rows.Err()
}

88
internal/server/routes.go Normal file
View File

@ -0,0 +1,88 @@
package server
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"git.eeqj.de/sneak/upaas/static"
)
// requestTimeout is the maximum duration for handling a request.
const requestTimeout = 60 * time.Second
// SetupRoutes configures all HTTP routes.
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
// Global middleware
s.router.Use(chimw.Recoverer)
s.router.Use(chimw.RequestID)
s.router.Use(s.mw.Logging())
s.router.Use(s.mw.CORS())
s.router.Use(chimw.Timeout(requestTimeout))
s.router.Use(s.mw.SetupRequired())
// Health check (no auth required)
s.router.Get("/health", s.handlers.HandleHealthCheck())
// Static files
s.router.Handle("/static/*", http.StripPrefix(
"/static/",
http.FileServer(http.FS(static.Static)),
))
// Public routes
s.router.Get("/login", s.handlers.HandleLoginGET())
s.router.Post("/login", s.handlers.HandleLoginPOST())
s.router.Get("/setup", s.handlers.HandleSetupGET())
s.router.Post("/setup", s.handlers.HandleSetupPOST())
// Webhook endpoint (uses secret for auth, not session)
s.router.Post("/webhook/{secret}", s.handlers.HandleWebhook())
// Protected routes (require session auth)
s.router.Group(func(r chi.Router) {
r.Use(s.mw.SessionAuth())
// Dashboard
r.Get("/", s.handlers.HandleDashboard())
// Logout
r.Get("/logout", s.handlers.HandleLogout())
// App routes
r.Get("/apps/new", s.handlers.HandleAppNew())
r.Post("/apps", s.handlers.HandleAppCreate())
r.Get("/apps/{id}", s.handlers.HandleAppDetail())
r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit())
r.Post("/apps/{id}", s.handlers.HandleAppUpdate())
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
// Environment variables
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
// Volumes
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
})
// Metrics endpoint (optional, with basic auth)
if s.params.Config.MetricsUsername != "" {
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get("/metrics", promhttp.Handler().ServeHTTP)
})
}
}

121
internal/server/server.go Normal file
View File

@ -0,0 +1,121 @@
// Package server provides the HTTP server.
package server
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware"
)
// Params contains dependencies for Server.
type Params struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Middleware *middleware.Middleware
Handlers *handlers.Handlers
}
// shutdownTimeout is how long to wait for graceful shutdown.
const shutdownTimeout = 30 * time.Second
// readHeaderTimeout is the maximum duration for reading request headers.
const readHeaderTimeout = 10 * time.Second
// Server is the HTTP server.
type Server struct {
startupTime time.Time
port int
log *slog.Logger
router *chi.Mux
httpServer *http.Server
params Params
mw *middleware.Middleware
handlers *handlers.Handlers
}
// New creates a new Server instance.
func New(lifecycle fx.Lifecycle, params Params) (*Server, error) {
srv := &Server{
port: params.Config.Port,
log: params.Logger.Get(),
params: params,
mw: params.Middleware,
handlers: params.Handlers,
}
lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error {
srv.startupTime = time.Now()
go srv.Run()
return nil
},
OnStop: func(ctx context.Context) error {
return srv.Shutdown(ctx)
},
})
return srv, nil
}
// Run starts the HTTP server.
func (s *Server) Run() {
s.SetupRoutes()
listenAddr := fmt.Sprintf(":%d", s.port)
s.httpServer = &http.Server{
Addr: listenAddr,
Handler: s,
ReadHeaderTimeout: readHeaderTimeout,
}
s.log.Info("http server starting", "addr", listenAddr)
err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("http server error", "error", err)
}
}
// Shutdown gracefully shuts down the server.
func (s *Server) Shutdown(ctx context.Context) error {
if s.httpServer == nil {
return nil
}
s.log.Info("shutting down http server")
shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout)
defer cancel()
err := s.httpServer.Shutdown(shutdownCtx)
if err != nil {
s.log.Error("http server shutdown error", "error", err)
return fmt.Errorf("shutting down http server: %w", err)
}
s.log.Info("http server stopped")
return nil
}
// ServeHTTP implements http.Handler.
func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
s.router.ServeHTTP(writer, request)
}

343
internal/service/app/app.go Normal file
View File

@ -0,0 +1,343 @@
// Package app provides application management services.
package app
import (
"context"
"database/sql"
"fmt"
"log/slog"
"github.com/google/uuid"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/ssh"
)
// ServiceParams contains dependencies for Service.
type ServiceParams struct {
fx.In
Logger *logger.Logger
Database *database.Database
}
// Service provides app management functionality.
type Service struct {
log *slog.Logger
db *database.Database
params *ServiceParams
}
// New creates a new app Service.
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
return &Service{
log: params.Logger.Get(),
db: params.Database,
params: &params,
}, nil
}
// CreateAppInput contains the input for creating an app.
type CreateAppInput struct {
Name string
RepoURL string
Branch string
DockerfilePath string
DockerNetwork string
NtfyTopic string
SlackWebhook string
}
// CreateApp creates a new application with generated SSH keys and webhook secret.
func (svc *Service) CreateApp(
ctx context.Context,
input CreateAppInput,
) (*models.App, error) {
// Generate SSH key pair
keyPair, err := ssh.GenerateKeyPair()
if err != nil {
return nil, fmt.Errorf("failed to generate SSH key pair: %w", err)
}
// Create app
app := models.NewApp(svc.db)
app.ID = uuid.New().String()
app.Name = input.Name
app.RepoURL = input.RepoURL
app.Branch = input.Branch
if app.Branch == "" {
app.Branch = "main"
}
app.DockerfilePath = input.DockerfilePath
if app.DockerfilePath == "" {
app.DockerfilePath = "Dockerfile"
}
app.WebhookSecret = uuid.New().String()
app.SSHPrivateKey = keyPair.PrivateKey
app.SSHPublicKey = keyPair.PublicKey
app.Status = models.AppStatusPending
if input.DockerNetwork != "" {
app.DockerNetwork = sql.NullString{String: input.DockerNetwork, Valid: true}
}
if input.NtfyTopic != "" {
app.NtfyTopic = sql.NullString{String: input.NtfyTopic, Valid: true}
}
if input.SlackWebhook != "" {
app.SlackWebhook = sql.NullString{String: input.SlackWebhook, Valid: true}
}
saveErr := app.Save(ctx)
if saveErr != nil {
return nil, fmt.Errorf("failed to save app: %w", saveErr)
}
svc.log.Info("app created", "id", app.ID, "name", app.Name)
return app, nil
}
// UpdateAppInput contains the input for updating an app.
type UpdateAppInput struct {
Name string
RepoURL string
Branch string
DockerfilePath string
DockerNetwork string
NtfyTopic string
SlackWebhook string
}
// UpdateApp updates an existing application.
func (svc *Service) UpdateApp(
ctx context.Context,
app *models.App,
input UpdateAppInput,
) error {
app.Name = input.Name
app.RepoURL = input.RepoURL
app.Branch = input.Branch
app.DockerfilePath = input.DockerfilePath
app.DockerNetwork = sql.NullString{
String: input.DockerNetwork,
Valid: input.DockerNetwork != "",
}
app.NtfyTopic = sql.NullString{
String: input.NtfyTopic,
Valid: input.NtfyTopic != "",
}
app.SlackWebhook = sql.NullString{
String: input.SlackWebhook,
Valid: input.SlackWebhook != "",
}
saveErr := app.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to save app: %w", saveErr)
}
svc.log.Info("app updated", "id", app.ID, "name", app.Name)
return nil
}
// DeleteApp deletes an application and its related data.
func (svc *Service) DeleteApp(ctx context.Context, app *models.App) error {
// Related data is deleted by CASCADE
deleteErr := app.Delete(ctx)
if deleteErr != nil {
return fmt.Errorf("failed to delete app: %w", deleteErr)
}
svc.log.Info("app deleted", "id", app.ID, "name", app.Name)
return nil
}
// GetApp retrieves an app by ID.
func (svc *Service) GetApp(ctx context.Context, appID string) (*models.App, error) {
app, err := models.FindApp(ctx, svc.db, appID)
if err != nil {
return nil, fmt.Errorf("failed to find app: %w", err)
}
return app, nil
}
// GetAppByWebhookSecret retrieves an app by webhook secret.
func (svc *Service) GetAppByWebhookSecret(
ctx context.Context,
secret string,
) (*models.App, error) {
app, err := models.FindAppByWebhookSecret(ctx, svc.db, secret)
if err != nil {
return nil, fmt.Errorf("failed to find app by webhook secret: %w", err)
}
return app, nil
}
// ListApps returns all apps.
func (svc *Service) ListApps(ctx context.Context) ([]*models.App, error) {
apps, err := models.AllApps(ctx, svc.db)
if err != nil {
return nil, fmt.Errorf("failed to list apps: %w", err)
}
return apps, nil
}
// AddEnvVar adds an environment variable to an app.
func (svc *Service) AddEnvVar(
ctx context.Context,
appID, key, value string,
) error {
envVar := models.NewEnvVar(svc.db)
envVar.AppID = appID
envVar.Key = key
envVar.Value = value
saveErr := envVar.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to save env var: %w", saveErr)
}
return nil
}
// DeleteEnvVar deletes an environment variable.
func (svc *Service) DeleteEnvVar(ctx context.Context, envVarID int64) error {
envVar, err := models.FindEnvVar(ctx, svc.db, envVarID)
if err != nil {
return fmt.Errorf("failed to find env var: %w", err)
}
if envVar == nil {
return nil
}
deleteErr := envVar.Delete(ctx)
if deleteErr != nil {
return fmt.Errorf("failed to delete env var: %w", deleteErr)
}
return nil
}
// AddLabel adds a label to an app.
func (svc *Service) AddLabel(
ctx context.Context,
appID, key, value string,
) error {
label := models.NewLabel(svc.db)
label.AppID = appID
label.Key = key
label.Value = value
saveErr := label.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to save label: %w", saveErr)
}
return nil
}
// DeleteLabel deletes a label.
func (svc *Service) DeleteLabel(ctx context.Context, labelID int64) error {
label, err := models.FindLabel(ctx, svc.db, labelID)
if err != nil {
return fmt.Errorf("failed to find label: %w", err)
}
if label == nil {
return nil
}
deleteErr := label.Delete(ctx)
if deleteErr != nil {
return fmt.Errorf("failed to delete label: %w", deleteErr)
}
return nil
}
// AddVolume adds a volume mount to an app.
func (svc *Service) AddVolume(
ctx context.Context,
appID, hostPath, containerPath string,
readonly bool,
) error {
volume := models.NewVolume(svc.db)
volume.AppID = appID
volume.HostPath = hostPath
volume.ContainerPath = containerPath
volume.ReadOnly = readonly
saveErr := volume.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to save volume: %w", saveErr)
}
return nil
}
// DeleteVolume deletes a volume mount.
func (svc *Service) DeleteVolume(ctx context.Context, volumeID int64) error {
volume, err := models.FindVolume(ctx, svc.db, volumeID)
if err != nil {
return fmt.Errorf("failed to find volume: %w", err)
}
if volume == nil {
return nil
}
deleteErr := volume.Delete(ctx)
if deleteErr != nil {
return fmt.Errorf("failed to delete volume: %w", deleteErr)
}
return nil
}
// UpdateAppStatus updates the status of an app.
func (svc *Service) UpdateAppStatus(
ctx context.Context,
app *models.App,
status models.AppStatus,
) error {
app.Status = status
saveErr := app.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to save app status: %w", saveErr)
}
return nil
}
// UpdateAppContainer updates the container ID of an app.
func (svc *Service) UpdateAppContainer(
ctx context.Context,
app *models.App,
containerID, imageID string,
) error {
app.ContainerID = sql.NullString{String: containerID, Valid: containerID != ""}
app.ImageID = sql.NullString{String: imageID, Valid: imageID != ""}
saveErr := app.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to save app container: %w", saveErr)
}
return nil
}

View File

@ -0,0 +1,636 @@
package app_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
)
func setupTestService(t *testing.T) (*app.Service, func()) {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalsInst},
)
require.NoError(t, err)
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
}
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: loggerInst,
Config: cfg,
})
require.NoError(t, err)
svc, err := app.New(fx.Lifecycle(nil), app.ServiceParams{
Logger: loggerInst,
Database: dbInst,
})
require.NoError(t, err)
// t.TempDir() automatically cleans up after test
cleanup := func() {}
return svc, cleanup
}
// deleteItemTestHelper is a generic helper for testing delete operations.
// It creates an app, adds an item, verifies it exists, deletes it, and verifies it's gone.
func deleteItemTestHelper(
t *testing.T,
appName string,
addItem func(ctx context.Context, svc *app.Service, appID string) error,
getCount func(ctx context.Context, application *models.App) (int, error),
deleteItem func(ctx context.Context, svc *app.Service, application *models.App) error,
) {
t.Helper()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: appName,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = addItem(context.Background(), svc, createdApp.ID)
require.NoError(t, err)
count, err := getCount(context.Background(), createdApp)
require.NoError(t, err)
require.Equal(t, 1, count)
err = deleteItem(context.Background(), svc, createdApp)
require.NoError(t, err)
count, err = getCount(context.Background(), createdApp)
require.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestCreateAppWithGeneratedKeys(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
input := app.CreateAppInput{
Name: "test-app",
RepoURL: "git@gitea.example.com:user/repo.git",
Branch: "main",
DockerfilePath: "Dockerfile",
}
createdApp, err := svc.CreateApp(context.Background(), input)
require.NoError(t, err)
require.NotNil(t, createdApp)
assert.Equal(t, "test-app", createdApp.Name)
assert.Equal(t, "git@gitea.example.com:user/repo.git", createdApp.RepoURL)
assert.Equal(t, "main", createdApp.Branch)
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
assert.NotEmpty(t, createdApp.ID)
assert.NotEmpty(t, createdApp.WebhookSecret)
assert.NotEmpty(t, createdApp.SSHPrivateKey)
assert.NotEmpty(t, createdApp.SSHPublicKey)
assert.Contains(t, createdApp.SSHPrivateKey, "-----BEGIN OPENSSH PRIVATE KEY-----")
assert.Contains(t, createdApp.SSHPublicKey, "ssh-ed25519")
assert.Equal(t, models.AppStatusPending, createdApp.Status)
}
func TestCreateAppDefaults(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
input := app.CreateAppInput{
Name: "test-app-defaults",
RepoURL: "git@gitea.example.com:user/repo.git",
}
createdApp, err := svc.CreateApp(context.Background(), input)
require.NoError(t, err)
assert.Equal(t, "main", createdApp.Branch)
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
}
func TestCreateAppOptionalFields(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
input := app.CreateAppInput{
Name: "test-app-full",
RepoURL: "git@gitea.example.com:user/repo.git",
Branch: "develop",
DockerNetwork: "my-network",
NtfyTopic: "https://ntfy.sh/my-topic",
SlackWebhook: "https://hooks.slack.com/services/xxx",
}
createdApp, err := svc.CreateApp(context.Background(), input)
require.NoError(t, err)
assert.True(t, createdApp.DockerNetwork.Valid)
assert.Equal(t, "my-network", createdApp.DockerNetwork.String)
assert.True(t, createdApp.NtfyTopic.Valid)
assert.Equal(t, "https://ntfy.sh/my-topic", createdApp.NtfyTopic.String)
assert.True(t, createdApp.SlackWebhook.Valid)
}
func TestUpdateApp(testingT *testing.T) {
testingT.Parallel()
testingT.Run("updates app fields", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "original-name",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
Name: "updated-name",
RepoURL: "git@example.com:user/new-repo.git",
Branch: "develop",
DockerfilePath: "docker/Dockerfile",
DockerNetwork: "prod-network",
})
require.NoError(t, err)
// Reload and verify
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
require.NoError(t, err)
assert.Equal(t, "updated-name", reloaded.Name)
assert.Equal(t, "git@example.com:user/new-repo.git", reloaded.RepoURL)
assert.Equal(t, "develop", reloaded.Branch)
assert.Equal(t, "docker/Dockerfile", reloaded.DockerfilePath)
assert.Equal(t, "prod-network", reloaded.DockerNetwork.String)
})
testingT.Run("clears optional fields when empty", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "test-clear",
RepoURL: "git@example.com:user/repo.git",
NtfyTopic: "https://ntfy.sh/topic",
SlackWebhook: "https://slack.com/hook",
})
require.NoError(t, err)
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
Name: "test-clear",
RepoURL: "git@example.com:user/repo.git",
Branch: "main",
})
require.NoError(t, err)
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
require.NoError(t, err)
assert.False(t, reloaded.NtfyTopic.Valid)
assert.False(t, reloaded.SlackWebhook.Valid)
})
}
func TestDeleteApp(testingT *testing.T) {
testingT.Parallel()
testingT.Run("deletes app and returns nil on lookup", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "to-delete",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.DeleteApp(context.Background(), createdApp)
require.NoError(t, err)
deleted, err := svc.GetApp(context.Background(), createdApp.ID)
require.NoError(t, err)
assert.Nil(t, deleted)
})
}
func TestGetApp(testingT *testing.T) {
testingT.Parallel()
testingT.Run("finds existing app", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "findable-app",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
found, err := svc.GetApp(context.Background(), created.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, created.ID, found.ID)
assert.Equal(t, "findable-app", found.Name)
})
testingT.Run("returns nil for non-existent app", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
found, err := svc.GetApp(context.Background(), "non-existent-id")
require.NoError(t, err)
assert.Nil(t, found)
})
}
func TestGetAppByWebhookSecret(testingT *testing.T) {
testingT.Parallel()
testingT.Run("finds app by webhook secret", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "webhook-app",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
found, err := svc.GetAppByWebhookSecret(context.Background(), created.WebhookSecret)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, created.ID, found.ID)
})
testingT.Run("returns nil for invalid secret", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
found, err := svc.GetAppByWebhookSecret(context.Background(), "invalid-secret")
require.NoError(t, err)
assert.Nil(t, found)
})
}
func TestListApps(testingT *testing.T) {
testingT.Parallel()
testingT.Run("returns empty list when no apps", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
apps, err := svc.ListApps(context.Background())
require.NoError(t, err)
assert.Empty(t, apps)
})
testingT.Run("returns all apps ordered by name", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "charlie",
RepoURL: "git@example.com:user/c.git",
})
require.NoError(t, err)
_, err = svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "alpha",
RepoURL: "git@example.com:user/a.git",
})
require.NoError(t, err)
_, err = svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "bravo",
RepoURL: "git@example.com:user/b.git",
})
require.NoError(t, err)
apps, err := svc.ListApps(context.Background())
require.NoError(t, err)
require.Len(t, apps, 3)
assert.Equal(t, "alpha", apps[0].Name)
assert.Equal(t, "bravo", apps[1].Name)
assert.Equal(t, "charlie", apps[2].Name)
})
}
func TestEnvVarsAddAndRetrieve(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "env-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.AddEnvVar(
context.Background(),
createdApp.ID,
"DATABASE_URL",
"postgres://localhost/db",
)
require.NoError(t, err)
err = svc.AddEnvVar(
context.Background(),
createdApp.ID,
"API_KEY",
"secret123",
)
require.NoError(t, err)
envVars, err := createdApp.GetEnvVars(context.Background())
require.NoError(t, err)
require.Len(t, envVars, 2)
keys := make(map[string]string)
for _, envVar := range envVars {
keys[envVar.Key] = envVar.Value
}
assert.Equal(t, "postgres://localhost/db", keys["DATABASE_URL"])
assert.Equal(t, "secret123", keys["API_KEY"])
}
func TestEnvVarsDelete(t *testing.T) {
t.Parallel()
deleteItemTestHelper(t, "env-delete-test",
func(ctx context.Context, svc *app.Service, appID string) error {
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
},
func(ctx context.Context, application *models.App) (int, error) {
envVars, err := application.GetEnvVars(ctx)
return len(envVars), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
envVars, err := application.GetEnvVars(ctx)
if err != nil {
return err
}
return svc.DeleteEnvVar(ctx, envVars[0].ID)
},
)
}
func TestLabels(testingT *testing.T) {
testingT.Parallel()
testingT.Run("adds and retrieves labels", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "label-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.AddLabel(context.Background(), createdApp.ID, "traefik.enable", "true")
require.NoError(t, err)
err = svc.AddLabel(
context.Background(),
createdApp.ID,
"com.example.env",
"production",
)
require.NoError(t, err)
labels, err := createdApp.GetLabels(context.Background())
require.NoError(t, err)
require.Len(t, labels, 2)
})
testingT.Run("deletes label", func(t *testing.T) {
t.Parallel()
deleteItemTestHelper(t, "label-delete-test",
func(ctx context.Context, svc *app.Service, appID string) error {
return svc.AddLabel(ctx, appID, "to.delete", "value")
},
func(ctx context.Context, application *models.App) (int, error) {
labels, err := application.GetLabels(ctx)
return len(labels), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
labels, err := application.GetLabels(ctx)
if err != nil {
return err
}
return svc.DeleteLabel(ctx, labels[0].ID)
},
)
})
}
func TestVolumesAddAndRetrieve(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "volume-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.AddVolume(
context.Background(),
createdApp.ID,
"/host/data",
"/app/data",
false,
)
require.NoError(t, err)
err = svc.AddVolume(
context.Background(),
createdApp.ID,
"/host/config",
"/app/config",
true,
)
require.NoError(t, err)
volumes, err := createdApp.GetVolumes(context.Background())
require.NoError(t, err)
require.Len(t, volumes, 2)
// Find readonly volume
var readonlyVolume *models.Volume
for _, vol := range volumes {
if vol.ReadOnly {
readonlyVolume = vol
break
}
}
require.NotNil(t, readonlyVolume)
assert.Equal(t, "/host/config", readonlyVolume.HostPath)
assert.Equal(t, "/app/config", readonlyVolume.ContainerPath)
}
func TestVolumesDelete(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "volume-delete-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.AddVolume(
context.Background(),
createdApp.ID,
"/host/path",
"/container/path",
false,
)
require.NoError(t, err)
volumes, err := createdApp.GetVolumes(context.Background())
require.NoError(t, err)
require.Len(t, volumes, 1)
err = svc.DeleteVolume(context.Background(), volumes[0].ID)
require.NoError(t, err)
volumes, err = createdApp.GetVolumes(context.Background())
require.NoError(t, err)
assert.Empty(t, volumes)
}
func TestUpdateAppStatus(testingT *testing.T) {
testingT.Parallel()
testingT.Run("updates app status", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "status-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
assert.Equal(t, models.AppStatusPending, createdApp.Status)
err = svc.UpdateAppStatus(
context.Background(),
createdApp,
models.AppStatusBuilding,
)
require.NoError(t, err)
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
require.NoError(t, err)
assert.Equal(t, models.AppStatusBuilding, reloaded.Status)
})
}
func TestUpdateAppContainer(testingT *testing.T) {
testingT.Parallel()
testingT.Run("updates container and image IDs", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "container-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
assert.False(t, createdApp.ContainerID.Valid)
assert.False(t, createdApp.ImageID.Valid)
err = svc.UpdateAppContainer(
context.Background(),
createdApp,
"container123",
"image456",
)
require.NoError(t, err)
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
require.NoError(t, err)
assert.True(t, reloaded.ContainerID.Valid)
assert.Equal(t, "container123", reloaded.ContainerID.String)
assert.True(t, reloaded.ImageID.Valid)
assert.Equal(t, "image456", reloaded.ImageID.String)
})
}

View File

@ -0,0 +1,286 @@
// Package auth provides authentication services.
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/gorilla/sessions"
"go.uber.org/fx"
"golang.org/x/crypto/argon2"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
)
const (
sessionName = "upaas_session"
sessionUserID = "user_id"
)
// Argon2 parameters.
const (
argonTime = 1
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
saltLen = 16
)
// Session duration constants.
const (
sessionMaxAgeDays = 7
sessionMaxAgeSeconds = 86400 * sessionMaxAgeDays
)
var (
// ErrInvalidCredentials is returned when username/password is incorrect.
ErrInvalidCredentials = errors.New("invalid credentials")
// ErrUserExists is returned when trying to create a user that already exists.
ErrUserExists = errors.New("user already exists")
)
// ServiceParams contains dependencies for Service.
type ServiceParams struct {
fx.In
Logger *logger.Logger
Config *config.Config
Database *database.Database
}
// Service provides authentication functionality.
type Service struct {
log *slog.Logger
db *database.Database
store *sessions.CookieStore
params *ServiceParams
}
// New creates a new auth Service.
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
store := sessions.NewCookieStore([]byte(params.Config.SessionSecret))
store.Options = &sessions.Options{
Path: "/",
MaxAge: sessionMaxAgeSeconds,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
return &Service{
log: params.Logger.Get(),
db: params.Database,
store: store,
params: &params,
}, nil
}
// HashPassword hashes a password using Argon2id.
func (svc *Service) HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
_, err := rand.Read(salt)
if err != nil {
return "", fmt.Errorf("failed to generate salt: %w", err)
}
hash := argon2.IDKey(
[]byte(password),
salt,
argonTime,
argonMemory,
argonThreads,
argonKeyLen,
)
// Encode as base64: salt$hash
saltB64 := base64.StdEncoding.EncodeToString(salt)
hashB64 := base64.StdEncoding.EncodeToString(hash)
return saltB64 + "$" + hashB64, nil
}
// VerifyPassword verifies a password against a hash.
func (svc *Service) VerifyPassword(hashedPassword, password string) bool {
// Parse salt$hash format using strings.Cut (more reliable than fmt.Sscanf)
saltB64, hashB64, found := strings.Cut(hashedPassword, "$")
if !found || saltB64 == "" || hashB64 == "" {
return false
}
salt, err := base64.StdEncoding.DecodeString(saltB64)
if err != nil {
return false
}
expectedHash, err := base64.StdEncoding.DecodeString(hashB64)
if err != nil {
return false
}
// Compute hash with same parameters
computedHash := argon2.IDKey(
[]byte(password),
salt,
argonTime,
argonMemory,
argonThreads,
argonKeyLen,
)
// Constant-time comparison
if len(computedHash) != len(expectedHash) {
return false
}
var result byte
for idx := range computedHash {
result |= computedHash[idx] ^ expectedHash[idx]
}
return result == 0
}
// IsSetupRequired checks if initial setup is needed (no users exist).
func (svc *Service) IsSetupRequired(ctx context.Context) (bool, error) {
exists, err := models.UserExists(ctx, svc.db)
if err != nil {
return false, fmt.Errorf("failed to check if user exists: %w", err)
}
return !exists, nil
}
// CreateUser creates the initial admin user.
func (svc *Service) CreateUser(
ctx context.Context,
username, password string,
) (*models.User, error) {
// Check if user already exists
exists, err := models.UserExists(ctx, svc.db)
if err != nil {
return nil, fmt.Errorf("failed to check if user exists: %w", err)
}
if exists {
return nil, ErrUserExists
}
// Hash password
hash, err := svc.HashPassword(password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Create user
user := models.NewUser(svc.db)
user.Username = username
user.PasswordHash = hash
err = user.Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed to save user: %w", err)
}
svc.log.Info("user created", "username", username)
return user, nil
}
// Authenticate validates credentials and returns the user.
func (svc *Service) Authenticate(
ctx context.Context,
username, password string,
) (*models.User, error) {
user, err := models.FindUserByUsername(ctx, svc.db, username)
if err != nil {
return nil, fmt.Errorf("failed to find user: %w", err)
}
if user == nil {
return nil, ErrInvalidCredentials
}
if !svc.VerifyPassword(user.PasswordHash, password) {
return nil, ErrInvalidCredentials
}
return user, nil
}
// CreateSession creates a session for the user.
func (svc *Service) CreateSession(
respWriter http.ResponseWriter,
request *http.Request,
user *models.User,
) error {
session, err := svc.store.Get(request, sessionName)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
}
session.Values[sessionUserID] = user.ID
saveErr := session.Save(request, respWriter)
if saveErr != nil {
return fmt.Errorf("failed to save session: %w", saveErr)
}
return nil
}
// GetCurrentUser returns the currently logged-in user, or nil if not logged in.
//
//nolint:nilerr // Session errors are not propagated - they indicate no user
func (svc *Service) GetCurrentUser(
ctx context.Context,
request *http.Request,
) (*models.User, error) {
session, sessionErr := svc.store.Get(request, sessionName)
if sessionErr != nil {
// Session error means no user - this is not an error condition
return nil, nil //nolint:nilnil // Expected behavior for no session
}
userID, ok := session.Values[sessionUserID].(int64)
if !ok {
return nil, nil //nolint:nilnil // No user ID in session is valid
}
user, err := models.FindUser(ctx, svc.db, userID)
if err != nil {
return nil, fmt.Errorf("failed to find user: %w", err)
}
return user, nil
}
// DestroySession destroys the current session.
func (svc *Service) DestroySession(
respWriter http.ResponseWriter,
request *http.Request,
) error {
session, err := svc.store.Get(request, sessionName)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
}
session.Options.MaxAge = -1 * int(time.Second)
saveErr := session.Save(request, respWriter)
if saveErr != nil {
return fmt.Errorf("failed to save session: %w", saveErr)
}
return nil
}

View File

@ -0,0 +1,243 @@
package auth_test
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/auth"
)
func setupTestService(t *testing.T) (*auth.Service, func()) {
t.Helper()
// Create temp directory
tmpDir := t.TempDir()
// Set up globals
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalsInst},
)
require.NoError(t, err)
// Create test config
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
}
// Create database
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: loggerInst,
Config: cfg,
})
require.NoError(t, err)
// Connect database manually for tests
dbPath := filepath.Join(tmpDir, "upaas.db")
cfg.DataDir = tmpDir
_ = dbPath // database will create this
// Create service
svc, err := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
Logger: loggerInst,
Config: cfg,
Database: dbInst,
})
require.NoError(t, err)
// t.TempDir() automatically cleans up after test
cleanup := func() {}
return svc, cleanup
}
func TestHashPassword(testingT *testing.T) {
testingT.Parallel()
testingT.Run("hashes password successfully", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
hash, err := svc.HashPassword("testpassword")
require.NoError(t, err)
assert.NotEmpty(t, hash)
assert.NotEqual(t, "testpassword", hash)
assert.Contains(t, hash, "$") // salt$hash format
})
testingT.Run("produces different hashes for same password", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
hash1, err := svc.HashPassword("testpassword")
require.NoError(t, err)
hash2, err := svc.HashPassword("testpassword")
require.NoError(t, err)
assert.NotEqual(t, hash1, hash2) // Different salts
})
}
func TestVerifyPassword(testingT *testing.T) {
testingT.Parallel()
testingT.Run("verifies correct password", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
hash, err := svc.HashPassword("correctpassword")
require.NoError(t, err)
valid := svc.VerifyPassword(hash, "correctpassword")
assert.True(t, valid)
})
testingT.Run("rejects incorrect password", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
hash, err := svc.HashPassword("correctpassword")
require.NoError(t, err)
valid := svc.VerifyPassword(hash, "wrongpassword")
assert.False(t, valid)
})
testingT.Run("rejects empty password", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
hash, err := svc.HashPassword("correctpassword")
require.NoError(t, err)
valid := svc.VerifyPassword(hash, "")
assert.False(t, valid)
})
testingT.Run("rejects invalid hash format", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
valid := svc.VerifyPassword("invalid-hash", "password")
assert.False(t, valid)
})
}
func TestIsSetupRequired(testingT *testing.T) {
testingT.Parallel()
testingT.Run("returns true when no users exist", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
required, err := svc.IsSetupRequired(context.Background())
require.NoError(t, err)
assert.True(t, required)
})
}
func TestCreateUser(testingT *testing.T) {
testingT.Parallel()
testingT.Run("creates user successfully", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
user, err := svc.CreateUser(context.Background(), "admin", "password123")
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, "admin", user.Username)
assert.NotEmpty(t, user.PasswordHash)
assert.NotZero(t, user.ID)
})
testingT.Run("rejects duplicate user", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.CreateUser(context.Background(), "admin", "password123")
require.NoError(t, err)
_, err = svc.CreateUser(context.Background(), "admin2", "password456")
assert.ErrorIs(t, err, auth.ErrUserExists)
})
}
func TestAuthenticate(testingT *testing.T) {
testingT.Parallel()
testingT.Run("authenticates valid credentials", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.CreateUser(context.Background(), "admin", "password123")
require.NoError(t, err)
user, err := svc.Authenticate(context.Background(), "admin", "password123")
require.NoError(t, err)
require.NotNil(t, user)
assert.Equal(t, "admin", user.Username)
})
testingT.Run("rejects invalid password", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.CreateUser(context.Background(), "admin", "password123")
require.NoError(t, err)
_, err = svc.Authenticate(context.Background(), "admin", "wrongpassword")
assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
})
testingT.Run("rejects unknown user", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.Authenticate(context.Background(), "nonexistent", "password")
assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
})
}

View File

@ -0,0 +1,451 @@
// Package deploy provides deployment services.
package deploy
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/notify"
)
// Time constants.
const (
healthCheckDelaySeconds = 60
// upaasLabelCount is the number of upaas-specific labels added to containers.
upaasLabelCount = 2
)
// Sentinel errors for deployment failures.
var (
// ErrContainerUnhealthy indicates the container failed health check.
ErrContainerUnhealthy = errors.New("container unhealthy after 60 seconds")
)
// ServiceParams contains dependencies for Service.
type ServiceParams struct {
fx.In
Logger *logger.Logger
Config *config.Config
Database *database.Database
Docker *docker.Client
Notify *notify.Service
}
// Service provides deployment functionality.
type Service struct {
log *slog.Logger
db *database.Database
docker *docker.Client
notify *notify.Service
config *config.Config
params *ServiceParams
}
// New creates a new deploy Service.
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
return &Service{
log: params.Logger.Get(),
db: params.Database,
docker: params.Docker,
notify: params.Notify,
config: params.Config,
params: &params,
}, nil
}
// GetBuildDir returns the build directory path for an app.
func (svc *Service) GetBuildDir(appID string) string {
return filepath.Join(svc.config.DataDir, "builds", appID)
}
// Deploy deploys an app.
func (svc *Service) Deploy(
ctx context.Context,
app *models.App,
webhookEventID *int64,
) error {
deployment, err := svc.createDeploymentRecord(ctx, app, webhookEventID)
if err != nil {
return err
}
err = svc.updateAppStatusBuilding(ctx, app)
if err != nil {
return err
}
svc.notify.NotifyBuildStart(ctx, app, deployment)
imageID, err := svc.buildImage(ctx, app, deployment)
if err != nil {
return err
}
svc.notify.NotifyBuildSuccess(ctx, app, deployment)
err = svc.updateDeploymentDeploying(ctx, deployment)
if err != nil {
return err
}
svc.removeOldContainer(ctx, app, deployment)
containerID, err := svc.createAndStartContainer(ctx, app, deployment, imageID)
if err != nil {
return err
}
err = svc.updateAppRunning(ctx, app, containerID, imageID)
if err != nil {
return err
}
// Use context.WithoutCancel to ensure health check completes even if
// the parent context is cancelled (e.g., HTTP request ends).
go svc.checkHealthAfterDelay(context.WithoutCancel(ctx), app, deployment)
return nil
}
func (svc *Service) createDeploymentRecord(
ctx context.Context,
app *models.App,
webhookEventID *int64,
) (*models.Deployment, error) {
deployment := models.NewDeployment(svc.db)
deployment.AppID = app.ID
if webhookEventID != nil {
deployment.WebhookEventID = sql.NullInt64{
Int64: *webhookEventID,
Valid: true,
}
}
deployment.Status = models.DeploymentStatusBuilding
saveErr := deployment.Save(ctx)
if saveErr != nil {
return nil, fmt.Errorf("failed to create deployment: %w", saveErr)
}
return deployment, nil
}
func (svc *Service) updateAppStatusBuilding(
ctx context.Context,
app *models.App,
) error {
app.Status = models.AppStatusBuilding
saveErr := app.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to update app status: %w", saveErr)
}
return nil
}
func (svc *Service) buildImage(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (string, error) {
tempDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
if err != nil {
return "", err
}
defer cleanup()
imageTag := "upaas/" + app.Name + ":latest"
imageID, err := svc.docker.BuildImage(ctx, docker.BuildImageOptions{
ContextDir: tempDir,
DockerfilePath: app.DockerfilePath,
Tags: []string{imageTag},
})
if err != nil {
svc.notify.NotifyBuildFailed(ctx, app, deployment, err)
svc.failDeployment(
ctx,
app,
deployment,
fmt.Errorf("failed to build image: %w", err),
)
return "", fmt.Errorf("failed to build image: %w", err)
}
deployment.ImageID = sql.NullString{String: imageID, Valid: true}
_ = deployment.AppendLog(ctx, "Image built: "+imageID)
return imageID, nil
}
func (svc *Service) cloneRepository(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (string, func(), error) {
tempDir, err := os.MkdirTemp("", "upaas-"+app.ID+"-*")
if err != nil {
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create temp dir: %w", err))
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
}
cleanup := func() { _ = os.RemoveAll(tempDir) }
cloneErr := svc.docker.CloneRepo(ctx, app.RepoURL, app.Branch, app.SSHPrivateKey, tempDir)
if cloneErr != nil {
cleanup()
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to clone repo: %w", cloneErr))
return "", nil, fmt.Errorf("failed to clone repo: %w", cloneErr)
}
_ = deployment.AppendLog(ctx, "Repository cloned successfully")
return tempDir, cleanup, nil
}
func (svc *Service) updateDeploymentDeploying(
ctx context.Context,
deployment *models.Deployment,
) error {
deployment.Status = models.DeploymentStatusDeploying
saveErr := deployment.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to update deployment status: %w", saveErr)
}
return nil
}
func (svc *Service) removeOldContainer(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) {
if !app.ContainerID.Valid || app.ContainerID.String == "" {
return
}
svc.log.Info("removing old container", "id", app.ContainerID.String)
removeErr := svc.docker.RemoveContainer(ctx, app.ContainerID.String, true)
if removeErr != nil {
svc.log.Warn("failed to remove old container", "error", removeErr)
}
_ = deployment.AppendLog(ctx, "Old container removed")
}
func (svc *Service) createAndStartContainer(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID string,
) (string, error) {
containerOpts, err := svc.buildContainerOptions(ctx, app, imageID)
if err != nil {
svc.failDeployment(ctx, app, deployment, err)
return "", err
}
containerID, err := svc.docker.CreateContainer(ctx, containerOpts)
if err != nil {
svc.notify.NotifyDeployFailed(ctx, app, deployment, err)
svc.failDeployment(
ctx,
app,
deployment,
fmt.Errorf("failed to create container: %w", err),
)
return "", fmt.Errorf("failed to create container: %w", err)
}
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(ctx, "Container created: "+containerID)
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
svc.notify.NotifyDeployFailed(ctx, app, deployment, startErr)
svc.failDeployment(
ctx,
app,
deployment,
fmt.Errorf("failed to start container: %w", startErr),
)
return "", fmt.Errorf("failed to start container: %w", startErr)
}
_ = deployment.AppendLog(ctx, "Container started")
return containerID, nil
}
func (svc *Service) buildContainerOptions(
ctx context.Context,
app *models.App,
_ string,
) (docker.CreateContainerOptions, error) {
envVars, err := app.GetEnvVars(ctx)
if err != nil {
return docker.CreateContainerOptions{}, fmt.Errorf("failed to get env vars: %w", err)
}
labels, err := app.GetLabels(ctx)
if err != nil {
return docker.CreateContainerOptions{}, fmt.Errorf("failed to get labels: %w", err)
}
volumes, err := app.GetVolumes(ctx)
if err != nil {
return docker.CreateContainerOptions{}, fmt.Errorf("failed to get volumes: %w", err)
}
envMap := make(map[string]string, len(envVars))
for _, envVar := range envVars {
envMap[envVar.Key] = envVar.Value
}
network := ""
if app.DockerNetwork.Valid {
network = app.DockerNetwork.String
}
return docker.CreateContainerOptions{
Name: "upaas-" + app.Name,
Image: "upaas/" + app.Name + ":latest",
Env: envMap,
Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes),
Network: network,
}, nil
}
func buildLabelMap(app *models.App, labels []*models.Label) map[string]string {
labelMap := make(map[string]string, len(labels)+upaasLabelCount)
for _, label := range labels {
labelMap[label.Key] = label.Value
}
labelMap["upaas.app.id"] = app.ID
labelMap["upaas.app.name"] = app.Name
return labelMap
}
func buildVolumeMounts(volumes []*models.Volume) []docker.VolumeMount {
mounts := make([]docker.VolumeMount, 0, len(volumes))
for _, vol := range volumes {
mounts = append(mounts, docker.VolumeMount{
HostPath: vol.HostPath,
ContainerPath: vol.ContainerPath,
ReadOnly: vol.ReadOnly,
})
}
return mounts
}
func (svc *Service) updateAppRunning(
ctx context.Context,
app *models.App,
containerID, imageID string,
) error {
app.ContainerID = sql.NullString{String: containerID, Valid: true}
app.ImageID = sql.NullString{String: imageID, Valid: true}
app.Status = models.AppStatusRunning
saveErr := app.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to update app: %w", saveErr)
}
return nil
}
func (svc *Service) checkHealthAfterDelay(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) {
svc.log.Info(
"waiting 60 seconds to check container health",
"app", app.Name,
)
time.Sleep(healthCheckDelaySeconds * time.Second)
// Reload app to get current state
reloadedApp, err := models.FindApp(ctx, svc.db, app.ID)
if err != nil || reloadedApp == nil {
svc.log.Error("failed to reload app for health check", "error", err)
return
}
if !reloadedApp.ContainerID.Valid {
return
}
healthy, err := svc.docker.IsContainerHealthy(
ctx,
reloadedApp.ContainerID.String,
)
if err != nil {
svc.log.Error("failed to check container health", "error", err)
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, err)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
return
}
if healthy {
svc.log.Info("container healthy after 60 seconds", "app", reloadedApp.Name)
svc.notify.NotifyDeploySuccess(ctx, reloadedApp, deployment)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusSuccess)
} else {
svc.log.Warn(
"container unhealthy after 60 seconds",
"app", reloadedApp.Name,
)
svc.notify.NotifyDeployFailed(ctx, reloadedApp, deployment, ErrContainerUnhealthy)
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
reloadedApp.Status = models.AppStatusError
_ = reloadedApp.Save(ctx)
}
}
func (svc *Service) failDeployment(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
deployErr error,
) {
svc.log.Error("deployment failed", "app", app.Name, "error", deployErr)
_ = deployment.AppendLog(ctx, "ERROR: "+deployErr.Error())
_ = deployment.MarkFinished(ctx, models.DeploymentStatusFailed)
app.Status = models.AppStatusError
_ = app.Save(ctx)
}

View File

@ -0,0 +1,280 @@
// Package notify provides notification services.
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
)
// HTTP client timeout.
const (
httpClientTimeout = 10 * time.Second
)
// HTTP status code thresholds.
const (
httpStatusClientError = 400
)
// Sentinel errors for notification failures.
var (
// ErrNtfyFailed indicates the ntfy notification request failed.
ErrNtfyFailed = errors.New("ntfy notification failed")
// ErrSlackFailed indicates the Slack notification request failed.
ErrSlackFailed = errors.New("slack notification failed")
)
// ServiceParams contains dependencies for Service.
type ServiceParams struct {
fx.In
Logger *logger.Logger
}
// Service provides notification functionality.
type Service struct {
log *slog.Logger
client *http.Client
params *ServiceParams
}
// New creates a new notify Service.
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
return &Service{
log: params.Logger.Get(),
client: &http.Client{
Timeout: httpClientTimeout,
},
params: &params,
}, nil
}
// NotifyBuildStart sends a build started notification.
func (svc *Service) NotifyBuildStart(
ctx context.Context,
app *models.App,
_ *models.Deployment,
) {
title := "Build started: " + app.Name
message := "Building from branch " + app.Branch
svc.sendNotifications(ctx, app, title, message, "info")
}
// NotifyBuildSuccess sends a build success notification.
func (svc *Service) NotifyBuildSuccess(
ctx context.Context,
app *models.App,
_ *models.Deployment,
) {
title := "Build success: " + app.Name
message := "Image built successfully from branch " + app.Branch
svc.sendNotifications(ctx, app, title, message, "success")
}
// NotifyBuildFailed sends a build failed notification.
func (svc *Service) NotifyBuildFailed(
ctx context.Context,
app *models.App,
_ *models.Deployment,
buildErr error,
) {
title := "Build failed: " + app.Name
message := "Build failed: " + buildErr.Error()
svc.sendNotifications(ctx, app, title, message, "error")
}
// NotifyDeploySuccess sends a deploy success notification.
func (svc *Service) NotifyDeploySuccess(
ctx context.Context,
app *models.App,
_ *models.Deployment,
) {
title := "Deploy success: " + app.Name
message := "Successfully deployed from branch " + app.Branch
svc.sendNotifications(ctx, app, title, message, "success")
}
// NotifyDeployFailed sends a deploy failed notification.
func (svc *Service) NotifyDeployFailed(
ctx context.Context,
app *models.App,
_ *models.Deployment,
deployErr error,
) {
title := "Deploy failed: " + app.Name
message := "Deployment failed: " + deployErr.Error()
svc.sendNotifications(ctx, app, title, message, "error")
}
func (svc *Service) sendNotifications(
ctx context.Context,
app *models.App,
title, message, priority string,
) {
// Send to ntfy if configured
if app.NtfyTopic.Valid && app.NtfyTopic.String != "" {
ntfyTopic := app.NtfyTopic.String
appName := app.Name
go func() {
// Use context.WithoutCancel to ensure notification completes
// even if the parent context is cancelled.
notifyCtx := context.WithoutCancel(ctx)
ntfyErr := svc.sendNtfy(notifyCtx, ntfyTopic, title, message, priority)
if ntfyErr != nil {
svc.log.Error(
"failed to send ntfy notification",
"error", ntfyErr,
"app", appName,
)
}
}()
}
// Send to Slack if configured
if app.SlackWebhook.Valid && app.SlackWebhook.String != "" {
slackWebhook := app.SlackWebhook.String
appName := app.Name
go func() {
// Use context.WithoutCancel to ensure notification completes
// even if the parent context is cancelled.
notifyCtx := context.WithoutCancel(ctx)
slackErr := svc.sendSlack(notifyCtx, slackWebhook, title, message)
if slackErr != nil {
svc.log.Error(
"failed to send slack notification",
"error", slackErr,
"app", appName,
)
}
}()
}
}
func (svc *Service) sendNtfy(
ctx context.Context,
topic, title, message, priority string,
) error {
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
topic,
bytes.NewBufferString(message),
)
if err != nil {
return fmt.Errorf("failed to create ntfy request: %w", err)
}
request.Header.Set("Title", title)
request.Header.Set("Priority", svc.ntfyPriority(priority))
resp, err := svc.client.Do(request)
if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= httpStatusClientError {
return fmt.Errorf("%w: status %d", ErrNtfyFailed, resp.StatusCode)
}
return nil
}
func (svc *Service) ntfyPriority(priority string) string {
switch priority {
case "error":
return "urgent"
case "success":
return "default"
case "info":
return "low"
default:
return "default"
}
}
// SlackPayload represents a Slack webhook payload.
type SlackPayload struct {
Text string `json:"text"`
Attachments []SlackAttachment `json:"attachments,omitempty"`
}
// SlackAttachment represents a Slack attachment.
type SlackAttachment struct {
Color string `json:"color"`
Title string `json:"title"`
Text string `json:"text"`
}
func (svc *Service) sendSlack(
ctx context.Context,
webhookURL, title, message string,
) error {
svc.log.Debug(
"sending slack notification",
"url", webhookURL,
"title", title,
)
payload := SlackPayload{
Attachments: []SlackAttachment{
{
Color: "#36a64f",
Title: title,
Text: message,
},
},
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal slack payload: %w", err)
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
webhookURL,
bytes.NewBuffer(body),
)
if err != nil {
return fmt.Errorf("failed to create slack request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
resp, err := svc.client.Do(request)
if err != nil {
return fmt.Errorf("failed to send slack request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode >= httpStatusClientError {
return fmt.Errorf("%w: status %d", ErrSlackFailed, resp.StatusCode)
}
return nil
}

View File

@ -0,0 +1,162 @@
// Package webhook provides webhook handling services.
package webhook
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
)
// ServiceParams contains dependencies for Service.
type ServiceParams struct {
fx.In
Logger *logger.Logger
Database *database.Database
Deploy *deploy.Service
}
// Service provides webhook handling functionality.
type Service struct {
log *slog.Logger
db *database.Database
deploy *deploy.Service
params *ServiceParams
}
// New creates a new webhook Service.
func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
return &Service{
log: params.Logger.Get(),
db: params.Database,
deploy: params.Deploy,
params: &params,
}, nil
}
// GiteaPushPayload represents a Gitea push webhook payload.
//
//nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
SSHURL string `json:"ssh_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// HandleWebhook processes a webhook request.
func (svc *Service) HandleWebhook(
ctx context.Context,
app *models.App,
eventType string,
payload []byte,
) error {
svc.log.Info("processing webhook", "app", app.Name, "event", eventType)
// Parse payload
var pushPayload GiteaPushPayload
unmarshalErr := json.Unmarshal(payload, &pushPayload)
if unmarshalErr != nil {
svc.log.Warn("failed to parse webhook payload", "error", unmarshalErr)
// Continue anyway to log the event
}
// Extract branch from ref
branch := extractBranch(pushPayload.Ref)
commitSHA := pushPayload.After
// Check if branch matches
matched := branch == app.Branch
// Create webhook event record
event := models.NewWebhookEvent(svc.db)
event.AppID = app.ID
event.EventType = eventType
event.Branch = branch
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched
event.Processed = false
saveErr := event.Save(ctx)
if saveErr != nil {
return fmt.Errorf("failed to save webhook event: %w", saveErr)
}
svc.log.Info("webhook event recorded",
"app", app.Name,
"branch", branch,
"matched", matched,
"commit", commitSHA,
)
// If branch matches, trigger deployment
if matched {
svc.triggerDeployment(ctx, app, event)
}
return nil
}
func (svc *Service) triggerDeployment(
ctx context.Context,
app *models.App,
event *models.WebhookEvent,
) {
// Capture values for goroutine
eventID := event.ID
appName := app.Name
go func() {
// Use context.WithoutCancel to ensure deployment completes
// even if the HTTP request context is cancelled.
deployCtx := context.WithoutCancel(ctx)
deployErr := svc.deploy.Deploy(deployCtx, app, &eventID)
if deployErr != nil {
svc.log.Error("deployment failed", "error", deployErr, "app", appName)
}
// Mark event as processed
event.Processed = true
_ = event.Save(deployCtx)
}()
}
// extractBranch extracts the branch name from a git ref.
func extractBranch(ref string) string {
// refs/heads/main -> main
const prefix = "refs/heads/"
if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix {
return ref[len(prefix):]
}
return ref
}

View File

@ -0,0 +1,334 @@
package webhook_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/notify"
"git.eeqj.de/sneak/upaas/internal/service/webhook"
)
type testDeps struct {
logger *logger.Logger
config *config.Config
db *database.Database
tmpDir string
}
func setupTestDeps(t *testing.T) *testDeps {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(fx.Lifecycle(nil), logger.Params{Globals: globalsInst})
require.NoError(t, err)
cfg := &config.Config{Port: 8080, DataDir: tmpDir, SessionSecret: "test-secret-key-at-least-32-chars"}
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{Logger: loggerInst, Config: cfg})
require.NoError(t, err)
return &testDeps{logger: loggerInst, config: cfg, db: dbInst, tmpDir: tmpDir}
}
func setupTestService(t *testing.T) (*webhook.Service, *database.Database, func()) {
t.Helper()
deps := setupTestDeps(t)
dockerClient, err := docker.New(fx.Lifecycle(nil), docker.Params{Logger: deps.logger, Config: deps.config})
require.NoError(t, err)
notifySvc, err := notify.New(fx.Lifecycle(nil), notify.ServiceParams{Logger: deps.logger})
require.NoError(t, err)
deploySvc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
Logger: deps.logger, Database: deps.db, Docker: dockerClient, Notify: notifySvc,
})
require.NoError(t, err)
svc, err := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{
Logger: deps.logger, Database: deps.db, Deploy: deploySvc,
})
require.NoError(t, err)
// t.TempDir() automatically cleans up after test
return svc, deps.db, func() {}
}
func createTestApp(
t *testing.T,
dbInst *database.Database,
branch string,
) *models.App {
t.Helper()
app := models.NewApp(dbInst)
app.ID = "test-app-id"
app.Name = "test-app"
app.RepoURL = "git@gitea.example.com:user/repo.git"
app.Branch = branch
app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "webhook-secret-123"
app.SSHPrivateKey = "private-key"
app.SSHPublicKey = "public-key"
app.Status = models.AppStatusPending
err := app.Save(context.Background())
require.NoError(t, err)
return app
}
func TestExtractBranch(testingT *testing.T) {
testingT.Parallel()
tests := []struct {
name string
ref string
expected string
}{
{
name: "extracts main branch",
ref: "refs/heads/main",
expected: "main",
},
{
name: "extracts feature branch",
ref: "refs/heads/feature/new-feature",
expected: "feature/new-feature",
},
{
name: "extracts develop branch",
ref: "refs/heads/develop",
expected: "develop",
},
{
name: "returns raw ref if no prefix",
ref: "main",
expected: "main",
},
{
name: "handles empty ref",
ref: "",
expected: "",
},
{
name: "handles partial prefix",
ref: "refs/heads/",
expected: "",
},
}
for _, testCase := range tests {
testingT.Run(testCase.name, func(t *testing.T) {
t.Parallel()
// We test via HandleWebhook since extractBranch is not exported.
// The test verifies behavior indirectly through the webhook event's branch.
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, testCase.expected)
payload := []byte(`{"ref": "` + testCase.ref + `"}`)
err := svc.HandleWebhook(context.Background(), app, "push", payload)
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, testCase.expected, events[0].Branch)
})
}
}
func TestHandleWebhookMatchingBranch(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456",
"repository": {
"full_name": "user/repo",
"clone_url": "https://gitea.example.com/user/repo.git",
"ssh_url": "git@gitea.example.com:user/repo.git"
},
"pusher": {"username": "testuser", "email": "test@example.com"},
"commits": [{"id": "abc123def456", "message": "Test commit",
"author": {"name": "Test User", "email": "test@example.com"}}]
}`)
err := svc.HandleWebhook(context.Background(), app, "push", payload)
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "push", event.EventType)
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "abc123def456", event.CommitSHA.String)
}
func TestHandleWebhookNonMatchingBranch(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`)
err := svc.HandleWebhook(context.Background(), app, "push", payload)
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, "develop", events[0].Branch)
assert.False(t, events[0].Matched)
}
func TestHandleWebhookInvalidJSON(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{invalid json}`))
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
}
func TestHandleWebhookEmptyPayload(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{}`))
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.False(t, events[0].Matched)
}
func TestGiteaPushPayloadParsing(testingT *testing.T) {
testingT.Parallel()
testingT.Run("parses full payload", func(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
"repository": {
"full_name": "myorg/myrepo",
"clone_url": "https://gitea.example.com/myorg/myrepo.git",
"ssh_url": "git@gitea.example.com:myorg/myrepo.git"
},
"pusher": {
"username": "developer",
"email": "dev@example.com"
},
"commits": [
{
"id": "abc123def456789",
"message": "Fix bug in feature",
"author": {
"name": "Developer",
"email": "dev@example.com"
}
},
{
"id": "def456789abc123",
"message": "Add tests",
"author": {
"name": "Developer",
"email": "dev@example.com"
}
}
]
}`)
var pushPayload webhook.GiteaPushPayload
err := json.Unmarshal(payload, &pushPayload)
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", pushPayload.Ref)
assert.Equal(t, "abc123def456789", pushPayload.After)
assert.Equal(t, "myorg/myrepo", pushPayload.Repository.FullName)
assert.Equal(
t,
"git@gitea.example.com:myorg/myrepo.git",
pushPayload.Repository.SSHURL,
)
assert.Equal(t, "developer", pushPayload.Pusher.Username)
assert.Len(t, pushPayload.Commits, 2)
assert.Equal(t, "Fix bug in feature", pushPayload.Commits[0].Message)
})
}
// TestSetupTestService verifies the test helper creates a working test service.
func TestSetupTestService(testingT *testing.T) {
testingT.Parallel()
testingT.Run("creates working test service", func(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
require.NotNil(t, svc)
require.NotNil(t, dbInst)
// Verify database is working
tmpDir := filepath.Dir(dbInst.Path())
_, err := os.Stat(tmpDir)
require.NoError(t, err)
})
}

53
internal/ssh/keygen.go Normal file
View File

@ -0,0 +1,53 @@
// Package ssh provides SSH key generation utilities.
package ssh
import (
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"fmt"
"golang.org/x/crypto/ssh"
)
// KeyPair contains an SSH key pair.
type KeyPair struct {
PrivateKey string
PublicKey string
}
// GenerateKeyPair generates a new Ed25519 SSH key pair.
func GenerateKeyPair() (*KeyPair, error) {
// Generate Ed25519 key pair
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate key pair: %w", err)
}
// Convert private key to PEM format
privateKeyPEM, err := ssh.MarshalPrivateKey(privateKey, "")
if err != nil {
return nil, fmt.Errorf("failed to marshal private key: %w", err)
}
// Convert public key to authorized_keys format
sshPublicKey, err := ssh.NewPublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("failed to create SSH public key: %w", err)
}
return &KeyPair{
PrivateKey: string(pem.EncodeToMemory(privateKeyPEM)),
PublicKey: string(ssh.MarshalAuthorizedKey(sshPublicKey)),
}, nil
}
// ValidatePrivateKey validates that a private key is valid.
func ValidatePrivateKey(privateKeyPEM string) error {
_, err := ssh.ParsePrivateKey([]byte(privateKeyPEM))
if err != nil {
return fmt.Errorf("invalid private key: %w", err)
}
return nil
}

View File

@ -0,0 +1,70 @@
package ssh_test
import (
"strings"
"testing"
"git.eeqj.de/sneak/upaas/internal/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateKeyPair(t *testing.T) {
t.Parallel()
t.Run("generates valid key pair", func(t *testing.T) {
t.Parallel()
keyPair, err := ssh.GenerateKeyPair()
require.NoError(t, err)
require.NotNil(t, keyPair)
// Private key should be PEM encoded
assert.Contains(t, keyPair.PrivateKey, "-----BEGIN OPENSSH PRIVATE KEY-----")
assert.Contains(t, keyPair.PrivateKey, "-----END OPENSSH PRIVATE KEY-----")
// Public key should be in authorized_keys format
assert.True(t, strings.HasPrefix(keyPair.PublicKey, "ssh-ed25519 "))
})
t.Run("generates unique keys each time", func(t *testing.T) {
t.Parallel()
keyPair1, err := ssh.GenerateKeyPair()
require.NoError(t, err)
keyPair2, err := ssh.GenerateKeyPair()
require.NoError(t, err)
assert.NotEqual(t, keyPair1.PrivateKey, keyPair2.PrivateKey)
assert.NotEqual(t, keyPair1.PublicKey, keyPair2.PublicKey)
})
}
func TestValidatePrivateKey(t *testing.T) {
t.Parallel()
t.Run("validates generated key", func(t *testing.T) {
t.Parallel()
keyPair, err := ssh.GenerateKeyPair()
require.NoError(t, err)
err = ssh.ValidatePrivateKey(keyPair.PrivateKey)
assert.NoError(t, err)
})
t.Run("rejects invalid key", func(t *testing.T) {
t.Parallel()
err := ssh.ValidatePrivateKey("not a valid key")
assert.Error(t, err)
})
t.Run("rejects empty key", func(t *testing.T) {
t.Parallel()
err := ssh.ValidatePrivateKey("")
assert.Error(t, err)
})
}

200
static/css/input.css Normal file
View File

@ -0,0 +1,200 @@
@import "tailwindcss";
/* Source the templates */
@source "../../templates/**/*.html";
/* Material Design inspired theme customization */
@theme {
/* Primary colors */
--color-primary-50: #e3f2fd;
--color-primary-100: #bbdefb;
--color-primary-200: #90caf9;
--color-primary-300: #64b5f6;
--color-primary-400: #42a5f5;
--color-primary-500: #2196f3;
--color-primary-600: #1e88e5;
--color-primary-700: #1976d2;
--color-primary-800: #1565c0;
--color-primary-900: #0d47a1;
/* Error colors */
--color-error-50: #ffebee;
--color-error-500: #f44336;
--color-error-700: #d32f2f;
/* Success colors */
--color-success-50: #e8f5e9;
--color-success-500: #4caf50;
--color-success-700: #388e3c;
/* Warning colors */
--color-warning-50: #fff3e0;
--color-warning-500: #ff9800;
--color-warning-700: #f57c00;
/* Material Design elevation shadows */
--shadow-elevation-1: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
--shadow-elevation-2: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
--shadow-elevation-3: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
}
/* Material Design component styles */
@layer components {
/* Buttons - base styles inlined */
.btn-primary {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 focus:ring-primary-500 shadow-elevation-1 hover:shadow-elevation-2;
}
.btn-secondary {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 active:bg-gray-100 focus:ring-primary-500;
}
.btn-danger {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-error-500 text-white hover:bg-error-700 active:bg-red-800 focus:ring-red-500 shadow-elevation-1 hover:shadow-elevation-2;
}
.btn-success {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-success-500 text-white hover:bg-success-700 active:bg-green-800 focus:ring-green-500 shadow-elevation-1 hover:shadow-elevation-2;
}
.btn-text {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100;
}
.btn-icon {
@apply p-2 rounded-full hover:bg-gray-100 active:bg-gray-200 transition-colors;
}
/* Cards */
.card {
@apply bg-white rounded-lg shadow-elevation-1 overflow-hidden;
}
.card-elevated {
@apply bg-white rounded-lg shadow-elevation-1 overflow-hidden hover:shadow-elevation-2 transition-shadow;
}
/* Form inputs - Material Design style */
.input {
@apply w-full px-4 py-3 border border-gray-300 rounded-md text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all;
}
.input-error {
@apply w-full px-4 py-3 border border-error-500 rounded-md text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-error-500 focus:border-transparent transition-all;
}
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
.form-group {
@apply mb-4;
}
/* Status badges */
.badge-success {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-700;
}
.badge-warning {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning-50 text-warning-700;
}
.badge-error {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-error-50 text-error-700;
}
.badge-info {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-50 text-primary-700;
}
.badge-neutral {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700;
}
/* Tables */
.table {
@apply min-w-full divide-y divide-gray-200;
}
.table-header {
@apply bg-gray-50;
}
.table-header th {
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
}
.table-body {
@apply bg-white divide-y divide-gray-200;
}
.table-body td {
@apply px-6 py-4 whitespace-nowrap text-sm;
}
.table-row-hover:hover {
@apply bg-gray-50;
}
/* App bar / Navigation */
.app-bar {
@apply bg-white shadow-elevation-1 px-6 py-4;
}
/* Copy button styling */
.copy-field {
@apply flex items-center gap-2 bg-gray-100 rounded-md p-2 font-mono text-sm;
}
.copy-field-value {
@apply flex-1 overflow-x-auto whitespace-nowrap;
}
.copy-btn {
@apply p-2 rounded-full hover:bg-gray-200 active:bg-gray-300 transition-colors text-gray-500 hover:text-gray-700 shrink-0;
}
/* Alert / Message boxes */
.alert-error {
@apply p-4 rounded-md mb-4 bg-error-50 text-error-700 border border-error-500/20;
}
.alert-success {
@apply p-4 rounded-md mb-4 bg-success-50 text-success-700 border border-success-500/20;
}
.alert-warning {
@apply p-4 rounded-md mb-4 bg-warning-50 text-warning-700 border border-warning-500/20;
}
.alert-info {
@apply p-4 rounded-md mb-4 bg-primary-50 text-primary-700 border border-primary-500/20;
}
/* Section headers */
.section-header {
@apply flex items-center justify-between pb-4 border-b border-gray-200 mb-4;
}
.section-title {
@apply text-lg font-medium text-gray-900;
}
/* Empty state */
.empty-state {
@apply text-center py-12;
}
.empty-state-icon {
@apply mx-auto h-12 w-12 text-gray-400;
}
.empty-state-title {
@apply mt-2 text-sm font-medium text-gray-900;
}
.empty-state-description {
@apply mt-1 text-sm text-gray-500;
}
}

2
static/css/tailwind.css Normal file

File diff suppressed because one or more lines are too long

215
static/js/app.js Normal file
View File

@ -0,0 +1,215 @@
/**
* upaas - Frontend JavaScript utilities
* Vanilla JS, no dependencies
*/
(function() {
'use strict';
/**
* Copy text to clipboard
* @param {string} text - Text to copy
* @param {HTMLElement} button - Button element to update feedback
*/
function copyToClipboard(text, button) {
const originalText = button.textContent;
const originalTitle = button.getAttribute('title');
navigator.clipboard.writeText(text).then(function() {
// Success feedback
button.textContent = 'Copied!';
button.classList.add('text-success-500');
setTimeout(function() {
button.textContent = originalText;
button.classList.remove('text-success-500');
if (originalTitle) {
button.setAttribute('title', originalTitle);
}
}, 2000);
}).catch(function(err) {
// Fallback for older browsers
console.error('Failed to copy:', err);
// Try fallback method
var textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
button.textContent = 'Copied!';
setTimeout(function() {
button.textContent = originalText;
}, 2000);
} catch (e) {
button.textContent = 'Failed';
setTimeout(function() {
button.textContent = originalText;
}, 2000);
}
document.body.removeChild(textArea);
});
}
/**
* Initialize copy buttons
* Looks for elements with data-copy attribute
*/
function initCopyButtons() {
var copyButtons = document.querySelectorAll('[data-copy]');
copyButtons.forEach(function(button) {
button.addEventListener('click', function(e) {
e.preventDefault();
var text = button.getAttribute('data-copy');
copyToClipboard(text, button);
});
});
// Also handle buttons that copy content from a sibling element
var copyTargetButtons = document.querySelectorAll('[data-copy-target]');
copyTargetButtons.forEach(function(button) {
button.addEventListener('click', function(e) {
e.preventDefault();
var targetId = button.getAttribute('data-copy-target');
var target = document.getElementById(targetId);
if (target) {
var text = target.textContent || target.value;
copyToClipboard(text, button);
}
});
});
}
/**
* Confirm destructive actions
* Looks for forms with data-confirm attribute
*/
function initConfirmations() {
var confirmForms = document.querySelectorAll('form[data-confirm]');
confirmForms.forEach(function(form) {
form.addEventListener('submit', function(e) {
var message = form.getAttribute('data-confirm');
if (!confirm(message)) {
e.preventDefault();
}
});
});
// Also handle buttons with data-confirm
var confirmButtons = document.querySelectorAll('button[data-confirm]');
confirmButtons.forEach(function(button) {
button.addEventListener('click', function(e) {
var message = button.getAttribute('data-confirm');
if (!confirm(message)) {
e.preventDefault();
}
});
});
}
/**
* Toggle visibility of elements
* Looks for buttons with data-toggle attribute
*/
function initToggles() {
var toggleButtons = document.querySelectorAll('[data-toggle]');
toggleButtons.forEach(function(button) {
button.addEventListener('click', function(e) {
e.preventDefault();
var targetId = button.getAttribute('data-toggle');
var target = document.getElementById(targetId);
if (target) {
target.classList.toggle('hidden');
// Update button text if data-toggle-text is provided
var toggleText = button.getAttribute('data-toggle-text');
if (toggleText) {
var currentText = button.textContent;
button.textContent = toggleText;
button.setAttribute('data-toggle-text', currentText);
}
}
});
});
}
/**
* Auto-dismiss alerts after a delay
* Looks for elements with data-auto-dismiss attribute
*/
function initAutoDismiss() {
var dismissElements = document.querySelectorAll('[data-auto-dismiss]');
dismissElements.forEach(function(element) {
var delay = parseInt(element.getAttribute('data-auto-dismiss'), 10) || 5000;
setTimeout(function() {
element.style.transition = 'opacity 0.3s ease-out';
element.style.opacity = '0';
setTimeout(function() {
element.remove();
}, 300);
}, delay);
});
}
/**
* Manual dismiss for alerts
* Looks for buttons with data-dismiss attribute
*/
function initDismissButtons() {
var dismissButtons = document.querySelectorAll('[data-dismiss]');
dismissButtons.forEach(function(button) {
button.addEventListener('click', function(e) {
e.preventDefault();
var targetId = button.getAttribute('data-dismiss');
var target = targetId ? document.getElementById(targetId) : button.closest('.alert');
if (target) {
target.style.transition = 'opacity 0.3s ease-out';
target.style.opacity = '0';
setTimeout(function() {
target.remove();
}, 300);
}
});
});
}
/**
* Initialize all features when DOM is ready
*/
function init() {
initCopyButtons();
initConfirmations();
initToggles();
initAutoDismiss();
initDismissButtons();
}
// Run on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Expose copyToClipboard globally for inline onclick handlers if needed
window.upaas = {
copyToClipboard: copyToClipboard
};
})();

9
static/static.go Normal file
View File

@ -0,0 +1,9 @@
// Package static provides embedded static assets.
package static
import "embed"
// Static contains embedded CSS and JavaScript files for serving web assets.
//
//go:embed css js
var Static embed.FS

265
templates/app_detail.html Normal file
View File

@ -0,0 +1,265 @@
{{template "base" .}}
{{define "title"}}{{.App.Name}} - upaas{{end}}
{{define "content"}}
{{template "nav" .}}
<main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-6">
<a href="/" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Dashboard
</a>
</div>
{{template "alert-success" .}}
{{template "alert-error" .}}
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-medium text-gray-900">{{.App.Name}}</h1>
{{if eq .App.Status "running"}}
<span class="badge-success">Running</span>
{{else if eq .App.Status "building"}}
<span class="badge-warning">Building</span>
{{else if eq .App.Status "error"}}
<span class="badge-error">Error</span>
{{else if eq .App.Status "stopped"}}
<span class="badge-neutral">Stopped</span>
{{else}}
<span class="badge-neutral">{{.App.Status}}</span>
{{end}}
</div>
<p class="text-gray-500 font-mono text-sm mt-1">{{.App.RepoURL}} @ {{.App.Branch}}</p>
</div>
<div class="flex gap-3">
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
<button type="submit" class="btn-success">Deploy Now</button>
</form>
</div>
</div>
<!-- Deploy Key -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Deploy Key</h2>
<p class="text-sm text-gray-500 mb-3">Add this SSH public key to your repository as a read-only deploy key:</p>
<div class="copy-field">
<code id="deploy-key" class="copy-field-value text-xs">{{.App.SSHPublicKey}}</code>
<button
type="button"
data-copy-target="deploy-key"
class="copy-btn"
title="Copy to clipboard"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
<!-- Webhook URL -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Webhook URL</h2>
<p class="text-sm text-gray-500 mb-3">Add this URL as a push webhook in your Gitea repository:</p>
<div class="copy-field">
<code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code>
<button
type="button"
data-copy-target="webhook-url"
class="copy-btn"
title="Copy to clipboard"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
<!-- Environment Variables -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Environment Variables</h2>
{{if .EnvVars}}
<div class="overflow-x-auto mb-4">
<table class="table">
<thead class="table-header">
<tr>
<th>Key</th>
<th>Value</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody class="table-body">
{{range .EnvVars}}
<tr>
<td class="font-mono font-medium">{{.Key}}</td>
<td class="font-mono text-gray-500">{{.Value}}</td>
<td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" data-confirm="Delete this environment variable?">
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<form method="POST" action="/apps/{{.App.ID}}/env" class="flex flex-col sm:flex-row gap-2">
<input type="text" name="key" placeholder="KEY" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary">Add</button>
</form>
</div>
<!-- Labels -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Docker Labels</h2>
{{if .Labels}}
<div class="overflow-x-auto mb-4">
<table class="table">
<thead class="table-header">
<tr>
<th>Key</th>
<th>Value</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody class="table-body">
{{range .Labels}}
<tr>
<td class="font-mono font-medium">{{.Key}}</td>
<td class="font-mono text-gray-500">{{.Value}}</td>
<td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" data-confirm="Delete this label?">
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<form method="POST" action="/apps/{{.App.ID}}/labels" class="flex flex-col sm:flex-row gap-2">
<input type="text" name="key" placeholder="label.key" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary">Add</button>
</form>
</div>
<!-- Volumes -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Volume Mounts</h2>
{{if .Volumes}}
<div class="overflow-x-auto mb-4">
<table class="table">
<thead class="table-header">
<tr>
<th>Host Path</th>
<th>Container Path</th>
<th>Mode</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody class="table-body">
{{range .Volumes}}
<tr>
<td class="font-mono">{{.HostPath}}</td>
<td class="font-mono">{{.ContainerPath}}</td>
<td>
{{if .ReadOnly}}
<span class="badge-neutral">Read-only</span>
{{else}}
<span class="badge-info">Read-write</span>
{{end}}
</td>
<td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" data-confirm="Delete this volume mount?">
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<form method="POST" action="/apps/{{.App.ID}}/volumes" class="flex flex-col sm:flex-row gap-2 items-end">
<div class="flex-1 w-full">
<input type="text" name="host_path" placeholder="/host/path" required class="input font-mono text-sm">
</div>
<div class="flex-1 w-full">
<input type="text" name="container_path" placeholder="/container/path" required class="input font-mono text-sm">
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 whitespace-nowrap">
<input type="checkbox" name="readonly" value="1" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500">
Read-only
</label>
<button type="submit" class="btn-primary">Add</button>
</form>
</div>
<!-- Recent Deployments -->
<div class="card p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="section-title">Recent Deployments</h2>
<a href="/apps/{{.App.ID}}/deployments" class="text-primary-600 hover:text-primary-800 text-sm">View All</a>
</div>
{{if .Deployments}}
<div class="overflow-x-auto">
<table class="table">
<thead class="table-header">
<tr>
<th>Started</th>
<th>Status</th>
<th>Commit</th>
</tr>
</thead>
<tbody class="table-body">
{{range .Deployments}}
<tr>
<td class="text-gray-500">{{.StartedAt.Format "2006-01-02 15:04:05"}}</td>
<td>
{{if eq .Status "success"}}
<span class="badge-success">Success</span>
{{else if eq .Status "failed"}}
<span class="badge-error">Failed</span>
{{else if eq .Status "building"}}
<span class="badge-warning">Building</span>
{{else if eq .Status "deploying"}}
<span class="badge-info">Deploying</span>
{{else}}
<span class="badge-neutral">{{.Status}}</span>
{{end}}
</td>
<td class="font-mono text-gray-500 text-xs">
{{if .CommitSHA.Valid}}{{slice .CommitSHA.String 0 12}}{{else}}-{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-gray-500 text-sm">No deployments yet.</p>
{{end}}
</div>
<!-- Danger Zone -->
<div class="card border-2 border-error-500/20 bg-error-50/50 p-6">
<h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2>
<p class="text-error-600 text-sm mb-4">Deleting this app will remove all configuration and deployment history. This action cannot be undone.</p>
<form method="POST" action="/apps/{{.App.ID}}/delete" data-confirm="Are you sure you want to delete this app? This action cannot be undone.">
<button type="submit" class="btn-danger">Delete App</button>
</form>
</div>
</main>
{{end}}

123
templates/app_edit.html Normal file
View File

@ -0,0 +1,123 @@
{{template "base" .}}
{{define "title"}}Edit {{.App.Name}} - upaas{{end}}
{{define "content"}}
{{template "nav" .}}
<main class="max-w-2xl mx-auto px-4 py-8">
<div class="mb-6">
<a href="/apps/{{.App.ID}}" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to {{.App.Name}}
</a>
</div>
<h1 class="text-2xl font-medium text-gray-900 mb-6">Edit Application</h1>
<div class="card p-6">
{{template "alert-error" .}}
<form method="POST" action="/apps/{{.App.ID}}" class="space-y-6">
<div class="form-group">
<label for="name" class="label">App Name</label>
<input
type="text"
id="name"
name="name"
value="{{.App.Name}}"
required
pattern="[a-z0-9-]+"
class="input"
>
<p class="text-sm text-gray-500 mt-1">Lowercase letters, numbers, and hyphens only</p>
</div>
<div class="form-group">
<label for="repo_url" class="label">Repository URL (SSH)</label>
<input
type="text"
id="repo_url"
name="repo_url"
value="{{.App.RepoURL}}"
required
class="input font-mono"
>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-group">
<label for="branch" class="label">Branch</label>
<input
type="text"
id="branch"
name="branch"
value="{{.App.Branch}}"
required
class="input"
>
</div>
<div class="form-group">
<label for="dockerfile_path" class="label">Dockerfile Path</label>
<input
type="text"
id="dockerfile_path"
name="dockerfile_path"
value="{{.App.DockerfilePath}}"
required
class="input"
>
</div>
</div>
<hr class="border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Optional Settings</h3>
<div class="form-group">
<label for="docker_network" class="label">Docker Network</label>
<input
type="text"
id="docker_network"
name="docker_network"
value="{{if .App.DockerNetwork.Valid}}{{.App.DockerNetwork.String}}{{end}}"
class="input"
placeholder="bridge"
>
</div>
<div class="form-group">
<label for="ntfy_topic" class="label">Ntfy Topic URL</label>
<input
type="url"
id="ntfy_topic"
name="ntfy_topic"
value="{{if .App.NtfyTopic.Valid}}{{.App.NtfyTopic.String}}{{end}}"
class="input"
placeholder="https://ntfy.sh/my-topic"
>
</div>
<div class="form-group">
<label for="slack_webhook" class="label">Slack Webhook URL</label>
<input
type="url"
id="slack_webhook"
name="slack_webhook"
value="{{if .App.SlackWebhook.Valid}}{{.App.SlackWebhook.String}}{{end}}"
class="input"
placeholder="https://hooks.slack.com/services/..."
>
</div>
<div class="flex justify-end gap-3 pt-4">
<a href="/apps/{{.App.ID}}" class="btn-secondary">Cancel</a>
<button type="submit" class="btn-primary">Save Changes</button>
</div>
</form>
</div>
</main>
{{end}}

126
templates/app_new.html Normal file
View File

@ -0,0 +1,126 @@
{{template "base" .}}
{{define "title"}}New App - upaas{{end}}
{{define "content"}}
{{template "nav" .}}
<main class="max-w-2xl mx-auto px-4 py-8">
<div class="mb-6">
<a href="/" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Dashboard
</a>
</div>
<h1 class="text-2xl font-medium text-gray-900 mb-6">Create New Application</h1>
<div class="card p-6">
{{template "alert-error" .}}
<form method="POST" action="/apps" class="space-y-6">
<div class="form-group">
<label for="name" class="label">App Name</label>
<input
type="text"
id="name"
name="name"
value="{{.Name}}"
required
pattern="[a-z0-9-]+"
class="input"
placeholder="my-app"
>
<p class="text-sm text-gray-500 mt-1">Lowercase letters, numbers, and hyphens only</p>
</div>
<div class="form-group">
<label for="repo_url" class="label">Repository URL (SSH)</label>
<input
type="text"
id="repo_url"
name="repo_url"
value="{{.RepoURL}}"
required
class="input font-mono"
placeholder="git@gitea.example.com:user/repo.git"
>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-group">
<label for="branch" class="label">Branch</label>
<input
type="text"
id="branch"
name="branch"
value="{{if .Branch}}{{.Branch}}{{else}}main{{end}}"
class="input"
placeholder="main"
>
</div>
<div class="form-group">
<label for="dockerfile_path" class="label">Dockerfile Path</label>
<input
type="text"
id="dockerfile_path"
name="dockerfile_path"
value="{{if .DockerfilePath}}{{.DockerfilePath}}{{else}}Dockerfile{{end}}"
class="input"
placeholder="Dockerfile"
>
</div>
</div>
<hr class="border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Optional Settings</h3>
<div class="form-group">
<label for="docker_network" class="label">Docker Network</label>
<input
type="text"
id="docker_network"
name="docker_network"
value="{{.DockerNetwork}}"
class="input"
placeholder="bridge"
>
<p class="text-sm text-gray-500 mt-1">Leave empty to use default bridge network</p>
</div>
<div class="form-group">
<label for="ntfy_topic" class="label">Ntfy Topic URL</label>
<input
type="url"
id="ntfy_topic"
name="ntfy_topic"
value="{{.NtfyTopic}}"
class="input"
placeholder="https://ntfy.sh/my-topic"
>
</div>
<div class="form-group">
<label for="slack_webhook" class="label">Slack Webhook URL</label>
<input
type="url"
id="slack_webhook"
name="slack_webhook"
value="{{.SlackWebhook}}"
class="input"
placeholder="https://hooks.slack.com/services/..."
>
</div>
<div class="flex justify-end gap-3 pt-4">
<a href="/" class="btn-secondary">Cancel</a>
<button type="submit" class="btn-primary">Create App</button>
</div>
</form>
</div>
</main>
{{end}}

60
templates/base.html Normal file
View File

@ -0,0 +1,60 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>{{block "title" .}}upaas{{end}}</title>
<link rel="stylesheet" href="/s/css/tailwind.css">
</head>
<body class="bg-gray-50 min-h-screen">
{{block "content" .}}{{end}}
<script src="/s/js/app.js"></script>
</body>
</html>
{{end}}
{{define "nav"}}
<nav class="app-bar">
<div class="max-w-6xl mx-auto flex justify-between items-center">
<a href="/" class="text-xl font-medium text-gray-900 hover:text-primary-600 transition-colors">
upaas
</a>
<div class="flex items-center gap-4">
<a href="/apps/new" class="btn-primary">
New App
</a>
<form method="POST" action="/logout" class="inline">
<button type="submit" class="btn-text">Logout</button>
</form>
</div>
</div>
</nav>
{{end}}
{{define "alert-error"}}
{{if .Error}}
<div class="alert-error" data-auto-dismiss="8000">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<span>{{.Error}}</span>
</div>
</div>
{{end}}
{{end}}
{{define "alert-success"}}
{{if .Success}}
<div class="alert-success" data-auto-dismiss="5000">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span>{{.Success}}</span>
</div>
</div>
{{end}}
{{end}}

91
templates/dashboard.html Normal file
View File

@ -0,0 +1,91 @@
{{template "base" .}}
{{define "title"}}Dashboard - upaas{{end}}
{{define "content"}}
{{template "nav" .}}
<main class="max-w-6xl mx-auto px-4 py-8">
{{template "alert-success" .}}
{{template "alert-error" .}}
<div class="section-header">
<h1 class="text-2xl font-medium text-gray-900">Applications</h1>
<a href="/apps/new" class="btn-primary">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
New App
</a>
</div>
{{if .Apps}}
<div class="card overflow-hidden">
<table class="table">
<thead class="table-header">
<tr>
<th>Name</th>
<th>Repository</th>
<th>Branch</th>
<th>Status</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody class="table-body">
{{range .Apps}}
<tr class="table-row-hover">
<td>
<a href="/apps/{{.ID}}" class="text-primary-600 hover:text-primary-800 font-medium">
{{.Name}}
</a>
</td>
<td class="text-gray-500 font-mono text-xs">{{.RepoURL}}</td>
<td class="text-gray-500">{{.Branch}}</td>
<td>
{{if eq .Status "running"}}
<span class="badge-success">Running</span>
{{else if eq .Status "building"}}
<span class="badge-warning">Building</span>
{{else if eq .Status "error"}}
<span class="badge-error">Error</span>
{{else if eq .Status "stopped"}}
<span class="badge-neutral">Stopped</span>
{{else}}
<span class="badge-neutral">{{.Status}}</span>
{{end}}
</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<a href="/apps/{{.ID}}" class="btn-text text-sm py-1 px-2">View</a>
<a href="/apps/{{.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
<form method="POST" action="/apps/{{.ID}}/deploy" class="inline">
<button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button>
</form>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card">
<div class="empty-state">
<svg class="empty-state-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<h3 class="empty-state-title">No applications yet</h3>
<p class="empty-state-description">Get started by creating your first application.</p>
<div class="mt-6">
<a href="/apps/new" class="btn-primary">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Create App
</a>
</div>
</div>
</div>
{{end}}
</main>
{{end}}

109
templates/deployments.html Normal file
View File

@ -0,0 +1,109 @@
{{template "base" .}}
{{define "title"}}Deployments - {{.App.Name}} - upaas{{end}}
{{define "content"}}
{{template "nav" .}}
<main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-6">
<a href="/apps/{{.App.ID}}" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to {{.App.Name}}
</a>
</div>
<div class="section-header">
<h1 class="text-2xl font-medium text-gray-900">Deployment History</h1>
<form method="POST" action="/apps/{{.App.ID}}/deploy">
<button type="submit" class="btn-success">Deploy Now</button>
</form>
</div>
{{if .Deployments}}
<div class="space-y-4">
{{range .Deployments}}
<div class="card p-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<div class="flex items-center gap-2 text-sm text-gray-500">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{.StartedAt.Format "2006-01-02 15:04:05"}}</span>
{{if .FinishedAt.Valid}}
<span class="text-gray-400">-</span>
<span>{{.FinishedAt.Time.Format "15:04:05"}}</span>
{{end}}
</div>
<div>
{{if eq .Status "success"}}
<span class="badge-success">Success</span>
{{else if eq .Status "failed"}}
<span class="badge-error">Failed</span>
{{else if eq .Status "building"}}
<span class="badge-warning">Building</span>
{{else if eq .Status "deploying"}}
<span class="badge-info">Deploying</span>
{{else}}
<span class="badge-neutral">{{.Status}}</span>
{{end}}
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
{{if .CommitSHA.Valid}}
<div>
<span class="font-medium text-gray-700">Commit:</span>
<span class="font-mono text-gray-500 ml-1">{{.CommitSHA.String}}</span>
</div>
{{end}}
{{if .ImageID.Valid}}
<div>
<span class="font-medium text-gray-700">Image:</span>
<span class="font-mono text-gray-500 ml-1 text-xs">{{slice .ImageID.String 0 24}}...</span>
</div>
{{end}}
{{if .ContainerID.Valid}}
<div>
<span class="font-medium text-gray-700">Container:</span>
<span class="font-mono text-gray-500 ml-1 text-xs">{{slice .ContainerID.String 0 12}}</span>
</div>
{{end}}
</div>
{{if .Logs.Valid}}
<details class="mt-4">
<summary class="cursor-pointer text-sm text-primary-600 hover:text-primary-800 font-medium inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
View Logs
</summary>
<pre class="mt-3 p-4 bg-gray-900 text-gray-100 rounded-lg text-xs overflow-x-auto font-mono leading-relaxed">{{.Logs.String}}</pre>
</details>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="card">
<div class="empty-state">
<svg class="empty-state-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<h3 class="empty-state-title">No deployments yet</h3>
<p class="empty-state-description">Deploy your application to see the deployment history here.</p>
<div class="mt-6">
<form method="POST" action="/apps/{{.App.ID}}/deploy">
<button type="submit" class="btn-success">Deploy Now</button>
</form>
</div>
</div>
</div>
{{end}}
</main>
{{end}}

50
templates/login.html Normal file
View File

@ -0,0 +1,50 @@
{{template "base" .}}
{{define "title"}}Login - upaas{{end}}
{{define "content"}}
<div class="min-h-screen flex items-center justify-center py-12 px-4">
<div class="max-w-md w-full">
<div class="text-center mb-8">
<h1 class="text-3xl font-medium text-gray-900">upaas</h1>
<p class="mt-2 text-gray-600">Sign in to continue</p>
</div>
<div class="card p-8">
{{template "alert-error" .}}
<form method="POST" action="/login" class="space-y-6">
<div class="form-group">
<label for="username" class="label">Username</label>
<input
type="text"
id="username"
name="username"
value="{{.Username}}"
required
autofocus
autocomplete="username"
class="input"
>
</div>
<div class="form-group">
<label for="password" class="label">Password</label>
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
class="input"
>
</div>
<button type="submit" class="btn-primary w-full py-3">
Sign In
</button>
</form>
</div>
</div>
</div>
{{end}}

69
templates/setup.html Normal file
View File

@ -0,0 +1,69 @@
{{template "base" .}}
{{define "title"}}Setup - upaas{{end}}
{{define "content"}}
<div class="min-h-screen flex items-center justify-center py-12 px-4">
<div class="max-w-md w-full">
<div class="text-center mb-8">
<h1 class="text-3xl font-medium text-gray-900">Welcome to upaas</h1>
<p class="mt-2 text-gray-600">Create your admin account to get started</p>
</div>
<div class="card p-8">
{{template "alert-error" .}}
<form method="POST" action="/setup" class="space-y-6">
<div class="form-group">
<label for="username" class="label">Username</label>
<input
type="text"
id="username"
name="username"
value="{{.Username}}"
required
autofocus
autocomplete="username"
class="input"
placeholder="admin"
>
</div>
<div class="form-group">
<label for="password" class="label">Password</label>
<input
type="password"
id="password"
name="password"
required
autocomplete="new-password"
class="input"
placeholder="Minimum 8 characters"
>
</div>
<div class="form-group">
<label for="password_confirm" class="label">Confirm Password</label>
<input
type="password"
id="password_confirm"
name="password_confirm"
required
autocomplete="new-password"
class="input"
placeholder="Repeat your password"
>
</div>
<button type="submit" class="btn-primary w-full py-3">
Create Account
</button>
</form>
</div>
<p class="mt-6 text-center text-sm text-gray-500">
This is a single-user system. This account will be the only admin.
</p>
</div>
</div>
{{end}}

98
templates/templates.go Normal file
View File

@ -0,0 +1,98 @@
// Package templates provides HTML template handling.
package templates
import (
"embed"
"fmt"
"html/template"
"io"
"sync"
)
//go:embed *.html
var templatesRaw embed.FS
// Template cache variables are global to enable efficient template reuse
// across requests without re-parsing on each call.
var (
//nolint:gochecknoglobals // singleton pattern for template cache
baseTemplate *template.Template
//nolint:gochecknoglobals // singleton pattern for template cache
pageTemplates map[string]*template.Template
//nolint:gochecknoglobals // protects template cache access
templatesMutex sync.RWMutex
)
// initTemplates parses base template and creates cloned templates for each page.
func initTemplates() {
templatesMutex.Lock()
defer templatesMutex.Unlock()
if pageTemplates != nil {
return
}
// Parse base template with shared components
baseTemplate = template.Must(template.ParseFS(templatesRaw, "base.html"))
// Pages that extend base
pages := []string{
"setup.html",
"login.html",
"dashboard.html",
"app_new.html",
"app_detail.html",
"app_edit.html",
"deployments.html",
}
pageTemplates = make(map[string]*template.Template)
for _, page := range pages {
// Clone base template and parse page-specific template into it
clone := template.Must(baseTemplate.Clone())
pageTemplates[page] = template.Must(clone.ParseFS(templatesRaw, page))
}
}
// GetParsed returns a template executor that routes to the correct page template.
func GetParsed() *TemplateExecutor {
initTemplates()
return &TemplateExecutor{}
}
// TemplateExecutor executes templates using the correct cloned template set.
type TemplateExecutor struct{}
// ExecuteTemplate executes the named template with the given data.
func (t *TemplateExecutor) ExecuteTemplate(
writer io.Writer,
name string,
data any,
) error {
templatesMutex.RLock()
tmpl, ok := pageTemplates[name]
templatesMutex.RUnlock()
if !ok {
// Fallback for non-page templates
err := baseTemplate.ExecuteTemplate(writer, name, data)
if err != nil {
return fmt.Errorf("execute base template %s: %w", name, err)
}
return nil
}
// Execute the "base" template from the cloned set
// (which has page-specific overrides)
err := tmpl.ExecuteTemplate(writer, "base", data)
if err != nil {
return fmt.Errorf("execute page template %s: %w", name, err)
}
return nil
}