commit 3f9d83c4363636175e12295be086dc770ed7b35d Author: sneak Date: Mon Dec 29 15:46:03 2025 +0700 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 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d425c0d --- /dev/null +++ b/.golangci.yml @@ -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 diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..6f7c217 --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,1225 @@ +# Go HTTP Server Conventions + +This document defines the architectural patterns, design decisions, and conventions for building Go HTTP servers. All new projects must follow these standards. + +## Table of Contents + +1. [Required Libraries](#1-required-libraries) +2. [Project Structure](#2-project-structure) +3. [Dependency Injection (Uber fx)](#3-dependency-injection-uber-fx) +4. [Server Architecture](#4-server-architecture) +5. [Routing (go-chi)](#5-routing-go-chi) +6. [Handler Conventions](#6-handler-conventions) +7. [Middleware Conventions](#7-middleware-conventions) +8. [Configuration (Viper)](#8-configuration-viper) +9. [Logging (slog)](#9-logging-slog) +10. [Database Wrapper](#10-database-wrapper) +11. [Globals Package](#11-globals-package) +12. [Static Assets & Templates](#12-static-assets--templates) +13. [Health Check](#13-health-check) +14. [External Integrations](#14-external-integrations) + +--- + +## 1. Required Libraries + +These libraries are **mandatory** for all new projects: + +| Purpose | Library | Import Path | +|---------|---------|-------------| +| Dependency Injection | Uber fx | `go.uber.org/fx` | +| HTTP Router | go-chi | `github.com/go-chi/chi` | +| Logging | slog (stdlib) | `log/slog` | +| Configuration | Viper | `github.com/spf13/viper` | +| Environment Loading | godotenv | `github.com/joho/godotenv/autoload` | +| CORS | go-chi/cors | `github.com/go-chi/cors` | +| Error Reporting | Sentry | `github.com/getsentry/sentry-go` | +| Metrics | Prometheus | `github.com/prometheus/client_golang` | +| Metrics Middleware | go-http-metrics | `github.com/slok/go-http-metrics` | +| Basic Auth | basicauth-go | `github.com/99designs/basicauth-go` | + +--- + +## 2. Project Structure + +``` +project-root/ +├── cmd/ +│ └── {appname}/ +│ └── main.go # Entry point +├── internal/ +│ ├── config/ +│ │ └── config.go # Configuration loading +│ ├── database/ +│ │ └── database.go # Database wrapper +│ ├── globals/ +│ │ └── globals.go # Build-time variables +│ ├── handlers/ +│ │ ├── handlers.go # Base handler struct and helpers +│ │ ├── index.go # Individual handlers... +│ │ ├── healthcheck.go +│ │ └── {feature}.go +│ ├── healthcheck/ +│ │ └── healthcheck.go # Health check service +│ ├── logger/ +│ │ └── logger.go # Logger setup +│ ├── middleware/ +│ │ └── middleware.go # All middleware definitions +│ └── server/ +│ ├── server.go # Server struct and lifecycle +│ ├── http.go # HTTP server setup +│ └── routes.go # Route definitions +├── static/ +│ ├── static.go # Embed directive +│ ├── css/ +│ └── js/ +├── templates/ +│ ├── templates.go # Embed and parse +│ └── *.html +├── go.mod +├── go.sum +├── Makefile +└── Dockerfile +``` + +### Key Principles + +- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping. +- **`internal/`**: All application packages. Not importable by external projects. +- **One package per concern**: config, database, handlers, middleware, etc. +- **Flat handler files**: One file per handler or logical group of handlers. + +--- + +## 3. Dependency Injection (Uber fx) + +### Entry Point Pattern + +```go +// cmd/httpd/main.go +package main + +import ( + "yourproject/internal/config" + "yourproject/internal/database" + "yourproject/internal/globals" + "yourproject/internal/handlers" + "yourproject/internal/healthcheck" + "yourproject/internal/logger" + "yourproject/internal/middleware" + "yourproject/internal/server" + "go.uber.org/fx" +) + +var ( + Appname string = "CHANGEME" + Version string + Buildarch string +) + +func main() { + globals.Appname = Appname + globals.Version = Version + globals.Buildarch = Buildarch + + fx.New( + fx.Provide( + config.New, + database.New, + globals.New, + handlers.New, + logger.New, + server.New, + middleware.New, + healthcheck.New, + ), + fx.Invoke(func(*server.Server) {}), + ).Run() +} +``` + +### Params Struct Pattern + +Every component that receives dependencies uses a params struct with `fx.In`: + +```go +type HandlersParams struct { + fx.In + Logger *logger.Logger + Globals *globals.Globals + Database *database.Database + Healthcheck *healthcheck.Healthcheck +} + +type Handlers struct { + params *HandlersParams + log *slog.Logger + hc *healthcheck.Healthcheck +} +``` + +### Factory Function Pattern + +All components expose a `New` function with this signature: + +```go +func New(lc fx.Lifecycle, params SomeParams) (*Something, error) { + s := new(Something) + s.params = ¶ms + s.log = params.Logger.Get() + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + // Initialize resources + return nil + }, + OnStop: func(ctx context.Context) error { + // Cleanup resources + return nil + }, + }) + return s, nil +} +``` + +### Dependency Order + +Providers are resolved automatically by fx, but conceptually follow this order: + +1. `globals.New` - Build-time variables (no dependencies) +2. `logger.New` - Logger (depends on Globals) +3. `config.New` - Configuration (depends on Globals, Logger) +4. `database.New` - Database (depends on Logger, Config) +5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, Database) +6. `middleware.New` - Middleware (depends on Logger, Globals, Config) +7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck) +8. `server.New` - Server (depends on all above) + +--- + +## 4. Server Architecture + +### Server Struct + +The Server struct is the central orchestrator: + +```go +// internal/server/server.go +type ServerParams struct { + fx.In + Logger *logger.Logger + Globals *globals.Globals + Config *config.Config + Middleware *middleware.Middleware + Handlers *handlers.Handlers +} + +type Server struct { + startupTime time.Time + port int + exitCode int + sentryEnabled bool + log *slog.Logger + ctx context.Context + cancelFunc context.CancelFunc + httpServer *http.Server + router *chi.Mux + params ServerParams + mw *middleware.Middleware + h *handlers.Handlers +} +``` + +### Server Factory + +```go +func New(lc fx.Lifecycle, params ServerParams) (*Server, error) { + s := new(Server) + s.params = params + s.mw = params.Middleware + s.h = params.Handlers + s.log = params.Logger.Get() + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + s.startupTime = time.Now() + go s.Run() + return nil + }, + OnStop: func(ctx context.Context) error { + // Server shutdown logic + return nil + }, + }) + return s, nil +} +``` + +### HTTP Server Setup + +```go +// internal/server/http.go +func (s *Server) serveUntilShutdown() { + listenAddr := fmt.Sprintf(":%d", s.params.Config.Port) + s.httpServer = &http.Server{ + Addr: listenAddr, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + Handler: s, + } + + s.SetupRoutes() + + s.log.Info("http begin listen", "listenaddr", listenAddr) + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + s.log.Error("listen error", "error", err) + if s.cancelFunc != nil { + s.cancelFunc() + } + } +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.router.ServeHTTP(w, r) +} +``` + +### Signal Handling and Graceful Shutdown + +```go +func (s *Server) serve() int { + s.ctx, s.cancelFunc = context.WithCancel(context.Background()) + + // Signal watcher + go func() { + c := make(chan os.Signal, 1) + signal.Ignore(syscall.SIGPIPE) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + sig := <-c + s.log.Info("signal received", "signal", sig) + if s.cancelFunc != nil { + s.cancelFunc() + } + }() + + go s.serveUntilShutdown() + + for range s.ctx.Done() { + } + s.cleanShutdown() + return s.exitCode +} + +func (s *Server) cleanShutdown() { + s.exitCode = 0 + ctxShutdown, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := s.httpServer.Shutdown(ctxShutdown); err != nil { + s.log.Error("server clean shutdown failed", "error", err) + } + if shutdownCancel != nil { + shutdownCancel() + } + s.cleanupForExit() + if s.sentryEnabled { + sentry.Flush(2 * time.Second) + } +} +``` + +--- + +## 5. Routing (go-chi) + +### Route Setup Pattern + +```go +// internal/server/routes.go +func (s *Server) SetupRoutes() { + s.router = chi.NewRouter() + + // Global middleware (applied to all routes) + s.router.Use(middleware.Recoverer) + s.router.Use(middleware.RequestID) + s.router.Use(s.mw.Logging()) + + // Conditional middleware + if viper.GetString("METRICS_USERNAME") != "" { + s.router.Use(s.mw.Metrics()) + } + + s.router.Use(s.mw.CORS()) + s.router.Use(middleware.Timeout(60 * time.Second)) + + if s.sentryEnabled { + sentryHandler := sentryhttp.New(sentryhttp.Options{ + Repanic: true, + }) + s.router.Use(sentryHandler.Handle) + } + + // Routes + s.router.Get("/", s.h.HandleIndex()) + + // Static files + s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static)))) + + // API versioning + s.router.Route("/api/v1", func(r chi.Router) { + r.Get("/now", s.h.HandleNow()) + }) + + // Routes with specific middleware + auth := s.mw.Auth() + s.router.Get("/login", auth(s.h.HandleLoginGET()).ServeHTTP) + + // Health check (standard path) + s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck()) + + // Protected route groups + if viper.GetString("METRICS_USERNAME") != "" { + s.router.Group(func(r chi.Router) { + r.Use(s.mw.MetricsAuth()) + r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP)) + }) + } +} +``` + +### Middleware Ordering (Critical) + +1. `middleware.Recoverer` - Panic recovery (must be first) +2. `middleware.RequestID` - Generate request IDs +3. `s.mw.Logging()` - Request logging +4. `s.mw.Metrics()` - Prometheus metrics (if enabled) +5. `s.mw.CORS()` - CORS headers +6. `middleware.Timeout(60s)` - Request timeout +7. `sentryhttp.Handler` - Sentry error reporting (if enabled) + +### API Versioning + +Use route groups for API versioning: + +```go +s.router.Route("/api/v1", func(r chi.Router) { + r.Get("/resource", s.h.HandleResource()) +}) +``` + +### Static File Serving + +Static files are served at `/s/` prefix: + +```go +s.router.Mount("/s", http.StripPrefix("/s", http.FileServer(http.FS(static.Static)))) +``` + +--- + +## 6. Handler Conventions + +### Handler Base Struct + +```go +// internal/handlers/handlers.go +type HandlersParams struct { + fx.In + Logger *logger.Logger + Globals *globals.Globals + Database *database.Database + Healthcheck *healthcheck.Healthcheck +} + +type Handlers struct { + params *HandlersParams + log *slog.Logger + hc *healthcheck.Healthcheck +} + +func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { + s := new(Handlers) + s.params = ¶ms + s.log = params.Logger.Get() + s.hc = params.Healthcheck + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + // Compile templates or other initialization + return nil + }, + }) + return s, nil +} +``` + +### Closure-Based Handler Pattern + +All handlers return `http.HandlerFunc` using the closure pattern. This allows initialization logic to run once when the handler is created: + +```go +// internal/handlers/index.go +func (s *Handlers) HandleIndex() http.HandlerFunc { + // Initialization runs once + t := templates.GetParsed() + + // Handler runs per-request + return func(w http.ResponseWriter, r *http.Request) { + err := t.ExecuteTemplate(w, "index.html", nil) + if err != nil { + s.log.Error("template execution failed", "error", err) + http.Error(w, http.StatusText(500), 500) + } + } +} +``` + +### JSON Handler Pattern + +```go +// internal/handlers/now.go +func (s *Handlers) HandleNow() http.HandlerFunc { + // Response struct defined in closure scope + type response struct { + Now time.Time `json:"now"` + } + return func(w http.ResponseWriter, r *http.Request) { + s.respondJSON(w, r, &response{Now: time.Now()}, 200) + } +} +``` + +### Response Helpers + +```go +// internal/handlers/handlers.go +func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) { + w.WriteHeader(status) + w.Header().Set("Content-Type", "application/json") + if data != nil { + err := json.NewEncoder(w).Encode(data) + if err != nil { + s.log.Error("json encode error", "error", err) + } + } +} + +func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error { + return json.NewDecoder(r.Body).Decode(v) +} +``` + +### Handler Naming Convention + +- `HandleIndex()` - Main page +- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix +- `HandleNow()` - API endpoints +- `HandleHealthCheck()` - System endpoints + +--- + +## 7. Middleware Conventions + +### Middleware Struct + +```go +// internal/middleware/middleware.go +type MiddlewareParams struct { + fx.In + Logger *logger.Logger + Globals *globals.Globals + Config *config.Config +} + +type Middleware struct { + log *slog.Logger + params *MiddlewareParams +} + +func New(lc fx.Lifecycle, params MiddlewareParams) (*Middleware, error) { + s := new(Middleware) + s.params = ¶ms + s.log = params.Logger.Get() + return s, nil +} +``` + +### Middleware Signature + +All custom middleware methods return `func(http.Handler) http.Handler`: + +```go +func (s *Middleware) Auth() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Before request + s.log.Info("AUTH: before request") + + next.ServeHTTP(w, r) + + // After request (optional) + }) + } +} +``` + +### Logging Middleware with Status Capture + +```go +type loggingResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter { + return &loggingResponseWriter{w, http.StatusOK} +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + lrw.ResponseWriter.WriteHeader(code) +} + +func (s *Middleware) Logging() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lrw := NewLoggingResponseWriter(w) + ctx := r.Context() + defer func() { + latency := time.Since(start) + s.log.InfoContext(ctx, "request", + "request_start", start, + "method", r.Method, + "url", r.URL.String(), + "useragent", r.UserAgent(), + "request_id", ctx.Value(middleware.RequestIDKey).(string), + "referer", r.Referer(), + "proto", r.Proto, + "remoteIP", ipFromHostPort(r.RemoteAddr), + "status", lrw.statusCode, + "latency_ms", latency.Milliseconds(), + ) + }() + next.ServeHTTP(lrw, r) + }) + } +} +``` + +### CORS Middleware + +```go +func (s *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: 300, + }) +} +``` + +### Metrics Middleware + +```go +func (s *Middleware) Metrics() func(http.Handler) http.Handler { + mdlw := ghmm.New(ghmm.Config{ + Recorder: metrics.NewRecorder(metrics.Config{}), + }) + return func(next http.Handler) http.Handler { + return std.Handler("", mdlw, next) + } +} + +func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler { + return basicauth.New( + "metrics", + map[string][]string{ + viper.GetString("METRICS_USERNAME"): { + viper.GetString("METRICS_PASSWORD"), + }, + }, + ) +} +``` + +--- + +## 8. Configuration (Viper) + +### Config Struct + +```go +// internal/config/config.go +type ConfigParams struct { + fx.In + Globals *globals.Globals + Logger *logger.Logger +} + +type Config struct { + DBURL string + Debug bool + MaintenanceMode bool + MetricsPassword string + MetricsUsername string + Port int + SentryDSN string + params *ConfigParams + log *slog.Logger +} +``` + +### Configuration Loading + +```go +func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { + log := params.Logger.Get() + name := params.Globals.Appname + + // Config file settings + viper.SetConfigName(name) + viper.SetConfigType("yaml") + viper.AddConfigPath(fmt.Sprintf("/etc/%s", name)) + viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", name)) + + // Environment variables override everything + viper.AutomaticEnv() + + // Defaults + viper.SetDefault("DEBUG", "false") + viper.SetDefault("MAINTENANCE_MODE", "false") + viper.SetDefault("PORT", "8080") + viper.SetDefault("DBURL", "") + viper.SetDefault("SENTRY_DSN", "") + viper.SetDefault("METRICS_USERNAME", "") + viper.SetDefault("METRICS_PASSWORD", "") + + // Read config file (optional) + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + // Config file not found is OK + } else { + log.Error("config file malformed", "error", err) + panic(err) + } + } + + // Build config struct + s := &Config{ + DBURL: viper.GetString("DBURL"), + Debug: viper.GetBool("debug"), + Port: viper.GetInt("PORT"), + SentryDSN: viper.GetString("SENTRY_DSN"), + MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), + MetricsUsername: viper.GetString("METRICS_USERNAME"), + MetricsPassword: viper.GetString("METRICS_PASSWORD"), + log: log, + params: ¶ms, + } + + // Enable debug logging if configured + if s.Debug { + params.Logger.EnableDebugLogging() + s.log = params.Logger.Get() + } + + return s, nil +} +``` + +### Configuration Precedence + +1. **Environment variables** (highest priority via `AutomaticEnv()`) +2. **`.env` file** (loaded via `godotenv/autoload` import) +3. **Config files**: `/etc/{appname}/{appname}.yaml`, `~/.config/{appname}/{appname}.yaml` +4. **Defaults** (lowest priority) + +### Environment Loading + +Import godotenv with autoload to automatically load `.env` files: + +```go +import ( + _ "github.com/joho/godotenv/autoload" +) +``` + +--- + +## 9. Logging (slog) + +### Logger Struct + +```go +// internal/logger/logger.go +type LoggerParams struct { + fx.In + Globals *globals.Globals +} + +type Logger struct { + log *slog.Logger + level *slog.LevelVar + params LoggerParams +} +``` + +### Logger Setup with TTY Detection + +```go +func New(lc fx.Lifecycle, params LoggerParams) (*Logger, error) { + l := new(Logger) + l.level = new(slog.LevelVar) + l.level.Set(slog.LevelInfo) + + // TTY detection for dev vs prod output + tty := false + if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 { + tty = true + } + + var handler slog.Handler + if tty { + // Text output for development + handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: l.level, + AddSource: true, + }) + } else { + // JSON output for production + handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: l.level, + AddSource: true, + }) + } + + l.log = slog.New(handler) + return l, nil +} +``` + +### Logger Methods + +```go +func (l *Logger) EnableDebugLogging() { + l.level.Set(slog.LevelDebug) + l.log.Debug("debug logging enabled", "debug", true) +} + +func (l *Logger) Get() *slog.Logger { + return l.log +} + +func (l *Logger) Identify() { + l.log.Info("starting", + "appname", l.params.Globals.Appname, + "version", l.params.Globals.Version, + "buildarch", l.params.Globals.Buildarch, + ) +} +``` + +### Logging Patterns + +```go +// Info with fields +s.log.Info("message", "key", "value") + +// Error with error object +s.log.Error("operation failed", "error", err) + +// With context +s.log.InfoContext(ctx, "processing request", "request_id", reqID) + +// Structured request logging +s.log.Info("request completed", + "request_start", start, + "method", r.Method, + "url", r.URL.String(), + "status", statusCode, + "latency_ms", latency.Milliseconds(), +) + +// Using slog.Group for nested attributes +s.log.Info("request", + slog.Group("http", + "method", r.Method, + "url", r.URL.String(), + ), + slog.Group("timing", + "start", start, + "latency_ms", latency.Milliseconds(), + ), +) +``` + +--- + +## 10. Database Wrapper + +### Database Struct + +```go +// internal/database/database.go +type DatabaseParams struct { + fx.In + Logger *logger.Logger + Config *config.Config +} + +type Database struct { + URL string + log *slog.Logger + params *DatabaseParams +} +``` + +### Database Factory with Lifecycle + +```go +func New(lc fx.Lifecycle, params DatabaseParams) (*Database, error) { + s := new(Database) + s.params = ¶ms + s.log = params.Logger.Get() + + s.log.Info("Database instantiated") + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + s.log.Info("Database OnStart Hook") + // Connect to database here + // Example: s.db, err = sql.Open("postgres", s.params.Config.DBURL) + return nil + }, + OnStop: func(ctx context.Context) error { + // Disconnect from database here + // Example: s.db.Close() + return nil + }, + }) + return s, nil +} +``` + +### Usage Pattern + +The Database struct is injected into handlers and other services: + +```go +type HandlersParams struct { + fx.In + Database *database.Database + // ... +} + +// Access in handler +func (s *Handlers) HandleSomething() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Use s.params.Database + } +} +``` + +--- + +## 11. Globals Package + +### Package Variables and Struct + +```go +// internal/globals/globals.go +package globals + +import "go.uber.org/fx" + +// Package-level variables (set from main) +var ( + Appname string + Version string + Buildarch string +) + +// Struct for DI +type Globals struct { + Appname string + Version string + Buildarch string +} + +func New(lc fx.Lifecycle) (*Globals, error) { + n := &Globals{ + Appname: Appname, + Buildarch: Buildarch, + Version: Version, + } + return n, nil +} +``` + +### Setting Globals in Main + +```go +// cmd/httpd/main.go +var ( + Appname string = "CHANGEME" // Default, overridden by build + Version string // Set at build time + Buildarch string // Set at build time +) + +func main() { + globals.Appname = Appname + globals.Version = Version + globals.Buildarch = Buildarch + // ... +} +``` + +### Build-Time Variable Injection + +Use ldflags to inject version information at build time: + +```makefile +VERSION := $(shell git describe --tags --always) +BUILDARCH := $(shell go env GOARCH) + +build: + go build -ldflags "-X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)" ./cmd/httpd +``` + +--- + +## 12. Static Assets & Templates + +### Static File Embedding + +```go +// static/static.go +package static + +import "embed" + +//go:embed css js +var Static embed.FS +``` + +Directory structure: +``` +static/ +├── static.go +├── css/ +│ ├── bootstrap-4.5.3.min.css +│ └── style.css +└── js/ + ├── bootstrap-4.5.3.bundle.min.js + └── jquery-3.5.1.slim.min.js +``` + +### Template Embedding and Lazy Parsing + +```go +// templates/templates.go +package templates + +import ( + "embed" + "text/template" +) + +//go:embed *.html +var TemplatesRaw embed.FS +var TemplatesParsed *template.Template + +func GetParsed() *template.Template { + if TemplatesParsed == nil { + TemplatesParsed = template.Must(template.ParseFS(TemplatesRaw, "*")) + } + return TemplatesParsed +} +``` + +### Template Composition + +Templates use Go's template composition: + +```html + +{{ template "htmlheader.html" . }} +{{ template "navbar.html" . }} + +
+ +
+ +{{ template "pagefooter.html" . }} +{{ template "htmlfooter.html" . }} +``` + +### Static Asset References + +Reference static files with `/s/` prefix: + +```html + + + +``` + +--- + +## 13. Health Check + +### Health Check Service + +```go +// internal/healthcheck/healthcheck.go +type HealthcheckParams struct { + fx.In + Globals *globals.Globals + Config *config.Config + Logger *logger.Logger + Database *database.Database +} + +type Healthcheck struct { + StartupTime time.Time + log *slog.Logger + params *HealthcheckParams +} + +func New(lc fx.Lifecycle, params HealthcheckParams) (*Healthcheck, error) { + s := new(Healthcheck) + s.params = ¶ms + s.log = params.Logger.Get() + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + s.StartupTime = time.Now() + return nil + }, + OnStop: func(ctx context.Context) error { + return nil + }, + }) + return s, nil +} +``` + +### Health Check Response + +```go +type HealthcheckResponse struct { + Status string `json:"status"` + Now string `json:"now"` + UptimeSeconds int64 `json:"uptime_seconds"` + UptimeHuman string `json:"uptime_human"` + Version string `json:"version"` + Appname string `json:"appname"` + Maintenance bool `json:"maintenance_mode"` +} + +func (s *Healthcheck) uptime() time.Duration { + return time.Since(s.StartupTime) +} + +func (s *Healthcheck) Healthcheck() *HealthcheckResponse { + resp := &HealthcheckResponse{ + Status: "ok", + Now: time.Now().UTC().Format(time.RFC3339Nano), + UptimeSeconds: int64(s.uptime().Seconds()), + UptimeHuman: s.uptime().String(), + Appname: s.params.Globals.Appname, + Version: s.params.Globals.Version, + } + return resp +} +``` + +### Standard Endpoint + +Health check is served at the standard `.well-known` path: + +```go +s.router.Get("/.well-known/healthcheck.json", s.h.HandleHealthCheck()) +``` + +--- + +## 14. External Integrations + +### Sentry Error Reporting + +Sentry is conditionally enabled based on `SENTRY_DSN` environment variable: + +```go +func (s *Server) enableSentry() { + s.sentryEnabled = false + + if s.params.Config.SentryDSN == "" { + return + } + + err := sentry.Init(sentry.ClientOptions{ + Dsn: s.params.Config.SentryDSN, + Release: fmt.Sprintf("%s-%s", s.params.Globals.Appname, s.params.Globals.Version), + }) + if err != nil { + s.log.Error("sentry init failure", "error", err) + os.Exit(1) + return + } + s.log.Info("sentry error reporting activated") + s.sentryEnabled = true +} +``` + +Sentry middleware with repanic (bubbles panics to chi's Recoverer): + +```go +if s.sentryEnabled { + sentryHandler := sentryhttp.New(sentryhttp.Options{ + Repanic: true, + }) + s.router.Use(sentryHandler.Handle) +} +``` + +Flush Sentry on shutdown: + +```go +if s.sentryEnabled { + sentry.Flush(2 * time.Second) +} +``` + +### Prometheus Metrics + +Metrics are conditionally enabled and protected by basic auth: + +```go +// Only enable if credentials are configured +if viper.GetString("METRICS_USERNAME") != "" { + s.router.Use(s.mw.Metrics()) +} + +// Protected /metrics endpoint +if viper.GetString("METRICS_USERNAME") != "" { + s.router.Group(func(r chi.Router) { + r.Use(s.mw.MetricsAuth()) + r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP)) + }) +} +``` + +### Environment Variables Summary + +| Variable | Description | Default | +|----------|-------------|---------| +| `PORT` | HTTP listen port | 8080 | +| `DEBUG` | Enable debug logging | false | +| `DBURL` | Database connection URL | "" | +| `SENTRY_DSN` | Sentry DSN for error reporting | "" | +| `MAINTENANCE_MODE` | Enable maintenance mode | false | +| `METRICS_USERNAME` | Basic auth username for /metrics | "" | +| `METRICS_PASSWORD` | Basic auth password for /metrics | "" | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3c6c1d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..da404e3 --- /dev/null +++ b/Makefile @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e736af6 --- /dev/null +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3f96ec9 --- /dev/null +++ b/TODO.md @@ -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 +} +``` diff --git a/cmd/upaasd/main.go b/cmd/upaasd/main.go new file mode 100644 index 0000000..7ba6051 --- /dev/null +++ b/cmd/upaasd/main.go @@ -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() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..46da669 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..000ccad --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0607ead --- /dev/null +++ b/internal/config/config.go @@ -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" +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..0479445 --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/database/migrations.go b/internal/database/migrations.go new file mode 100644 index 0000000..6e9b923 --- /dev/null +++ b/internal/database/migrations.go @@ -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 +} diff --git a/internal/database/migrations/001_initial.sql b/internal/database/migrations/001_initial.sql new file mode 100644 index 0000000..2e5e94b --- /dev/null +++ b/internal/database/migrations/001_initial.sql @@ -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); diff --git a/internal/docker/client.go b/internal/docker/client.go new file mode 100644 index 0000000..6390efb --- /dev/null +++ b/internal/docker/client.go @@ -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 +} diff --git a/internal/globals/globals.go b/internal/globals/globals.go new file mode 100644 index 0000000..8c11187 --- /dev/null +++ b/internal/globals/globals.go @@ -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 +} diff --git a/internal/handlers/app.go b/internal/handlers/app.go new file mode 100644 index 0000000..120a29d --- /dev/null +++ b/internal/handlers/app.go @@ -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) + } +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..faa4520 --- /dev/null +++ b/internal/handlers/auth.go @@ -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) + } +} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go new file mode 100644 index 0000000..cce9777 --- /dev/null +++ b/internal/handlers/dashboard.go @@ -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) + } + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..2d8c3ec --- /dev/null +++ b/internal/handlers/handlers.go @@ -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) + } + } +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go new file mode 100644 index 0000000..5bf5d61 --- /dev/null +++ b/internal/handlers/handlers_test.go @@ -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) +} diff --git a/internal/handlers/healthcheck.go b/internal/handlers/healthcheck.go new file mode 100644 index 0000000..10ad54a --- /dev/null +++ b/internal/handlers/healthcheck.go @@ -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) + } +} diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go new file mode 100644 index 0000000..0202585 --- /dev/null +++ b/internal/handlers/setup.go @@ -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) + } +} diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go new file mode 100644 index 0000000..e1e0ba9 --- /dev/null +++ b/internal/handlers/webhook.go @@ -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) + } +} diff --git a/internal/healthcheck/healthcheck.go b/internal/healthcheck/healthcheck.go new file mode 100644 index 0000000..f80afe1 --- /dev/null +++ b/internal/healthcheck/healthcheck.go @@ -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) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..aae1df1 --- /dev/null +++ b/internal/logger/logger.go @@ -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, + ) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..7173609 --- /dev/null +++ b/internal/middleware/middleware.go @@ -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) + }) + } +} diff --git a/internal/models/app.go b/internal/models/app.go new file mode 100644 index 0000000..1c8a35e --- /dev/null +++ b/internal/models/app.go @@ -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 +} diff --git a/internal/models/deployment.go b/internal/models/deployment.go new file mode 100644 index 0000000..13fbdc5 --- /dev/null +++ b/internal/models/deployment.go @@ -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 +} diff --git a/internal/models/env_var.go b/internal/models/env_var.go new file mode 100644 index 0000000..916a70a --- /dev/null +++ b/internal/models/env_var.go @@ -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 +} diff --git a/internal/models/label.go b/internal/models/label.go new file mode 100644 index 0000000..5cbdb9c --- /dev/null +++ b/internal/models/label.go @@ -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 +} diff --git a/internal/models/models_test.go b/internal/models/models_test.go new file mode 100644 index 0000000..308df8f --- /dev/null +++ b/internal/models/models_test.go @@ -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 +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..dd66fb3 --- /dev/null +++ b/internal/models/user.go @@ -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 +} diff --git a/internal/models/volume.go b/internal/models/volume.go new file mode 100644 index 0000000..c5e5fa9 --- /dev/null +++ b/internal/models/volume.go @@ -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 +} diff --git a/internal/models/webhook_event.go b/internal/models/webhook_event.go new file mode 100644 index 0000000..4265ac5 --- /dev/null +++ b/internal/models/webhook_event.go @@ -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() +} diff --git a/internal/server/routes.go b/internal/server/routes.go new file mode 100644 index 0000000..eee7169 --- /dev/null +++ b/internal/server/routes.go @@ -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) + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..5d091d3 --- /dev/null +++ b/internal/server/server.go @@ -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) +} diff --git a/internal/service/app/app.go b/internal/service/app/app.go new file mode 100644 index 0000000..522f484 --- /dev/null +++ b/internal/service/app/app.go @@ -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 +} diff --git a/internal/service/app/app_test.go b/internal/service/app/app_test.go new file mode 100644 index 0000000..53cdf72 --- /dev/null +++ b/internal/service/app/app_test.go @@ -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) + }) +} diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go new file mode 100644 index 0000000..352cbf1 --- /dev/null +++ b/internal/service/auth/auth.go @@ -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 +} diff --git a/internal/service/auth/auth_test.go b/internal/service/auth/auth_test.go new file mode 100644 index 0000000..29aa80d --- /dev/null +++ b/internal/service/auth/auth_test.go @@ -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) + }) +} diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go new file mode 100644 index 0000000..fd5ef18 --- /dev/null +++ b/internal/service/deploy/deploy.go @@ -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) +} diff --git a/internal/service/notify/notify.go b/internal/service/notify/notify.go new file mode 100644 index 0000000..8488b8c --- /dev/null +++ b/internal/service/notify/notify.go @@ -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 +} diff --git a/internal/service/webhook/webhook.go b/internal/service/webhook/webhook.go new file mode 100644 index 0000000..80b8393 --- /dev/null +++ b/internal/service/webhook/webhook.go @@ -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 +} diff --git a/internal/service/webhook/webhook_test.go b/internal/service/webhook/webhook_test.go new file mode 100644 index 0000000..3820cb8 --- /dev/null +++ b/internal/service/webhook/webhook_test.go @@ -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) + }) +} diff --git a/internal/ssh/keygen.go b/internal/ssh/keygen.go new file mode 100644 index 0000000..49e0ee9 --- /dev/null +++ b/internal/ssh/keygen.go @@ -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 +} diff --git a/internal/ssh/keygen_test.go b/internal/ssh/keygen_test.go new file mode 100644 index 0000000..fc2da06 --- /dev/null +++ b/internal/ssh/keygen_test.go @@ -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) + }) +} diff --git a/static/css/input.css b/static/css/input.css new file mode 100644 index 0000000..e298c11 --- /dev/null +++ b/static/css/input.css @@ -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; + } +} diff --git a/static/css/tailwind.css b/static/css/tailwind.css new file mode 100644 index 0000000..2f4431a --- /dev/null +++ b/static/css/tailwind.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-leading:initial;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-contain-size:initial;--tw-contain-layout:initial;--tw-contain-paint:initial;--tw-contain-style:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-serif:ui-serif,Georgia,Cambria,"Times New Roman",Times,serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-yellow-950:oklch(28.6% .066 53.813);--color-lime-50:oklch(98.6% .031 120.757);--color-lime-100:oklch(96.7% .067 122.328);--color-lime-200:oklch(93.8% .127 124.321);--color-lime-300:oklch(89.7% .196 126.665);--color-lime-400:oklch(84.1% .238 128.85);--color-lime-500:oklch(76.8% .233 130.85);--color-lime-600:oklch(64.8% .2 131.684);--color-lime-700:oklch(53.2% .157 131.589);--color-lime-800:oklch(45.3% .124 130.933);--color-lime-900:oklch(40.5% .101 131.063);--color-lime-950:oklch(27.4% .072 132.109);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-900:oklch(37.8% .077 168.94);--color-emerald-950:oklch(26.2% .051 172.552);--color-teal-50:oklch(98.4% .014 180.72);--color-teal-100:oklch(95.3% .051 180.801);--color-teal-200:oklch(91% .096 180.426);--color-teal-300:oklch(85.5% .138 181.071);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-teal-600:oklch(60% .118 184.704);--color-teal-700:oklch(51.1% .096 186.391);--color-teal-800:oklch(43.7% .078 188.216);--color-teal-900:oklch(38.6% .063 188.416);--color-teal-950:oklch(27.7% .046 192.524);--color-cyan-50:oklch(98.4% .019 200.873);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-cyan-700:oklch(52% .105 223.128);--color-cyan-800:oklch(45% .085 224.283);--color-cyan-900:oklch(39.8% .07 227.392);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-sky-900:oklch(39.1% .09 240.876);--color-sky-950:oklch(29.3% .066 243.157);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-indigo-900:oklch(35.9% .144 278.697);--color-indigo-950:oklch(25.7% .09 281.288);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--color-violet-950:oklch(28.3% .141 291.089);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-700:oklch(49.6% .265 301.924);--color-purple-800:oklch(43.8% .218 303.724);--color-purple-900:oklch(38.1% .176 304.987);--color-purple-950:oklch(29.1% .149 302.717);--color-fuchsia-50:oklch(97.7% .017 320.058);--color-fuchsia-100:oklch(95.2% .037 318.852);--color-fuchsia-200:oklch(90.3% .076 319.62);--color-fuchsia-300:oklch(83.3% .145 321.434);--color-fuchsia-400:oklch(74% .238 322.16);--color-fuchsia-500:oklch(66.7% .295 322.15);--color-fuchsia-600:oklch(59.1% .293 322.896);--color-fuchsia-700:oklch(51.8% .253 323.949);--color-fuchsia-800:oklch(45.2% .211 324.591);--color-fuchsia-900:oklch(40.1% .17 325.612);--color-fuchsia-950:oklch(29.3% .136 325.661);--color-pink-50:oklch(97.1% .014 343.198);--color-pink-100:oklch(94.8% .028 342.258);--color-pink-200:oklch(89.9% .061 343.231);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-400:oklch(71.8% .202 349.761);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-600:oklch(59.2% .249 .584);--color-pink-700:oklch(52.5% .223 3.958);--color-pink-800:oklch(45.9% .187 3.815);--color-pink-900:oklch(40.8% .153 2.432);--color-pink-950:oklch(28.4% .109 3.907);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-300:oklch(81% .117 11.638);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-rose-800:oklch(45.5% .188 13.697);--color-rose-900:oklch(41% .159 10.272);--color-rose-950:oklch(27.1% .105 12.094);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-gray-950:oklch(13% .028 261.692);--color-zinc-50:oklch(98.5% 0 0);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-neutral-50:oklch(98.5% 0 0);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-stone-50:oklch(98.5% .001 106.423);--color-stone-100:oklch(97% .001 106.424);--color-stone-200:oklch(92.3% .003 48.717);--color-stone-300:oklch(86.9% .005 56.366);--color-stone-400:oklch(70.9% .01 56.259);--color-stone-500:oklch(55.3% .013 58.071);--color-stone-600:oklch(44.4% .011 73.639);--color-stone-700:oklch(37.4% .01 67.558);--color-stone-800:oklch(26.8% .007 34.298);--color-stone-900:oklch(21.6% .006 56.043);--color-stone-950:oklch(14.7% .004 49.25);--color-black:#000;--color-white:#fff;--spacing:.25rem;--breakpoint-sm:40rem;--breakpoint-md:48rem;--breakpoint-lg:64rem;--breakpoint-xl:80rem;--breakpoint-2xl:96rem;--container-3xs:16rem;--container-2xs:18rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--text-8xl:6rem;--text-8xl--line-height:1;--text-9xl:8rem;--text-9xl--line-height:1;--font-weight-thin:100;--font-weight-extralight:200;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--leading-loose:2;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--shadow-2xs:0 1px #0000000d;--shadow-xs:0 1px 2px 0 #0000000d;--shadow-sm:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--shadow-xl:0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a;--shadow-2xl:0 25px 50px -12px #00000040;--inset-shadow-2xs:inset 0 1px #0000000d;--inset-shadow-xs:inset 0 1px 1px #0000000d;--inset-shadow-sm:inset 0 2px 4px #0000000d;--drop-shadow-xs:0 1px 1px #0000000d;--drop-shadow-sm:0 1px 2px #00000026;--drop-shadow-md:0 3px 3px #0000001f;--drop-shadow-lg:0 4px 4px #00000026;--drop-shadow-xl:0 9px 7px #0000001a;--drop-shadow-2xl:0 25px 25px #00000026;--text-shadow-2xs:0px 1px 0px #00000026;--text-shadow-xs:0px 1px 1px #0003;--text-shadow-sm:0px 1px 0px #00000013,0px 1px 1px #00000013,0px 2px 2px #00000013;--text-shadow-md:0px 1px 1px #0000001a,0px 1px 2px #0000001a,0px 2px 4px #0000001a;--text-shadow-lg:0px 1px 2px #0000001a,0px 3px 2px #0000001a,0px 4px 8px #0000001a;--ease-in:cubic-bezier(.4,0,1,1);--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0,0,.2,1)infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--blur-2xl:40px;--blur-3xl:64px;--perspective-dramatic:100px;--perspective-near:300px;--perspective-normal:500px;--perspective-midrange:800px;--perspective-distant:1200px;--aspect-video:16/9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-primary-50:#e3f2fd;--color-primary-100:#bbdefb;--color-primary-500:#2196f3;--color-primary-600:#1e88e5;--color-primary-700:#1976d2;--color-primary-800:#1565c0;--color-error-50:#ffebee;--color-error-500:#f44336;--color-error-700:#d32f2f;--color-success-50:#e8f5e9;--color-success-500:#4caf50;--color-success-700:#388e3c;--color-warning-50:#fff3e0;--color-warning-500:#ff9800;--color-warning-700:#f57c00}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.btn-primary{border-radius:var(--radius-md);background-color:var(--color-primary-600);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-primary:hover{background-color:var(--color-primary-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029),0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.btn-primary:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-primary:active{background-color:var(--color-primary-800)}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);background-color:var(--color-white);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-secondary:hover{background-color:var(--color-gray-50)}}.btn-secondary:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-secondary:active{background-color:var(--color-gray-100)}.btn-secondary:disabled{cursor:not-allowed;opacity:.5}.btn-danger{border-radius:var(--radius-md);background-color:var(--color-error-500);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-danger:hover{background-color:var(--color-error-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029),0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.btn-danger:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-red-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-danger:active{background-color:var(--color-red-800)}.btn-danger:disabled{cursor:not-allowed;opacity:.5}.btn-success{border-radius:var(--radius-md);background-color:var(--color-success-500);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-success:hover{background-color:var(--color-success-700);--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029),0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.btn-success:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-green-500);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-success:active{background-color:var(--color-green-800)}.btn-success:disabled{cursor:not-allowed;opacity:.5}.btn-text{border-radius:var(--radius-md);padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-600);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;justify-content:center;align-items:center;transition-duration:.2s;display:inline-flex}@media (hover:hover){.btn-text:hover{background-color:var(--color-primary-50)}}.btn-text:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color);--tw-outline-style:none;outline-style:none}.btn-text:active{background-color:var(--color-primary-100)}.btn-text:disabled{cursor:not-allowed;opacity:.5}.btn-icon{padding:calc(var(--spacing)*2);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px}@media (hover:hover){.btn-icon:hover{background-color:var(--color-gray-100)}}.btn-icon:active{background-color:var(--color-gray-200)}.card{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);overflow:hidden}.card-elevated{border-radius:var(--radius-lg);background-color:var(--color-white);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));overflow:hidden}@media (hover:hover){.card-elevated:hover{--tw-shadow:0 3px 6px var(--tw-shadow-color,#00000029),0 3px 6px var(--tw-shadow-color,#0000003b);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.input{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-gray-300);width:100%;padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*3);color:var(--color-gray-900)}.input::placeholder{color:var(--color-gray-500)}.input{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-primary-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.input-error{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-error-500);width:100%;padding-inline:calc(var(--spacing)*4);padding-block:calc(var(--spacing)*3);color:var(--color-gray-900)}.input-error::placeholder{color:var(--color-gray-500)}.input-error{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.input-error:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);--tw-ring-color:var(--color-error-500);--tw-outline-style:none;border-color:#0000;outline-style:none}.label{margin-bottom:calc(var(--spacing)*1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);display:block}.form-group{margin-bottom:calc(var(--spacing)*4)}.badge-success{background-color:var(--color-success-50);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-success-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-warning{background-color:var(--color-warning-50);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-warning-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-error{background-color:var(--color-error-50);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-error-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-info{background-color:var(--color-primary-50);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-primary-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge-neutral{background-color:var(--color-gray-100);padding-inline:calc(var(--spacing)*2.5);padding-block:calc(var(--spacing)*.5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700);border-radius:3.40282e38px;align-items:center;display:inline-flex}.table{min-width:100%}:where(.table>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)));border-color:var(--color-gray-200)}.table-header{background-color:var(--color-gray-50)}.table-header th{padding-inline:calc(var(--spacing)*6);padding-block:calc(var(--spacing)*3);text-align:left;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider);color:var(--color-gray-500);text-transform:uppercase}:where(.table-body>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)));border-color:var(--color-gray-200)}.table-body{background-color:var(--color-white)}.table-body td{padding-inline:calc(var(--spacing)*6);padding-block:calc(var(--spacing)*4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));white-space:nowrap}.table-row-hover:hover{background-color:var(--color-gray-50)}.app-bar{background-color:var(--color-white);padding-inline:calc(var(--spacing)*6);padding-block:calc(var(--spacing)*4);--tw-shadow:0 1px 3px var(--tw-shadow-color,#0000001f),0 1px 2px var(--tw-shadow-color,#0000003d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.copy-field{align-items:center;gap:calc(var(--spacing)*2);border-radius:var(--radius-md);background-color:var(--color-gray-100);padding:calc(var(--spacing)*2);font-family:var(--font-mono);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));display:flex}.copy-field-value{white-space:nowrap;flex:1;overflow-x:auto}.copy-btn{padding:calc(var(--spacing)*2);color:var(--color-gray-500);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));border-radius:3.40282e38px;flex-shrink:0}@media (hover:hover){.copy-btn:hover{background-color:var(--color-gray-200);color:var(--color-gray-700)}}.copy-btn:active{background-color:var(--color-gray-300)}.alert-error{margin-bottom:calc(var(--spacing)*4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in oklab,var(--color-error-500)20%,transparent)}}.alert-error{background-color:var(--color-error-50);padding:calc(var(--spacing)*4);color:var(--color-error-700)}.alert-success{margin-bottom:calc(var(--spacing)*4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#4caf5033}@supports (color:color-mix(in lab, red, red)){.alert-success{border-color:color-mix(in oklab,var(--color-success-500)20%,transparent)}}.alert-success{background-color:var(--color-success-50);padding:calc(var(--spacing)*4);color:var(--color-success-700)}.alert-warning{margin-bottom:calc(var(--spacing)*4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#ff980033}@supports (color:color-mix(in lab, red, red)){.alert-warning{border-color:color-mix(in oklab,var(--color-warning-500)20%,transparent)}}.alert-warning{background-color:var(--color-warning-50);padding:calc(var(--spacing)*4);color:var(--color-warning-700)}.alert-info{margin-bottom:calc(var(--spacing)*4);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:#2196f333}@supports (color:color-mix(in lab, red, red)){.alert-info{border-color:color-mix(in oklab,var(--color-primary-500)20%,transparent)}}.alert-info{background-color:var(--color-primary-50);padding:calc(var(--spacing)*4);color:var(--color-primary-700)}.section-header{margin-bottom:calc(var(--spacing)*4);border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-gray-200);padding-bottom:calc(var(--spacing)*4);justify-content:space-between;align-items:center;display:flex}.section-title{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-900)}.empty-state{padding-block:calc(var(--spacing)*12);text-align:center}.empty-state-icon{height:calc(var(--spacing)*12);width:calc(var(--spacing)*12);color:var(--color-gray-400);margin-inline:auto}.empty-state-title{margin-top:calc(var(--spacing)*2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-900)}.empty-state-description{margin-top:calc(var(--spacing)*1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-gray-500)}}@layer utilities{.\@container\/card-header{container:card-header/inline-size}.\@container{container-type:inline-size}.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.right-2{right:calc(var(--spacing)*2)}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-10{z-index:10}.z-50{z-index:50}.order-0,.order-none{order:0}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.float-end{float:inline-end}.float-left{float:left}.float-none{float:none}.float-right{float:right}.float-start{float:inline-start}.clear-both{clear:both}.clear-end{clear:inline-end}.clear-left{clear:left}.clear-none{clear:none}.clear-right{clear:right}.clear-start{clear:inline-start}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:calc(var(--spacing)*0)}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.my-4{margin-block:calc(var(--spacing)*4)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.field-sizing-content{field-sizing:content}.field-sizing-fixed{field-sizing:fixed}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-8{width:calc(var(--spacing)*8);height:calc(var(--spacing)*8)}.size-9{width:calc(var(--spacing)*9);height:calc(var(--spacing)*9)}.size-10{width:calc(var(--spacing)*10);height:calc(var(--spacing)*10)}.size-auto{width:auto;height:auto}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-8{height:calc(var(--spacing)*8)}.h-9{height:calc(var(--spacing)*9)}.h-10{height:calc(var(--spacing)*10)}.h-24{height:calc(var(--spacing)*24)}.h-36{height:calc(var(--spacing)*36)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-auto{height:auto}.h-lh{height:1lh}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-lh{max-height:1lh}.max-h-none{max-height:none}.max-h-screen{max-height:100vh}.min-h-16{min-height:calc(var(--spacing)*16)}.min-h-\[140px\]{min-height:140px}.min-h-auto{min-height:auto}.min-h-lh{min-height:1lh}.min-h-screen{min-height:100vh}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-\[100px\]{width:100px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-screen{width:100vw}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-screen{max-width:100vw}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[0px\]{min-width:0}.min-w-\[8rem\]{min-width:8rem}.min-w-\[320px\]{min-width:320px}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.min-w-auto{min-width:auto}.min-w-screen{min-width:100vw}.flex-1{flex:1}.flex-auto{flex:auto}.flex-initial{flex:0 auto}.flex-none{flex:none}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.basis-auto{flex-basis:auto}.basis-full{flex-basis:100%}.table-auto{table-layout:auto}.table-fixed{table-layout:fixed}.caption-bottom{caption-side:bottom}.caption-top{caption-side:top}.border-collapse{border-collapse:collapse}.border-separate{border-collapse:separate}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-full{--tw-translate-x:-100%;--tw-translate-y:-100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-full{--tw-translate-x:100%;--tw-translate-y:100%;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-3d{translate:var(--tw-translate-x)var(--tw-translate-y)var(--tw-translate-z)}.translate-none{translate:none}.scale-120{--tw-scale-x:120%;--tw-scale-y:120%;--tw-scale-z:120%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-3d{scale:var(--tw-scale-x)var(--tw-scale-y)var(--tw-scale-z)}.scale-none{scale:none}.rotate-none{rotate:none}.transform,.transform-cpu{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.transform-gpu{transform:translateZ(0)var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.transform-none{transform:none}.\[animation\:spin_20s_linear_infinite\],.animate-\[spin_20s_linear_infinite\]{animation:20s linear infinite spin}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,)var(--tw-pan-y,)var(--tw-pinch-zoom,)}.resize{resize:both}.resize-none{resize:none}.resize-x{resize:horizontal}.resize-y{resize:vertical}.snap-none{scroll-snap-type:none}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-proximity{--tw-scroll-snap-strictness:proximity}.snap-align-none{scroll-snap-align:none}.snap-center{scroll-snap-align:center}.snap-end{scroll-snap-align:end}.snap-start{scroll-snap-align:start}.snap-always{scroll-snap-stop:always}.snap-normal{scroll-snap-stop:normal}.scroll-my-1{scroll-margin-block:calc(var(--spacing)*1)}.list-inside{list-style-position:inside}.list-outside{list-style-position:outside}.appearance-auto{appearance:auto}.appearance-none{appearance:none}.grid-flow-col{grid-auto-flow:column}.grid-flow-col-dense{grid-auto-flow:column dense}.grid-flow-dense{grid-auto-flow:dense}.grid-flow-row{grid-auto-flow:row}.grid-flow-row-dense{grid-auto-flow:dense}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.place-content-around{place-content:space-around}.place-content-baseline{place-content:baseline start}.place-content-between{place-content:space-between}.place-content-center{place-content:center}.place-content-center-safe{place-content:safe center}.place-content-end{place-content:end}.place-content-end-safe{place-content:safe end}.place-content-evenly{place-content:space-evenly}.place-content-start{place-content:start}.place-content-stretch{place-content:stretch}.place-items-baseline{place-items:baseline}.place-items-center{place-items:center}.place-items-center-safe{place-items:safe center}.place-items-end{place-items:end}.place-items-end-safe{place-items:safe end}.place-items-start{place-items:start}.place-items-stretch{place-items:stretch stretch}.content-around{align-content:space-around}.content-baseline{align-content:baseline}.content-between{align-content:space-between}.content-center{align-content:center}.content-center-safe{align-content:safe center}.content-end{align-content:flex-end}.content-end-safe{align-content:safe flex-end}.content-evenly{align-content:space-evenly}.content-normal{align-content:normal}.content-start{align-content:flex-start}.content-stretch{align-content:stretch}.items-baseline{align-items:baseline}.items-baseline-last{align-items:last baseline}.items-center{align-items:center}.items-center-safe{align-items:safe center}.items-end{align-items:flex-end}.items-end-safe{align-items:safe flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-around{justify-content:space-around}.justify-baseline{justify-content:baseline}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-center-safe{justify-content:safe center}.justify-end{justify-content:flex-end}.justify-end-safe{justify-content:safe flex-end}.justify-evenly{justify-content:space-evenly}.justify-normal{justify-content:normal}.justify-start{justify-content:flex-start}.justify-stretch{justify-content:stretch}.justify-items-center{justify-items:center}.justify-items-center-safe{justify-items:safe center}.justify-items-end{justify-items:end}.justify-items-end-safe{justify-items:safe end}.justify-items-normal{justify-items:normal}.justify-items-start{justify-items:start}.justify-items-stretch{justify-items:stretch}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px*var(--tw-divide-x-reverse));border-inline-end-width:calc(1px*calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}.place-self-auto{place-self:auto}.place-self-center{place-self:center}.place-self-center-safe{place-self:safe center}.place-self-end{place-self:end}.place-self-end-safe{place-self:safe end}.place-self-start{place-self:start}.place-self-stretch{place-self:stretch stretch}.self-auto{align-self:auto}.self-baseline{align-self:baseline}.self-baseline-last{align-self:last baseline}.self-center{align-self:center}.self-center-safe{align-self:safe center}.self-end{align-self:flex-end}.self-end-safe{align-self:safe flex-end}.self-start{align-self:flex-start}.self-stretch{align-self:stretch}.justify-self-auto{justify-self:auto}.justify-self-center{justify-self:center}.justify-self-center-safe{justify-self:safe center}.justify-self-end{justify-self:flex-end}.justify-self-end-safe{justify-self:safe flex-end}.justify-self-start{justify-self:flex-start}.justify-self-stretch{justify-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.scroll-auto{scroll-behavior:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-dotted{--tw-border-style:dotted;border-style:dotted}.border-double{--tw-border-style:double;border-style:double}.border-hidden{--tw-border-style:hidden;border-style:hidden}.border-none{--tw-border-style:none;border-style:none}.border-solid{--tw-border-style:solid;border-style:solid}.border-\[\#fbf0df\]{border-color:#fbf0df}.border-error-500\/20{border-color:#f4433633}@supports (color:color-mix(in lab, red, red)){.border-error-500\/20{border-color:color-mix(in oklab,var(--color-error-500)20%,transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.bg-\[\#1a1a1a\]{background-color:#1a1a1a}.bg-\[\#242424\]{background-color:#242424}.bg-\[\#fbf0df\]{background-color:#fbf0df}.bg-error-50\/50{background-color:#ffebee80}@supports (color:color-mix(in lab, red, red)){.bg-error-50\/50{background-color:color-mix(in oklab,var(--color-error-50)50%,transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-transparent{background-color:#0000}.-bg-conic,.bg-conic{--tw-gradient-position:in oklab;background-image:conic-gradient(var(--tw-gradient-stops))}.bg-radial{--tw-gradient-position:in oklab;background-image:radial-gradient(var(--tw-gradient-stops))}.bg-none{background-image:none}.via-none{--tw-gradient-via-stops:initial}.mask-none{-webkit-mask-image:none;mask-image:none}.mask-circle{--tw-mask-radial-shape:circle}.mask-ellipse{--tw-mask-radial-shape:ellipse}.mask-radial-closest-corner{--tw-mask-radial-size:closest-corner}.mask-radial-closest-side{--tw-mask-radial-size:closest-side}.mask-radial-farthest-corner{--tw-mask-radial-size:farthest-corner}.mask-radial-farthest-side{--tw-mask-radial-size:farthest-side}.mask-radial-at-bottom{--tw-mask-radial-position:bottom}.mask-radial-at-bottom-left{--tw-mask-radial-position:bottom left}.mask-radial-at-bottom-right{--tw-mask-radial-position:bottom right}.mask-radial-at-center{--tw-mask-radial-position:center}.mask-radial-at-left{--tw-mask-radial-position:left}.mask-radial-at-right{--tw-mask-radial-position:right}.mask-radial-at-top{--tw-mask-radial-position:top}.mask-radial-at-top-left{--tw-mask-radial-position:top left}.mask-radial-at-top-right{--tw-mask-radial-position:top right}.box-decoration-clone{-webkit-box-decoration-break:clone;box-decoration-break:clone}.box-decoration-slice{-webkit-box-decoration-break:slice;box-decoration-break:slice}.decoration-clone{-webkit-box-decoration-break:clone;box-decoration-break:clone}.decoration-slice{-webkit-box-decoration-break:slice;box-decoration-break:slice}.bg-auto{background-size:auto}.bg-contain{background-size:contain}.bg-cover{background-size:cover}.bg-fixed{background-attachment:fixed}.bg-local{background-attachment:local}.bg-scroll{background-attachment:scroll}.bg-clip-border{background-clip:border-box}.bg-clip-content{background-clip:content-box}.bg-clip-padding{background-clip:padding-box}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.bg-bottom{background-position:bottom}.bg-bottom-left{background-position:0 100%}.bg-bottom-right{background-position:100% 100%}.bg-center{background-position:50%}.bg-left{background-position:0}.bg-left-bottom{background-position:0 100%}.bg-left-top{background-position:0 0}.bg-right{background-position:100%}.bg-right-bottom{background-position:100% 100%}.bg-right-top{background-position:100% 0}.bg-top{background-position:top}.bg-top-left{background-position:0 0}.bg-top-right{background-position:100% 0}.bg-no-repeat{background-repeat:no-repeat}.bg-repeat{background-repeat:repeat}.bg-repeat-round{background-repeat:round}.bg-repeat-space{background-repeat:space}.bg-repeat-x{background-repeat:repeat-x}.bg-repeat-y{background-repeat:repeat-y}.bg-origin-border{background-origin:border-box}.bg-origin-content{background-origin:content-box}.bg-origin-padding{background-origin:padding-box}.mask-add{-webkit-mask-composite:source-over;-webkit-mask-composite:source-over;mask-composite:add}.mask-exclude{-webkit-mask-composite:xor;-webkit-mask-composite:xor;mask-composite:exclude}.mask-intersect{-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}.mask-subtract{-webkit-mask-composite:source-out;-webkit-mask-composite:source-out;mask-composite:subtract}.mask-alpha{-webkit-mask-source-type:alpha;-webkit-mask-source-type:alpha;mask-mode:alpha}.mask-luminance{-webkit-mask-source-type:luminance;-webkit-mask-source-type:luminance;mask-mode:luminance}.mask-match{-webkit-mask-source-type:auto;-webkit-mask-source-type:auto;mask-mode:match-source}.mask-type-alpha{mask-type:alpha}.mask-type-luminance{mask-type:luminance}.mask-auto{-webkit-mask-size:auto;mask-size:auto}.mask-contain{-webkit-mask-size:contain;mask-size:contain}.mask-cover{-webkit-mask-size:cover;mask-size:cover}.mask-clip-border{-webkit-mask-clip:border-box;mask-clip:border-box}.mask-clip-content{-webkit-mask-clip:content-box;mask-clip:content-box}.mask-clip-fill{-webkit-mask-clip:fill-box;mask-clip:fill-box}.mask-clip-padding{-webkit-mask-clip:padding-box;mask-clip:padding-box}.mask-clip-stroke{-webkit-mask-clip:stroke-box;mask-clip:stroke-box}.mask-clip-view{-webkit-mask-clip:view-box;mask-clip:view-box}.mask-no-clip{-webkit-mask-clip:no-clip;mask-clip:no-clip}.mask-bottom{-webkit-mask-position:bottom;mask-position:bottom}.mask-bottom-left{-webkit-mask-position:0 100%;mask-position:0 100%}.mask-bottom-right{-webkit-mask-position:100% 100%;mask-position:100% 100%}.mask-center{-webkit-mask-position:50%;mask-position:50%}.mask-left{-webkit-mask-position:0;mask-position:0}.mask-right{-webkit-mask-position:100%;mask-position:100%}.mask-top{-webkit-mask-position:top;mask-position:top}.mask-top-left{-webkit-mask-position:0 0;mask-position:0 0}.mask-top-right{-webkit-mask-position:100% 0;mask-position:100% 0}.mask-no-repeat{-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.mask-repeat-round{-webkit-mask-repeat:round;mask-repeat:round}.mask-repeat-space{-webkit-mask-repeat:space;mask-repeat:space}.mask-repeat-x{-webkit-mask-repeat:repeat-x;mask-repeat:repeat-x}.mask-repeat-y{-webkit-mask-repeat:repeat-y;mask-repeat:repeat-y}.mask-origin-border{-webkit-mask-origin:border-box;mask-origin:border-box}.mask-origin-content{-webkit-mask-origin:content-box;mask-origin:content-box}.mask-origin-fill{-webkit-mask-origin:fill-box;mask-origin:fill-box}.mask-origin-padding{-webkit-mask-origin:padding-box;mask-origin:padding-box}.mask-origin-stroke{-webkit-mask-origin:stroke-box;mask-origin:stroke-box}.mask-origin-view{-webkit-mask-origin:view-box;mask-origin:view-box}.fill-none{fill:none}.stroke-none{stroke:none}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-fill{object-fit:fill}.object-none{object-fit:none}.object-scale-down{object-fit:scale-down}.object-left-bottom{object-position:left bottom}.object-left-top{object-position:left top}.object-right-bottom{object-position:right bottom}.object-right-top{object-position:right top}.p-1{padding:calc(var(--spacing)*1)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-\[0\.3rem\]{padding-inline:.3rem}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.py-12{padding-block:calc(var(--spacing)*12)}.py-\[0\.2rem\]{padding-block:.2rem}.pt-4{padding-top:calc(var(--spacing)*4)}.pr-8{padding-right:calc(var(--spacing)*8)}.pl-2{padding-left:calc(var(--spacing)*2)}.text-center{text-align:center}.text-end{text-align:end}.text-justify{text-align:justify}.text-left{text-align:left}.text-right{text-align:right}.text-start{text-align:start}.align-baseline{vertical-align:baseline}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.align-sub{vertical-align:sub}.align-super{vertical-align:super}.align-text-bottom{vertical-align:text-bottom}.align-text-top{vertical-align:text-top}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-balance{text-wrap:balance}.text-nowrap{text-wrap:nowrap}.text-pretty{text-wrap:pretty}.text-wrap{text-wrap:wrap}.break-normal{overflow-wrap:normal;word-break:normal}.break-words{overflow-wrap:break-word}.wrap-anywhere{overflow-wrap:anywhere}.wrap-break-word{overflow-wrap:break-word}.wrap-normal{overflow-wrap:normal}.break-all{word-break:break-all}.break-keep{word-break:keep-all}.overflow-ellipsis{text-overflow:ellipsis}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.hyphens-auto{-webkit-hyphens:auto;hyphens:auto}.hyphens-manual{-webkit-hyphens:manual;hyphens:manual}.hyphens-none{-webkit-hyphens:none;hyphens:none}.whitespace-break-spaces{white-space:break-spaces}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#1a1a1a\]{color:#1a1a1a}.text-\[\#fbf0df\]{color:#fbf0df}.text-\[rgba\(255\,255\,255\,0\.87\)\]{color:#ffffffde}.text-error-500{color:var(--color-error-500)}.text-error-700{color:var(--color-error-700)}.text-gray-100{color:var(--color-gray-100)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-primary-600{color:var(--color-primary-600)}.text-success-500{color:var(--color-success-500)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.font-stretch-condensed{font-stretch:75%}.font-stretch-expanded{font-stretch:125%}.font-stretch-extra-condensed{font-stretch:62.5%}.font-stretch-extra-expanded{font-stretch:150%}.font-stretch-normal{font-stretch:100%}.font-stretch-semi-condensed{font-stretch:87.5%}.font-stretch-semi-expanded{font-stretch:112.5%}.font-stretch-ultra-condensed{font-stretch:50%}.font-stretch-ultra-expanded{font-stretch:200%}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.decoration-dashed{text-decoration-style:dashed}.decoration-dotted{text-decoration-style:dotted}.decoration-double{text-decoration-style:double}.decoration-solid{text-decoration-style:solid}.decoration-wavy{text-decoration-style:wavy}.decoration-auto{text-decoration-thickness:auto}.decoration-from-font{text-decoration-thickness:from-font}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.placeholder-\[\#fbf0df\]\/40::placeholder{color:oklab(95.9232% .00488412 .0249393/.4)}.accent-auto{accent-color:auto}.scheme-dark{color-scheme:dark}.scheme-light{color-scheme:light}.scheme-light-dark{color-scheme:light dark}.scheme-normal{color-scheme:normal}.scheme-only-dark{color-scheme:dark only}.scheme-only-light{color-scheme:light only}.opacity-50{opacity:.5}.mix-blend-plus-darker{mix-blend-mode:plus-darker}.mix-blend-plus-lighter{mix-blend-mode:plus-lighter}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.inset-ring{--tw-inset-ring-shadow:inset 0 0 0 1px var(--tw-inset-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-initial{--tw-shadow-color:initial}.inset-shadow-initial{--tw-inset-shadow-color:initial}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a))drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a)drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow-none{--tw-drop-shadow: ;filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-discrete{transition-behavior:allow-discrete}.transition-normal{transition-behavior:normal}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.will-change-auto{will-change:auto}.will-change-contents{will-change:contents}.will-change-scroll{will-change:scroll-position}.will-change-transform{will-change:transform}.contain-inline-size{--tw-contain-size:inline-size;contain:var(--tw-contain-size,)var(--tw-contain-layout,)var(--tw-contain-paint,)var(--tw-contain-style,)}.contain-layout{--tw-contain-layout:layout;contain:var(--tw-contain-size,)var(--tw-contain-layout,)var(--tw-contain-paint,)var(--tw-contain-style,)}.contain-paint{--tw-contain-paint:paint;contain:var(--tw-contain-size,)var(--tw-contain-layout,)var(--tw-contain-paint,)var(--tw-contain-style,)}.contain-size{--tw-contain-size:size;contain:var(--tw-contain-size,)var(--tw-contain-layout,)var(--tw-contain-paint,)var(--tw-contain-style,)}.contain-style{--tw-contain-style:style;contain:var(--tw-contain-size,)var(--tw-contain-layout,)var(--tw-contain-paint,)var(--tw-contain-style,)}.contain-content{contain:content}.contain-none{contain:none}.contain-strict{contain:strict}.content-none{--tw-content:none;content:none}.forced-color-adjust-auto{forced-color-adjust:auto}.forced-color-adjust-none{forced-color-adjust:none}.outline-dashed{--tw-outline-style:dashed;outline-style:dashed}.outline-dotted{--tw-outline-style:dotted;outline-style:dotted}.outline-double{--tw-outline-style:double;outline-style:double}.outline-none{--tw-outline-style:none;outline-style:none}.outline-solid{--tw-outline-style:solid;outline-style:solid}.select-none{-webkit-user-select:none;user-select:none}.backface-hidden{backface-visibility:hidden}.backface-visible{backface-visibility:visible}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.duration-initial{--tw-duration:initial}.ring-inset{--tw-ring-inset:inset}.text-shadow-initial{--tw-text-shadow-color:initial}.transform-3d{transform-style:preserve-3d}.transform-border{transform-box:border-box}.transform-content{transform-box:content-box}.transform-fill{transform-box:fill-box}.transform-flat{transform-style:flat}.transform-stroke{transform-box:stroke-box}.transform-view{transform-box:view-box}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing)*7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.focus-within\:border-\[\#f3d5a3\]:focus-within{border-color:#f3d5a3}@media (hover:hover){.hover\:-translate-y-px:hover{--tw-translate-y:-1px;translate:var(--tw-translate-x)var(--tw-translate-y)}.hover\:bg-\[\#f3d5a3\]:hover{background-color:#f3d5a3}.hover\:text-error-700:hover{color:var(--color-error-700)}.hover\:text-primary-600:hover{color:var(--color-primary-600)}.hover\:text-primary-800:hover{color:var(--color-primary-800)}.hover\:underline:hover{text-decoration-line:underline}.hover\:drop-shadow-\[0_0_2em_\#61dafbaa\]:hover{--tw-drop-shadow-size:drop-shadow(0 0 2em var(--tw-drop-shadow-color,#61dafbaa));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.hover\:drop-shadow-\[0_0_2em_\#646cffaa\]:hover{--tw-drop-shadow-size:drop-shadow(0 0 2em var(--tw-drop-shadow-color,#646cffaa));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}}.focus\:border-\[\#f3d5a3\]:focus{border-color:#f3d5a3}.focus\:text-white:focus{color:var(--color-white)}.focus\:ring-primary-500:focus{--tw-ring-color:var(--color-primary-500)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing)*2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing)*3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing)*4)}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing)*9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing)*8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing)*2)}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}}@media (min-width:48rem){.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing)*6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing)*6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing)*2)}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-scroll-snap-strictness{syntax:"*";inherits:false;initial-value:proximity}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-contain-size{syntax:"*";inherits:false}@property --tw-contain-layout{syntax:"*";inherits:false}@property --tw-contain-paint{syntax:"*";inherits:false}@property --tw-contain-style{syntax:"*";inherits:false}@property --tw-text-shadow-color{syntax:"*";inherits:false}@property --tw-text-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-tracking{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..e347369 --- /dev/null +++ b/static/js/app.js @@ -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 + }; + +})(); diff --git a/static/static.go b/static/static.go new file mode 100644 index 0000000..9ee8a46 --- /dev/null +++ b/static/static.go @@ -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 diff --git a/templates/app_detail.html b/templates/app_detail.html new file mode 100644 index 0000000..9cc6742 --- /dev/null +++ b/templates/app_detail.html @@ -0,0 +1,265 @@ +{{template "base" .}} + +{{define "title"}}{{.App.Name}} - upaas{{end}} + +{{define "content"}} +{{template "nav" .}} + +
+ + + {{template "alert-success" .}} + {{template "alert-error" .}} + + +
+
+
+

