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:
commit
3f9d83c436
31
.golangci.yml
Normal file
31
.golangci.yml
Normal 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
1225
CONVENTIONS.md
Normal file
File diff suppressed because it is too large
Load Diff
39
Dockerfile
Normal file
39
Dockerfile
Normal 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
37
Makefile
Normal 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
179
README.md
Normal 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
312
TODO.md
Normal 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
57
cmd/upaasd/main.go
Normal 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
79
go.mod
Normal 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
219
go.sum
Normal 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
139
internal/config/config.go
Normal 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, ¶ms)
|
||||||
|
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"
|
||||||
|
}
|
||||||
175
internal/database/database.go
Normal file
175
internal/database/database.go
Normal 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: ¶ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
122
internal/database/migrations.go
Normal file
122
internal/database/migrations.go
Normal 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
|
||||||
|
}
|
||||||
94
internal/database/migrations/001_initial.sql
Normal file
94
internal/database/migrations/001_initial.sql
Normal 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
523
internal/docker/client.go
Normal 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: ¶ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
62
internal/globals/globals.go
Normal file
62
internal/globals/globals.go
Normal 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
572
internal/handlers/app.go
Normal 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
82
internal/handlers/auth.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
internal/handlers/dashboard.go
Normal file
33
internal/handlers/dashboard.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
internal/handlers/handlers.go
Normal file
76
internal/handlers/handlers.go
Normal 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: ¶ms,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
486
internal/handlers/handlers_test.go
Normal file
486
internal/handlers/handlers_test.go
Normal 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)
|
||||||
|
}
|
||||||
12
internal/handlers/healthcheck.go
Normal file
12
internal/handlers/healthcheck.go
Normal 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
118
internal/handlers/setup.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
72
internal/handlers/webhook.go
Normal file
72
internal/handlers/webhook.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
85
internal/healthcheck/healthcheck.go
Normal file
85
internal/healthcheck/healthcheck.go
Normal 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: ¶ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
86
internal/logger/logger.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
197
internal/middleware/middleware.go
Normal file
197
internal/middleware/middleware.go
Normal 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: ¶ms,
|
||||||
|
}, 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
290
internal/models/app.go
Normal 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
|
||||||
|
}
|
||||||
241
internal/models/deployment.go
Normal file
241
internal/models/deployment.go
Normal 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
141
internal/models/env_var.go
Normal 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
139
internal/models/label.go
Normal 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
|
||||||
|
}
|
||||||
801
internal/models/models_test.go
Normal file
801
internal/models/models_test.go
Normal 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
150
internal/models/user.go
Normal 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
151
internal/models/volume.go
Normal 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
|
||||||
|
}
|
||||||
198
internal/models/webhook_event.go
Normal file
198
internal/models/webhook_event.go
Normal 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
88
internal/server/routes.go
Normal 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
121
internal/server/server.go
Normal 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
343
internal/service/app/app.go
Normal 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: ¶ms,
|
||||||
|
}, 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
|
||||||
|
}
|
||||||
636
internal/service/app/app_test.go
Normal file
636
internal/service/app/app_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
286
internal/service/auth/auth.go
Normal file
286
internal/service/auth/auth.go
Normal 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: ¶ms,
|
||||||
|
}, 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
|
||||||
|
}
|
||||||
243
internal/service/auth/auth_test.go
Normal file
243
internal/service/auth/auth_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
451
internal/service/deploy/deploy.go
Normal file
451
internal/service/deploy/deploy.go
Normal 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: ¶ms,
|
||||||
|
}, 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)
|
||||||
|
}
|
||||||
280
internal/service/notify/notify.go
Normal file
280
internal/service/notify/notify.go
Normal 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: ¶ms,
|
||||||
|
}, 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
|
||||||
|
}
|
||||||
162
internal/service/webhook/webhook.go
Normal file
162
internal/service/webhook/webhook.go
Normal 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: ¶ms,
|
||||||
|
}, 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
|
||||||
|
}
|
||||||
334
internal/service/webhook/webhook_test.go
Normal file
334
internal/service/webhook/webhook_test.go
Normal 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
53
internal/ssh/keygen.go
Normal 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
|
||||||
|
}
|
||||||
70
internal/ssh/keygen_test.go
Normal file
70
internal/ssh/keygen_test.go
Normal 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
200
static/css/input.css
Normal 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
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
215
static/js/app.js
Normal 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
9
static/static.go
Normal 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
265
templates/app_detail.html
Normal 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
123
templates/app_edit.html
Normal 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
126
templates/app_new.html
Normal 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
60
templates/base.html
Normal 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
91
templates/dashboard.html
Normal 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
109
templates/deployments.html
Normal 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
50
templates/login.html
Normal 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
69
templates/setup.html
Normal 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
98
templates/templates.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user