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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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