{{.App.Name}}

+ {{if eq .App.Status "running"}} + Running + {{else if eq .App.Status "building"}} + Building + {{else if eq .App.Status "error"}} + Error + {{else if eq .App.Status "stopped"}} + Stopped + {{else}} + {{.App.Status}} + {{end}} +
+

{{.App.RepoURL}} @ {{.App.Branch}}

+
+
+ Edit +
+ +
+
+
+ + +
+

Deploy Key

+

Add this SSH public key to your repository as a read-only deploy key:

+
+ {{.App.SSHPublicKey}} + +
+
+ + +
+

Webhook URL

+

Add this URL as a push webhook in your Gitea repository:

+
+ {{.WebhookURL}} + +
+
+ + +
+

Environment Variables

+ {{if .EnvVars}} +
+ + + + + + + + + + {{range .EnvVars}} + + + + + + {{end}} + +
KeyValueActions
{{.Key}}{{.Value}} +
+ +
+
+
+ {{end}} +
+ + + +
+
+ + +
+

Docker Labels

+ {{if .Labels}} +
+ + + + + + + + + + {{range .Labels}} + + + + + + {{end}} + +
KeyValueActions
{{.Key}}{{.Value}} +
+ +
+
+
+ {{end}} +
+ + + +
+
+ + +
+

