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