Initial commit with server startup infrastructure

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

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

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

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

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

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

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