Volume Mounts

+ {{if .Volumes}} +
+ + + + + + + + + + + {{range .Volumes}} + + + + + + + {{end}} + +
Host PathContainer PathModeActions
{{.HostPath}}{{.ContainerPath}} + {{if .ReadOnly}} + Read-only + {{else}} + Read-write + {{end}} + +
+ +
+
+
+ {{end}} +
+
+ +
+
+ +
+ + +
+
+ + +
+
+

Recent Deployments

+ View All +
+ {{if .Deployments}} +
+ + + + + + + + + + {{range .Deployments}} + + + + + + {{end}} + +
StartedStatusCommit
{{.StartedAt.Format "2006-01-02 15:04:05"}} + {{if eq .Status "success"}} + Success + {{else if eq .Status "failed"}} + Failed + {{else if eq .Status "building"}} + Building + {{else if eq .Status "deploying"}} + Deploying + {{else}} + {{.Status}} + {{end}} + + {{if .CommitSHA.Valid}}{{slice .CommitSHA.String 0 12}}{{else}}-{{end}} +
+
+ {{else}} +

No deployments yet.

+ {{end}} +
+ + +
+

Danger Zone

+

Deleting this app will remove all configuration and deployment history. This action cannot be undone.

+
+ +
+
+
+{{end}} diff --git a/templates/app_edit.html b/templates/app_edit.html new file mode 100644 index 0000000..ae99668 --- /dev/null +++ b/templates/app_edit.html @@ -0,0 +1,123 @@ +{{template "base" .}} + +{{define "title"}}Edit {{.App.Name}} - upaas{{end}} + +{{define "content"}} +{{template "nav" .}} + +
+ + +

Edit Application

+ +
+ {{template "alert-error" .}} + +
+
+ + +

Lowercase letters, numbers, and hyphens only

+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +

Optional Settings

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Cancel + +
+
+
+
+{{end}} diff --git a/templates/app_new.html b/templates/app_new.html new file mode 100644 index 0000000..77c2bfc --- /dev/null +++ b/templates/app_new.html @@ -0,0 +1,126 @@ +{{template "base" .}} + +{{define "title"}}New App - upaas{{end}} + +{{define "content"}} +{{template "nav" .}} + +
+ + +

Create New Application

+ +
+ {{template "alert-error" .}} + +
+
+ + +

Lowercase letters, numbers, and hyphens only

+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +

Optional Settings

+ +
+ + +

Leave empty to use default bridge network

+
+ +
+ + +
+ +
+ + +
+ +
+ Cancel + +
+
+
+
+{{end}} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..53fc568 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,60 @@ +{{define "base"}} + + + + + + + {{block "title" .}}upaas{{end}} + + + + {{block "content" .}}{{end}} + + + +{{end}} + +{{define "nav"}} + +{{end}} + +{{define "alert-error"}} +{{if .Error}} +
+
+ + + + {{.Error}} +
+
+{{end}} +{{end}} + +{{define "alert-success"}} +{{if .Success}} +
+
+ + + + {{.Success}} +
+
+{{end}} +{{end}} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..74bb19f --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,91 @@ +{{template "base" .}} + +{{define "title"}}Dashboard - upaas{{end}} + +{{define "content"}} +{{template "nav" .}} + +
+ {{template "alert-success" .}} + {{template "alert-error" .}} + +
+

Applications

+ + + + + New App + +
+ + {{if .Apps}} +
+ + + + + + + + + + + + {{range .Apps}} + + + + + + + + {{end}} + +
NameRepositoryBranchStatusActions
+ + {{.Name}} + + {{.RepoURL}}{{.Branch}} + {{if eq .Status "running"}} + Running + {{else if eq .Status "building"}} + Building + {{else if eq .Status "error"}} + Error + {{else if eq .Status "stopped"}} + Stopped + {{else}} + {{.Status}} + {{end}} + +
+ View + Edit +
+ +
+
+
+
+ {{else}} +
+
+ + + +

No applications yet

+

Get started by creating your first application.

+ +
+
+ {{end}} +
+{{end}} diff --git a/templates/deployments.html b/templates/deployments.html new file mode 100644 index 0000000..f91c57e --- /dev/null +++ b/templates/deployments.html @@ -0,0 +1,109 @@ +{{template "base" .}} + +{{define "title"}}Deployments - {{.App.Name}} - upaas{{end}} + +{{define "content"}} +{{template "nav" .}} + +
+ + +
+

Deployment History

+
+ +
+
+ + {{if .Deployments}} +
+ {{range .Deployments}} +
+
+
+ + + + {{.StartedAt.Format "2006-01-02 15:04:05"}} + {{if .FinishedAt.Valid}} + - + {{.FinishedAt.Time.Format "15:04:05"}} + {{end}} +
+
+ {{if eq .Status "success"}} + Success + {{else if eq .Status "failed"}} + Failed + {{else if eq .Status "building"}} + Building + {{else if eq .Status "deploying"}} + Deploying + {{else}} + {{.Status}} + {{end}} +
+
+ +
+ {{if .CommitSHA.Valid}} +
+ Commit: + {{.CommitSHA.String}} +
+ {{end}} + + {{if .ImageID.Valid}} +
+ Image: + {{slice .ImageID.String 0 24}}... +
+ {{end}} + + {{if .ContainerID.Valid}} +
+ Container: + {{slice .ContainerID.String 0 12}} +
+ {{end}} +
+ + {{if .Logs.Valid}} +
+ + + + + View Logs + +
{{.Logs.String}}
+
+ {{end}} +
+ {{end}} +
+ {{else}} +
+
+ + + +

No deployments yet

+

Deploy your application to see the deployment history here.

+
+
+ +
+
+
+
+ {{end}} +
+{{end}} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..d38d81f --- /dev/null +++ b/templates/login.html @@ -0,0 +1,50 @@ +{{template "base" .}} + +{{define "title"}}Login - upaas{{end}} + +{{define "content"}} +
+
+
+

upaas

+

Sign in to continue

+
+ +
+ {{template "alert-error" .}} + +
+
+ + +
+ +
+ + +
+ + +
+
+
+
+{{end}} diff --git a/templates/setup.html b/templates/setup.html new file mode 100644 index 0000000..ba8fde7 --- /dev/null +++ b/templates/setup.html @@ -0,0 +1,69 @@ +{{template "base" .}} + +{{define "title"}}Setup - upaas{{end}} + +{{define "content"}} +
+
+
+

Welcome to upaas

+

Create your admin account to get started

+
+ +
+ {{template "alert-error" .}} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +

+ This is a single-user system. This account will be the only admin. +

+
+
+{{end}} diff --git a/templates/templates.go b/templates/templates.go new file mode 100644 index 0000000..5f733d1 --- /dev/null +++ b/templates/templates.go @@ -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 +}