Compare commits

..

4 Commits

Author SHA1 Message Date
bee933ce8e TODO (#173)
All checks were successful
Check / check (push) Successful in 4s
Reviewed-on: #173
2026-07-06 21:36:27 +02:00
d5c3d4d55f Move schema_migrations table creation into 000.sql with INTEGER version column (#172)
All checks were successful
Check / check (push) Successful in 5s
Closes #171

Refactors the migration system to follow the [pixa pattern](sneak/pixa#36):

## Changes

### New file: `internal/database/migrations/000.sql`
- Bootstrap migration that creates `schema_migrations` with `version INTEGER PRIMARY KEY`
- Self-contained: includes both `CREATE TABLE IF NOT EXISTS` and `INSERT OR IGNORE INTO schema_migrations (version) VALUES (0)`

### Refactored: `internal/database/migrations.go`
- **Go code no longer creates the migrations table inline** — it reads and executes `000.sql` as a bootstrap step before the normal migration loop
- **`ParseMigrationVersion(filename)`** — exported function that extracts integer version from filenames like `001_initial.sql` → `1`
- **`ApplyMigrations(ctx, db, log)`** — exported function for tests to apply schema without full fx lifecycle
- **`bootstrapMigrationsTable`** — checks if table exists; if missing, runs `000.sql`; if present, checks for legacy format
- **`convertLegacyMigrations`** — one-time conversion for existing databases that stored versions as TEXT filenames (e.g. `"001_initial.sql"`) to INTEGER versions (e.g. `1`)
- **Transaction-wrapped migration application** — each migration runs in a transaction for atomicity
- Sentinel error `ErrInvalidMigrationFilename` for static error wrapping

### New file: `internal/database/migrations_test.go`
- `TestParseMigrationVersion` — valid/invalid filename parsing
- `TestApplyMigrationsFreshDatabase` — verifies bootstrap creates table, all migrations apply, application tables exist
- `TestApplyMigrationsIdempotent` — double-apply is a no-op
- `TestApplyMigrationsLegacyConversion` — simulates old TEXT-based table with filename entries, verifies conversion to integer format

## Backward Compatibility

Existing databases with the old TEXT-based `schema_migrations` table are automatically converted on first run. The conversion:
1. Detects legacy entries (versions containing `_`)
2. Parses integer version from each legacy filename
3. Drops the old table
4. Creates the new INTEGER-based table via `000.sql`
5. Re-inserts all parsed integer versions

All existing migrations (001-007) continue to work unchanged.

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@sneak.berlin>
Reviewed-on: #172
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-30 21:40:16 +02:00
57ec4331ef feat: add GitHub and GitLab webhook support (#170)
All checks were successful
Check / check (push) Successful in 3m17s
## Summary

Adds GitHub and GitLab push webhook support alongside the existing Gitea support.

closes #68

## What Changed

### Auto-detection of webhook source

The webhook handler now auto-detects which platform sent the webhook by examining HTTP headers:
- **Gitea**: `X-Gitea-Event`
- **GitHub**: `X-GitHub-Event`
- **GitLab**: `X-Gitlab-Event`

Existing Gitea webhooks continue to work unchanged. Unknown sources fall back to Gitea format for backward compatibility.

### Normalized push event

All three payload formats are parsed into a unified `PushEvent` struct containing:
- Source platform, ref, branch, commit SHA
- Repository name, clone URL, HTML URL
- Commit URL (with per-platform fallback logic)
- Pusher username/name

### New files

- **`internal/service/webhook/payloads.go`**: Source-specific payload structs (`GiteaPushPayload`, `GitHubPushPayload`, `GitLabPushPayload`), `ParsePushPayload()` dispatcher, per-platform parsers, branch extraction, and commit URL extraction functions.

### Modified files

- **`internal/service/webhook/types.go`**: Added `Source` type (gitea/github/gitlab/unknown), `DetectWebhookSource()`, `DetectEventType()`, and `PushEvent` normalized type. Moved `GiteaPushPayload` to payloads.go.
- **`internal/service/webhook/webhook.go`**: `HandleWebhook` now accepts a `Source` parameter and uses `ParsePushPayload()` for unified parsing instead of directly unmarshaling Gitea payloads.
- **`internal/handlers/webhook.go`**: Calls `DetectWebhookSource()` and `DetectEventType()` to auto-detect the platform before delegating to the webhook service.
- **`internal/service/webhook/webhook_test.go`**: Comprehensive tests for source detection, event type extraction, payload parsing (all 3 platforms), commit URL fallback paths, and integration tests through `HandleWebhook` for GitHub and GitLab sources.
- **`README.md`**: Updated description, features, non-goals, and architecture to reflect multi-platform webhook support.

## Test coverage

Webhook package: **96.9%** statement coverage. Tests cover:
- `DetectWebhookSource` with all header combinations and precedence
- `DetectEventType` for each platform
- `ParsePushPayload` for Gitea, GitHub, GitLab, unknown source, invalid JSON, empty payloads
- Commit URL extraction fallback paths for GitHub and GitLab
- Direct struct deserialization for all three payload types
- Full `HandleWebhook` integration tests with GitHub and GitLab sources

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #170
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-22 00:46:10 +01:00
67361419f5 feat: CPU/memory resource limits per app (#165)
All checks were successful
Check / check (push) Successful in 5s
## Summary

Adds configurable Docker CPU and memory resource constraints per app, closes #72.

## Changes

### Database
- Migration `007_add_resource_limits.sql`: adds `cpu_limit` (REAL, nullable) and `memory_limit` (INTEGER in bytes, nullable) columns to the `apps` table

### Model (`internal/models/app.go`)
- Added `CPULimit` (`sql.NullFloat64`) and `MemoryLimit` (`sql.NullInt64`) fields to `App` struct
- Updated insert, update, scan, and column list to include the new fields

### Docker Client (`internal/docker/client.go`)
- Added `CPULimit` (float64, CPU cores) and `MemoryLimit` (int64, bytes) to `CreateContainerOptions`
- Added `cpuLimitToNanoCPUs()` conversion helper and `buildResources()` to construct `container.Resources`
- Extracted `buildEnvSlice()` and `buildMounts()` helpers from `CreateContainer` for cleaner code
- Resource limits are passed to Docker's `HostConfig.Resources` (NanoCPUs / Memory)

### Deploy Service (`internal/service/deploy/deploy.go`)
- `buildContainerOptions` reads `CPULimit` and `MemoryLimit` from the app and passes them to `CreateContainerOptions`

### Handlers (`internal/handlers/app.go`)
- `HandleAppUpdate` reads and validates `cpu_limit` and `memory_limit` form fields
- Added `parseOptionalFloat64()` for CPU limit parsing (positive float or empty)
- Added `parseOptionalMemoryBytes()` for memory parsing with unit suffixes (k/m/g) or plain bytes
- Added `optionalNullString()` and `applyResourceLimits()` helpers to keep cyclomatic complexity in check

### Templates
- `app_edit.html`: Added "Resource Limits" section with CPU limit (cores) and memory limit (with unit suffix) fields
- `templates.go`: Added `formatMemoryBytes` template function for display (converts bytes → human-readable like `256m`, `1g`)

### Tests
- `internal/docker/resource_limits_test.go`: Tests for `cpuLimitToNanoCPUs` conversion
- `internal/handlers/resource_limits_test.go`: Tests for `parseOptionalFloat64` and `parseOptionalMemoryBytes` (happy paths, edge cases, validation)
- `internal/models/models_test.go`: Tests for App model resource limit persistence (save/load, null defaults, clearing)
- `internal/service/deploy/deploy_container_test.go`: Tests for container options with/without resource limits
- `templates/templates_test.go`: Tests for `formatMemoryBytes` formatting

### README
- Added "CPU and memory resource limits per app" to Features list

## Behavior

- **CPU limit**: Specified in cores (e.g. `0.5` = half a core, `2` = two cores). Converted to Docker NanoCPUs internally.
- **Memory limit**: Accepts plain bytes or suffixed values (`256m`, `1g`, `512k`). Stored as bytes in the database.
- Both fields are **optional** — empty/unset means unlimited (no Docker constraint applied).
- Limits are applied on every container creation: new deploys, rollbacks, and restarts that recreate the container.

closes #72

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #165
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-20 06:44:48 +01:00
31 changed files with 2179 additions and 2038 deletions

View File

@@ -1,17 +1,17 @@
# µPaaS by [@sneak](https://sneak.berlin)
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via Gitea webhooks.
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab.
## Features
- Single admin user with argon2id password hashing
- Per-app SSH keypairs for read-only deploy keys
- Per-app UUID-based webhook URLs for Gitea integration
- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab
- Branch filtering - only deploy on configured branch changes
- Environment variables, labels, and volume mounts per app
- CPU and memory resource limits per app
- Docker builds via socket access
- Notifications via ntfy and Slack-compatible webhooks
- Backup/restore of app configurations (JSON export/import via UI and API)
- Simple server-rendered UI with Tailwind CSS
## Non-Goals
@@ -20,7 +20,7 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor
- Complex CI pipelines
- Multiple container orchestration
- SPA/API-first design
- Support for non-Gitea webhooks
- Support for non-push webhook events (e.g. issues, merge requests)
## Architecture
@@ -45,7 +45,7 @@ upaas/
│ │ ├── auth/ # Authentication service
│ │ ├── deploy/ # Deployment orchestration
│ │ ├── notify/ # Notifications (ntfy, Slack)
│ │ └── webhook/ # Gitea webhook processing
│ │ └── webhook/ # Webhook processing (Gitea, GitHub, GitLab)
│ └── ssh/ # SSH key generation
├── static/ # Embedded CSS/JS assets
└── templates/ # Embedded HTML templates

53
TODO.md Normal file
View File

@@ -0,0 +1,53 @@
# Workflow
* branch (from `main`)
* do the work in Next Step
* move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status
1.0+. Tagged 1.0.0 on 2026-02-26; 8 commits on main since. Policy
violation: main currently fails make check (91 lint issues), so the tree
is out of compliance until fixed.
# Next Step
Fix the 47 noctx lint findings (HTTP requests without context) in one
commit and confirm the count drops under make check. This is the largest
of the three lint classes blocking a green main.
# Completed Steps
- 2026-03-11: Monolithic env var editing with bulk save (#158).
- 2026-03-10: Webhook event history UI page (#164); added missing
Makefile docker and hooks targets plus test timeout (#159);
notification settings passed from create form (#160).
- 2026-03-03: REPO_POLICIES compliance file set added (#155).
- 2026-03-01: Module path changed to sneak.berlin/go/upaas (#143);
Dockerfile split into lint and build stages with forced lint
execution (#152, #154).
- 2026-02-26: 1.0.0 tagged; dashboard CSRFField crash fixed (#146).
- 1.0 audit bug fixes (#120-#125): deferred rollback on commit error,
deployment log size cap, error path rendering, docker-compose bind
mount, domain type refactor.
- CI simplified to docker build only (#130).
- 2025-12-29 onward: core PaaS built out: deploys with real-time build
log streaming, container start/stop/restart and logs, TCP/UDP port
mapping, Alpine.js UI, Slack notifications, ULID app IDs, session
handling.
# Future Steps
- Get main green (compliance, ordered):
- Fix 47 noctx findings (Next Step).
- Fix 23 gosec findings.
- Fix 21 goconst findings.
- Run make check clean on main and keep it green; main must always
pass.
- Confirm .gitea/workflows/check.yml gates merges on make check so main
cannot regress.
- Resume feature work only after main is green.

View File

@@ -2,36 +2,74 @@ package database
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"sort"
"strconv"
"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)
// bootstrapVersion is the migration that creates the schema_migrations
// table itself. It is applied before the normal migration loop.
const bootstrapVersion = 0
// ErrInvalidMigrationFilename indicates a migration filename does not follow
// the expected "<version>.sql" or "<version>_<description>.sql" pattern.
var ErrInvalidMigrationFilename = errors.New("invalid migration filename")
// ParseMigrationVersion extracts the numeric version prefix from a migration
// filename. Filenames must follow the pattern "<version>.sql" or
// "<version>_<description>.sql", where version is a zero-padded numeric
// string (e.g. "001", "002"). Returns the version as an integer and an
// error if the filename does not match the expected pattern.
func ParseMigrationVersion(filename string) (int, error) {
name := strings.TrimSuffix(filename, ".sql")
if name == "" || name == filename {
return 0, fmt.Errorf("%w: %q has no .sql extension or is empty", ErrInvalidMigrationFilename, filename)
}
// Get list of migration files
// Split on underscore to separate version from description.
// If there's no underscore, the entire stem is the version.
versionStr, _, _ := strings.Cut(name, "_")
if versionStr == "" {
return 0, fmt.Errorf("%w: %q has empty version prefix", ErrInvalidMigrationFilename, filename)
}
// Validate the version is purely numeric.
for _, ch := range versionStr {
if ch < '0' || ch > '9' {
return 0, fmt.Errorf(
"%w: %q version %q contains non-numeric character %q",
ErrInvalidMigrationFilename, filename, versionStr, string(ch),
)
}
}
version, err := strconv.Atoi(versionStr)
if err != nil {
return 0, fmt.Errorf("%w: %q: %w", ErrInvalidMigrationFilename, filename, err)
}
return version, nil
}
// collectMigrations reads the embedded migrations directory and returns
// migration filenames sorted lexicographically.
func collectMigrations() ([]string, error) {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("failed to read migrations directory: %w", err)
return nil, fmt.Errorf("failed to read migrations directory: %w", err)
}
// Sort migrations by name
migrations := make([]string, 0, len(entries))
var migrations []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
@@ -41,54 +79,113 @@ func (d *Database) migrate(ctx context.Context) error {
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)
}
return migrations, nil
}
if applied {
d.log.Debug("migration already applied", "migration", migration)
// bootstrapMigrationsTable ensures the schema_migrations table exists by
// applying 000_migration.sql if the table is missing.
func bootstrapMigrationsTable(ctx context.Context, db *sql.DB, log *slog.Logger) error {
var tableExists int
continue
}
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
).Scan(&tableExists)
if err != nil {
return fmt.Errorf("failed to check for migrations table: %w", err)
}
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)
if tableExists == 0 {
return applyBootstrapMigration(ctx, db, log)
}
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)
// applyBootstrapMigration reads and executes 000_migration.sql to create the
// schema_migrations table on a fresh database.
func applyBootstrapMigration(ctx context.Context, db *sql.DB, log *slog.Logger) error {
content, err := migrationsFS.ReadFile("migrations/000_migration.sql")
if err != nil {
return false, fmt.Errorf("failed to query migration status: %w", err)
return fmt.Errorf("failed to read bootstrap migration 000_migration.sql: %w", err)
}
return count > 0, nil
if log != nil {
log.Info("applying bootstrap migration", "version", bootstrapVersion)
}
_, err = db.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
}
return 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)
// ApplyMigrations applies all pending migrations to db. An optional logger
// may be provided for informational output; pass nil for silent operation.
// This is exported so tests can apply the real schema without the full fx
// lifecycle.
func ApplyMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
bootstrapErr := bootstrapMigrationsTable(ctx, db, log)
if bootstrapErr != nil {
return bootstrapErr
}
transaction, err := d.database.BeginTx(ctx, nil)
migrations, err := collectMigrations()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
return err
}
for _, migration := range migrations {
version, parseErr := ParseMigrationVersion(migration)
if parseErr != nil {
return parseErr
}
// Check if already applied.
var count int
err = db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
version,
).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check migration %s: %w", migration, err)
}
if count > 0 {
if log != nil {
log.Debug("migration already applied", "version", version)
}
continue
}
// Apply migration in a transaction.
applyErr := applyMigrationTx(ctx, db, migration, version)
if applyErr != nil {
return applyErr
}
if log != nil {
log.Info("migration applied", "version", version)
}
}
return nil
}
// applyMigrationTx reads and executes a migration file within a transaction,
// recording the version in schema_migrations on success.
func applyMigrationTx(ctx context.Context, db *sql.DB, filename string, version int) error {
content, err := migrationsFS.ReadFile("migrations/" + filename)
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", filename, err)
}
transaction, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction for migration %s: %w", filename, err)
}
defer func() {
@@ -97,26 +194,27 @@ func (d *Database) applyMigration(ctx context.Context, filename string) error {
}
}()
// Execute migration
_, err = transaction.ExecContext(ctx, string(content))
if err != nil {
return fmt.Errorf("failed to execute migration: %w", err)
return fmt.Errorf("failed to execute migration %s: %w", filename, err)
}
// Record migration
_, err = transaction.ExecContext(
ctx,
_, err = transaction.ExecContext(ctx,
"INSERT INTO schema_migrations (version) VALUES (?)",
filename,
version,
)
if err != nil {
return fmt.Errorf("failed to record migration: %w", err)
return fmt.Errorf("failed to record migration %s: %w", filename, err)
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf("failed to commit migration: %w", err)
return fmt.Errorf("failed to commit migration %s: %w", filename, err)
}
return nil
}
func (d *Database) migrate(ctx context.Context) error {
return ApplyMigrations(ctx, d.database, d.log)
}

View File

@@ -0,0 +1,9 @@
-- Migration 000: Schema migrations tracking table
-- Applied as a bootstrap step before the normal migration loop.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);

View File

@@ -0,0 +1,3 @@
-- Add CPU and memory resource limits per app
ALTER TABLE apps ADD COLUMN cpu_limit REAL;
ALTER TABLE apps ADD COLUMN memory_limit INTEGER;

View File

@@ -0,0 +1,117 @@
package database_test
import (
"context"
"database/sql"
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/database"
)
func TestParseMigrationVersion(t *testing.T) {
t.Parallel()
tests := []struct {
filename string
wantVersion int
wantErr bool
}{
{filename: "000_migration.sql", wantVersion: 0},
{filename: "001_initial.sql", wantVersion: 1},
{filename: "002_remove_container_id.sql", wantVersion: 2},
{filename: "007_add_resource_limits.sql", wantVersion: 7},
{filename: "100_large_version.sql", wantVersion: 100},
{filename: ".sql", wantErr: true},
{filename: "_foo.sql", wantErr: true},
{filename: "abc_foo.sql", wantErr: true},
{filename: "1a2_bad.sql", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
t.Parallel()
version, err := database.ParseMigrationVersion(tt.filename)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantVersion, version)
})
}
}
func TestApplyMigrationsFreshDatabase(t *testing.T) {
t.Parallel()
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
require.NoError(t, err)
defer func() { _ = db.Close() }()
ctx := context.Background()
err = database.ApplyMigrations(ctx, db, nil)
require.NoError(t, err)
// Verify schema_migrations table exists with INTEGER version column.
var version int
err = db.QueryRowContext(ctx,
"SELECT version FROM schema_migrations WHERE version = 0",
).Scan(&version)
require.NoError(t, err)
assert.Equal(t, 0, version)
// Verify that all migrations were recorded.
var count int
err = db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations",
).Scan(&count)
require.NoError(t, err)
// 000 bootstrap + 001 through 007 = 8 entries.
assert.Equal(t, 8, count)
// Verify application tables were created by the migrations.
var tableCount int
err = db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'",
).Scan(&tableCount)
require.NoError(t, err)
assert.Equal(t, 1, tableCount)
}
func TestApplyMigrationsIdempotent(t *testing.T) {
t.Parallel()
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
require.NoError(t, err)
defer func() { _ = db.Close() }()
ctx := context.Background()
// Apply twice — second run should be a no-op.
err = database.ApplyMigrations(ctx, db, nil)
require.NoError(t, err)
err = database.ApplyMigrations(ctx, db, nil)
require.NoError(t, err)
var count int
err = db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM schema_migrations",
).Scan(&count)
require.NoError(t, err)
assert.Equal(t, 8, count)
}

View File

@@ -138,13 +138,15 @@ func (c *Client) BuildImage(
// CreateContainerOptions contains options for creating a container.
type CreateContainerOptions struct {
Name string
Image string
Env map[string]string
Labels map[string]string
Volumes []VolumeMount
Ports []PortMapping
Network string
Name string
Image string
Env map[string]string
Labels map[string]string
Volumes []VolumeMount
Ports []PortMapping
Network string
CPULimit float64 // CPU cores (e.g. 0.5 = half a core, 2.0 = two cores). 0 means unlimited.
MemoryLimit int64 // Memory in bytes. 0 means unlimited.
}
// VolumeMount represents a volume mount.
@@ -161,6 +163,14 @@ type PortMapping struct {
Protocol string // "tcp" or "udp"
}
// nanoCPUsPerCPU is the number of NanoCPUs per CPU core.
const nanoCPUsPerCPU = 1e9
// cpuLimitToNanoCPUs converts a CPU limit (e.g. 0.5 cores) to Docker NanoCPUs.
func cpuLimitToNanoCPUs(cpuLimit float64) int64 {
return int64(cpuLimit * nanoCPUsPerCPU)
}
// buildPortConfig converts port mappings to Docker port configuration.
func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
exposedPorts := make(nat.PortSet)
@@ -185,6 +195,48 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
return exposedPorts, portBindings
}
// buildEnvSlice converts an env map to a Docker-compatible env slice.
func buildEnvSlice(env map[string]string) []string {
envSlice := make([]string, 0, len(env))
for key, val := range env {
envSlice = append(envSlice, key+"="+val)
}
return envSlice
}
// buildMounts converts volume mounts to Docker mount configuration.
func buildMounts(volumes []VolumeMount) []mount.Mount {
mounts := make([]mount.Mount, 0, len(volumes))
for _, vol := range volumes {
mounts = append(mounts, mount.Mount{
Type: mount.TypeBind,
Source: vol.HostPath,
Target: vol.ContainerPath,
ReadOnly: vol.ReadOnly,
})
}
return mounts
}
// buildResources builds Docker resource constraints from container options.
func buildResources(opts CreateContainerOptions) container.Resources {
resources := container.Resources{}
if opts.CPULimit > 0 {
resources.NanoCPUs = cpuLimitToNanoCPUs(opts.CPULimit)
}
if opts.MemoryLimit > 0 {
resources.Memory = opts.MemoryLimit
}
return resources
}
// CreateContainer creates a new container.
func (c *Client) CreateContainer(
ctx context.Context,
@@ -196,40 +248,20 @@ func (c *Client) CreateContainer(
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,
})
}
// Convert ports to exposed ports and port bindings
exposedPorts, portBindings := buildPortConfig(opts.Ports)
// Create container
resp, err := c.docker.ContainerCreate(ctx,
&container.Config{
Image: opts.Image,
Env: envSlice,
Env: buildEnvSlice(opts.Env),
Labels: opts.Labels,
ExposedPorts: exposedPorts,
},
&container.HostConfig{
Mounts: mounts,
Mounts: buildMounts(opts.Volumes),
PortBindings: portBindings,
NetworkMode: container.NetworkMode(opts.Network),
Resources: buildResources(opts),
RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyUnlessStopped,
},

View File

@@ -0,0 +1,31 @@
package docker //nolint:testpackage // tests unexported cpuLimitToNanoCPUs
import (
"testing"
)
func TestCpuLimitToNanoCPUs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cpuLimit float64
expected int64
}{
{"one core", 1.0, 1_000_000_000},
{"half core", 0.5, 500_000_000},
{"two cores", 2.0, 2_000_000_000},
{"quarter core", 0.25, 250_000_000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := cpuLimitToNanoCPUs(tt.cpuLimit)
if got != tt.expected {
t.Errorf("cpuLimitToNanoCPUs(%v) = %d, want %d", tt.cpuLimit, got, tt.expected)
}
})
}
}

View File

@@ -27,11 +27,6 @@ func apiRouter(tc *testContext) http.Handler {
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
// Backup/Restore API
apiR.Get("/apps/{id}/export", tc.handlers.HandleAPIExportApp())
apiR.Get("/backup/export", tc.handlers.HandleAPIExportAllApps())
apiR.Post("/backup/import", tc.handlers.HandleAPIImportApps())
})
})

View File

@@ -257,23 +257,19 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
application.RepoURL = request.FormValue("repo_url")
application.Branch = request.FormValue("branch")
application.DockerfilePath = request.FormValue("dockerfile_path")
application.DockerNetwork = optionalNullString(request.FormValue("docker_network"))
application.NtfyTopic = optionalNullString(request.FormValue("ntfy_topic"))
application.SlackWebhook = optionalNullString(request.FormValue("slack_webhook"))
if network := request.FormValue("docker_network"); network != "" {
application.DockerNetwork = sql.NullString{String: network, Valid: true}
} else {
application.DockerNetwork = sql.NullString{}
}
limitsErr := applyResourceLimits(application, request)
if limitsErr != "" {
data := h.addGlobals(map[string]any{
"App": application,
"Error": limitsErr,
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
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{}
return
}
saveErr := application.Save(request.Context())
@@ -1368,6 +1364,129 @@ func validateVolumePaths(hostPath, containerPath string) error {
return nil
}
// ErrInvalidMemoryFormat is returned when a memory limit string cannot be parsed.
var ErrInvalidMemoryFormat = errors.New(
"must be a number with optional unit suffix (e.g. 256m, 1g, 512000000)",
)
// ErrNegativeValue is returned when a resource limit is negative.
var ErrNegativeValue = errors.New("value must be positive")
// Memory unit byte multipliers.
const (
kilobyte = 1024
megabyte = 1024 * 1024
gigabyte = 1024 * 1024 * 1024
)
// optionalNullString converts a form value to a sql.NullString.
// Returns a valid NullString if non-empty, invalid (NULL) if empty.
func optionalNullString(s string) sql.NullString {
if s != "" {
return sql.NullString{String: s, Valid: true}
}
return sql.NullString{}
}
// applyResourceLimits parses CPU and memory limit form values and applies them to the app.
// Returns an error message string if validation fails, or empty string on success.
func applyResourceLimits(application *models.App, request *http.Request) string {
cpuLimit, cpuErr := parseOptionalFloat64(request.FormValue("cpu_limit"))
if cpuErr != nil {
return "Invalid CPU limit: must be a positive number (e.g. 0.5, 1, 2)"
}
application.CPULimit = cpuLimit
memoryLimit, memErr := parseOptionalMemoryBytes(request.FormValue("memory_limit"))
if memErr != nil {
return "Invalid memory limit: " + memErr.Error()
}
application.MemoryLimit = memoryLimit
return ""
}
// memoryUnitMultiplier returns the byte multiplier for a memory unit suffix.
// Returns 0 if the suffix is not recognized.
func memoryUnitMultiplier(suffix byte) int64 {
switch suffix {
case 'k':
return kilobyte
case 'm':
return megabyte
case 'g':
return gigabyte
default:
return 0
}
}
// parseOptionalFloat64 parses an optional float64 form field.
// Returns a valid NullFloat64 if the string is non-empty and parses to a positive number.
// Returns an empty NullFloat64 if the string is empty.
// Returns an error if the string is non-empty but invalid or non-positive.
func parseOptionalFloat64(s string) (sql.NullFloat64, error) {
s = strings.TrimSpace(s)
if s == "" {
return sql.NullFloat64{}, nil
}
val, err := strconv.ParseFloat(s, 64)
if err != nil {
return sql.NullFloat64{}, fmt.Errorf("invalid number: %w", err)
}
if val <= 0 {
return sql.NullFloat64{}, ErrNegativeValue
}
return sql.NullFloat64{Float64: val, Valid: true}, nil
}
// parseOptionalMemoryBytes parses an optional memory limit string into bytes.
// Accepts plain bytes (e.g. "536870912") or suffixed values (e.g. "512m", "1g", "256k").
// Returns a valid NullInt64 with bytes if non-empty, empty NullInt64 if blank.
func parseOptionalMemoryBytes(s string) (sql.NullInt64, error) {
s = strings.TrimSpace(s)
if s == "" {
return sql.NullInt64{}, nil
}
s = strings.ToLower(s)
// Check for unit suffix
multiplier := memoryUnitMultiplier(s[len(s)-1])
if multiplier > 0 {
numStr := s[:len(s)-1]
val, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return sql.NullInt64{}, ErrInvalidMemoryFormat
}
if val <= 0 {
return sql.NullInt64{}, ErrNegativeValue
}
return sql.NullInt64{Int64: int64(val * float64(multiplier)), Valid: true}, nil
}
// Plain bytes
val, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return sql.NullInt64{}, ErrInvalidMemoryFormat
}
if val <= 0 {
return sql.NullInt64{}, ErrNegativeValue
}
return sql.NullInt64{Int64: val, Valid: true}, nil
}
// formatDeployKey formats an SSH public key with a descriptive comment.
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {

View File

@@ -1,282 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
"sneak.berlin/go/upaas/templates"
)
// importMaxBodyBytes is the maximum allowed request body size for backup import (10 MB).
const importMaxBodyBytes = 10 << 20
// HandleExportApp exports a single app's configuration as a JSON download.
func (h *Handlers) HandleExportApp() 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
}
bundle, exportErr := h.appService.ExportApp(request.Context(), application)
if exportErr != nil {
h.log.Error("failed to export app", "error", exportErr, "app", application.Name)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("upaas-backup-%s-%s.json",
application.Name,
time.Now().UTC().Format("20060102-150405"),
)
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Content-Disposition",
`attachment; filename="`+filename+`"`)
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
err := encoder.Encode(bundle)
if err != nil {
h.log.Error("failed to encode backup", "error", err)
}
}
}
// HandleExportAllApps exports all app configurations as a JSON download.
func (h *Handlers) HandleExportAllApps() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
bundle, exportErr := h.appService.ExportAllApps(request.Context())
if exportErr != nil {
h.log.Error("failed to export all apps", "error", exportErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("upaas-backup-all-%s.json",
time.Now().UTC().Format("20060102-150405"),
)
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Content-Disposition",
`attachment; filename="`+filename+`"`)
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
err := encoder.Encode(bundle)
if err != nil {
h.log.Error("failed to encode backup", "error", err)
}
}
}
// HandleImportPage renders the import/restore page.
func (h *Handlers) HandleImportPage() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
data := h.addGlobals(map[string]any{
"Success": request.URL.Query().Get("success"),
}, request)
h.renderTemplate(writer, tmpl, "backup_import.html", data)
}
}
// HandleImportApps processes an uploaded backup JSON file and imports apps.
func (h *Handlers) HandleImportApps() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
bundle, parseErr := h.parseBackupUpload(request)
if parseErr != "" {
data := h.addGlobals(map[string]any{"Error": parseErr}, request)
h.renderTemplate(writer, tmpl, "backup_import.html", data)
return
}
imported, skipped, importErr := h.appService.ImportApps(
request.Context(), bundle,
)
if importErr != nil {
h.log.Error("failed to import apps", "error", importErr)
data := h.addGlobals(map[string]any{
"Error": "Import failed: " + importErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "backup_import.html", data)
return
}
successMsg := fmt.Sprintf("Imported %d app(s)", len(imported))
if len(skipped) > 0 {
successMsg += fmt.Sprintf(", skipped %d (name conflict)", len(skipped))
}
http.Redirect(writer, request,
"/backup/import?success="+successMsg,
http.StatusSeeOther,
)
}
}
// parseBackupUpload extracts and validates a BackupBundle from a multipart upload.
// Returns the bundle and an empty string on success, or nil and an error message.
func (h *Handlers) parseBackupUpload(
request *http.Request,
) (*app.BackupBundle, string) {
request.Body = http.MaxBytesReader(nil, request.Body, importMaxBodyBytes)
parseErr := request.ParseMultipartForm(importMaxBodyBytes)
if parseErr != nil {
return nil, "Failed to parse upload: " + parseErr.Error()
}
file, _, openErr := request.FormFile("backup_file")
if openErr != nil {
return nil, "Please select a backup file to import"
}
defer func() { _ = file.Close() }()
var bundle app.BackupBundle
decodeErr := json.NewDecoder(file).Decode(&bundle)
if decodeErr != nil {
return nil, "Invalid backup file: " + decodeErr.Error()
}
if bundle.Version != 1 {
return nil, fmt.Sprintf(
"Unsupported backup version: %d (expected 1)", bundle.Version,
)
}
if len(bundle.Apps) == 0 {
return nil, "Backup file contains no apps"
}
return &bundle, ""
}
// HandleAPIExportApp exports a single app's configuration as JSON via API.
func (h *Handlers) HandleAPIExportApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal server error"},
http.StatusInternalServerError)
return
}
if application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
bundle, exportErr := h.appService.ExportApp(request.Context(), application)
if exportErr != nil {
h.log.Error("failed to export app", "error", exportErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to export app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, bundle, http.StatusOK)
}
}
// HandleAPIExportAllApps exports all app configurations as JSON via API.
func (h *Handlers) HandleAPIExportAllApps() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
bundle, exportErr := h.appService.ExportAllApps(request.Context())
if exportErr != nil {
h.log.Error("failed to export all apps", "error", exportErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to export apps"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, bundle, http.StatusOK)
}
}
// HandleAPIImportApps imports app configurations from a JSON request body via API.
func (h *Handlers) HandleAPIImportApps() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
request.Body = http.MaxBytesReader(writer, request.Body, importMaxBodyBytes)
var bundle app.BackupBundle
decodeErr := json.NewDecoder(request.Body).Decode(&bundle)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid request body"},
http.StatusBadRequest)
return
}
if bundle.Version != 1 {
h.respondJSON(writer, request,
map[string]string{"error": fmt.Sprintf(
"unsupported backup version: %d", bundle.Version,
)},
http.StatusBadRequest)
return
}
if len(bundle.Apps) == 0 {
h.respondJSON(writer, request,
map[string]string{"error": "backup contains no apps"},
http.StatusBadRequest)
return
}
imported, skipped, importErr := h.appService.ImportApps(
request.Context(), &bundle,
)
if importErr != nil {
h.log.Error("api: failed to import apps", "error", importErr)
h.respondJSON(writer, request,
map[string]string{"error": "import failed: " + importErr.Error()},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, map[string]any{
"imported": imported,
"skipped": skipped,
}, http.StatusOK)
}
}

View File

@@ -1,582 +0,0 @@
package handlers_test
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
)
// createTestAppWithConfig creates an app with env vars, labels, volumes, and ports.
func createTestAppWithConfig(
t *testing.T,
tc *testContext,
name string,
) *models.App {
t.Helper()
createdApp := createTestApp(t, tc, name)
// Add env vars
ev := models.NewEnvVar(tc.database)
ev.AppID = createdApp.ID
ev.Key = "DATABASE_URL"
ev.Value = "postgres://localhost/mydb"
require.NoError(t, ev.Save(context.Background()))
// Add label
label := models.NewLabel(tc.database)
label.AppID = createdApp.ID
label.Key = "traefik.enable"
label.Value = "true"
require.NoError(t, label.Save(context.Background()))
// Add volume
volume := models.NewVolume(tc.database)
volume.AppID = createdApp.ID
volume.HostPath = "/data/app"
volume.ContainerPath = "/app/data"
volume.ReadOnly = false
require.NoError(t, volume.Save(context.Background()))
// Add port
port := models.NewPort(tc.database)
port.AppID = createdApp.ID
port.HostPort = 8080
port.ContainerPort = 80
port.Protocol = models.PortProtocolTCP
require.NoError(t, port.Save(context.Background()))
return createdApp
}
// createTestAppWithConfigPort creates an app with a custom host port.
func createTestAppWithConfigPort(
t *testing.T,
tc *testContext,
name string,
hostPort int,
) *models.App {
t.Helper()
createdApp := createTestApp(t, tc, name)
ev := models.NewEnvVar(tc.database)
ev.AppID = createdApp.ID
ev.Key = "DATABASE_URL"
ev.Value = "postgres://localhost/mydb"
require.NoError(t, ev.Save(context.Background()))
port := models.NewPort(tc.database)
port.AppID = createdApp.ID
port.HostPort = hostPort
port.ContainerPort = 80
port.Protocol = models.PortProtocolTCP
require.NoError(t, port.Save(context.Background()))
return createdApp
}
func TestHandleExportApp(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestAppWithConfig(t, testCtx, "export-test-app")
request := httptest.NewRequest(http.MethodGet, "/apps/"+createdApp.ID+"/export", nil)
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleExportApp()
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.Header().Get("Content-Disposition"), "attachment")
assert.Contains(t, recorder.Header().Get("Content-Disposition"), "export-test-app")
var bundle app.BackupBundle
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle))
assert.Equal(t, 1, bundle.Version)
assert.NotEmpty(t, bundle.ExportedAt)
require.Len(t, bundle.Apps, 1)
appBackup := bundle.Apps[0]
assert.Equal(t, "export-test-app", appBackup.Name)
assert.Equal(t, "main", appBackup.Branch)
assert.Len(t, appBackup.EnvVars, 1)
assert.Equal(t, "DATABASE_URL", appBackup.EnvVars[0].Key)
assert.Equal(t, "postgres://localhost/mydb", appBackup.EnvVars[0].Value)
assert.Len(t, appBackup.Labels, 1)
assert.Equal(t, "traefik.enable", appBackup.Labels[0].Key)
assert.Len(t, appBackup.Volumes, 1)
assert.Equal(t, "/data/app", appBackup.Volumes[0].HostPath)
assert.Len(t, appBackup.Ports, 1)
assert.Equal(t, 8080, appBackup.Ports[0].HostPort)
}
func TestHandleExportAppNotFound(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/apps/nonexistent/export", nil)
request = addChiURLParams(request, map[string]string{"id": "nonexistent"})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleExportApp()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestHandleExportAllApps(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createTestAppWithConfig(t, testCtx, "export-all-app1")
createTestAppWithConfigPort(t, testCtx, "export-all-app2", 8081)
request := httptest.NewRequest(http.MethodGet, "/backup/export", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleExportAllApps()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Header().Get("Content-Disposition"), "upaas-backup-all")
var bundle app.BackupBundle
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle))
assert.Equal(t, 1, bundle.Version)
assert.Len(t, bundle.Apps, 2)
}
func TestHandleExportAllAppsEmpty(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/backup/export", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleExportAllApps()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
var bundle app.BackupBundle
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle))
assert.Empty(t, bundle.Apps)
}
// createMultipartBackupRequest builds a multipart form request with backup JSON as a file upload.
func createMultipartBackupRequest(
t *testing.T,
backupJSON string,
) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("backup_file", "backup.json")
require.NoError(t, err)
_, err = io.WriteString(part, backupJSON)
require.NoError(t, err)
require.NoError(t, writer.Close())
request := httptest.NewRequest(http.MethodPost, "/backup/import", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
return request
}
func TestHandleImportApps(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
backupJSON := `{
"version": 1,
"exportedAt": "2025-01-01T00:00:00Z",
"apps": [{
"name": "imported-app",
"repoUrl": "git@example.com:user/repo.git",
"branch": "main",
"dockerfilePath": "Dockerfile",
"envVars": [{"key": "FOO", "value": "bar"}],
"labels": [{"key": "app.name", "value": "test"}],
"volumes": [{"hostPath": "/data", "containerPath": "/app/data", "readOnly": true}],
"ports": [{"hostPort": 3000, "containerPort": 8080, "protocol": "tcp"}]
}]
}`
request := createMultipartBackupRequest(t, backupJSON)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportApps()
handler.ServeHTTP(recorder, request)
// Should redirect on success
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Contains(t, recorder.Header().Get("Location"), "success=")
// Verify the app was created
apps, err := models.AllApps(context.Background(), testCtx.database)
require.NoError(t, err)
require.Len(t, apps, 1)
assert.Equal(t, "imported-app", apps[0].Name)
// Verify env vars
envVars, _ := apps[0].GetEnvVars(context.Background())
require.Len(t, envVars, 1)
assert.Equal(t, "FOO", envVars[0].Key)
assert.Equal(t, "bar", envVars[0].Value)
// Verify labels
labels, _ := apps[0].GetLabels(context.Background())
require.Len(t, labels, 1)
assert.Equal(t, "app.name", labels[0].Key)
// Verify volumes
volumes, _ := apps[0].GetVolumes(context.Background())
require.Len(t, volumes, 1)
assert.Equal(t, "/data", volumes[0].HostPath)
assert.True(t, volumes[0].ReadOnly)
// Verify ports
ports, _ := apps[0].GetPorts(context.Background())
require.Len(t, ports, 1)
assert.Equal(t, 3000, ports[0].HostPort)
assert.Equal(t, 8080, ports[0].ContainerPort)
}
func TestHandleImportAppsSkipsDuplicateNames(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create an existing app with same name
createTestApp(t, testCtx, "existing-app")
backupJSON := `{
"version": 1,
"exportedAt": "2025-01-01T00:00:00Z",
"apps": [
{
"name": "existing-app",
"repoUrl": "git@example.com:user/repo.git",
"branch": "main",
"dockerfilePath": "Dockerfile",
"envVars": [],
"labels": [],
"volumes": [],
"ports": []
},
{
"name": "new-app",
"repoUrl": "git@example.com:user/new.git",
"branch": "main",
"dockerfilePath": "Dockerfile",
"envVars": [],
"labels": [],
"volumes": [],
"ports": []
}
]
}`
request := createMultipartBackupRequest(t, backupJSON)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportApps()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Contains(t, recorder.Header().Get("Location"), "skipped")
// Should have 2 apps total (existing + new)
apps, err := models.AllApps(context.Background(), testCtx.database)
require.NoError(t, err)
assert.Len(t, apps, 2)
}
func TestHandleImportAppsInvalidJSON(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := createMultipartBackupRequest(t, "not valid json")
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportApps()
handler.ServeHTTP(recorder, request)
// Should render the page with error, not redirect
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Invalid backup file")
}
func TestHandleImportAppsUnsupportedVersion(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
backupJSON := `{"version": 99, "exportedAt": "2025-01-01T00:00:00Z", "apps": [{"name": "test"}]}`
request := createMultipartBackupRequest(t, backupJSON)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportApps()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Unsupported backup version")
}
func TestHandleImportAppsEmptyBundle(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
backupJSON := `{"version": 1, "exportedAt": "2025-01-01T00:00:00Z", "apps": []}`
request := createMultipartBackupRequest(t, backupJSON)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportApps()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "contains no apps")
}
func TestHandleImportPage(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/backup/import", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleImportPage()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Import Backup")
}
func TestExportImportRoundTrip(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createTestAppWithConfig(t, testCtx, "roundtrip-app")
// Export
exportReq := httptest.NewRequest(http.MethodGet, "/backup/export", nil)
exportRec := httptest.NewRecorder()
testCtx.handlers.HandleExportAllApps().ServeHTTP(exportRec, exportReq)
require.Equal(t, http.StatusOK, exportRec.Code)
exportedJSON := exportRec.Body.String()
// Delete the original app
apps, _ := models.AllApps(context.Background(), testCtx.database)
for _, a := range apps {
require.NoError(t, a.Delete(context.Background()))
}
// Import
importReq := createMultipartBackupRequest(t, exportedJSON)
importRec := httptest.NewRecorder()
testCtx.handlers.HandleImportApps().ServeHTTP(importRec, importReq)
assert.Equal(t, http.StatusSeeOther, importRec.Code)
// Verify the app was recreated with all config
restoredApps, _ := models.AllApps(context.Background(), testCtx.database)
require.Len(t, restoredApps, 1)
assert.Equal(t, "roundtrip-app", restoredApps[0].Name)
envVars, _ := restoredApps[0].GetEnvVars(context.Background())
assert.Len(t, envVars, 1)
labels, _ := restoredApps[0].GetLabels(context.Background())
assert.Len(t, labels, 1)
volumes, _ := restoredApps[0].GetVolumes(context.Background())
assert.Len(t, volumes, 1)
ports, _ := restoredApps[0].GetPorts(context.Background())
assert.Len(t, ports, 1)
}
// TestAPIExportApp tests the API endpoint for exporting a single app.
func TestAPIExportApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
createdApp, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "api-export-app",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+createdApp.ID+"/export")
assert.Equal(t, http.StatusOK, rr.Code)
var bundle app.BackupBundle
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &bundle))
assert.Equal(t, 1, bundle.Version)
require.Len(t, bundle.Apps, 1)
assert.Equal(t, "api-export-app", bundle.Apps[0].Name)
}
// TestAPIExportAppNotFound tests the API endpoint for a nonexistent app.
func TestAPIExportAppNotFound(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/apps/nonexistent/export")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
// TestAPIExportAllApps tests the API endpoint for exporting all apps.
func TestAPIExportAllApps(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
_, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "api-export-all-1",
RepoURL: "git@example.com:user/repo1.git",
})
require.NoError(t, err)
_, err = tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{
Name: "api-export-all-2",
RepoURL: "git@example.com:user/repo2.git",
})
require.NoError(t, err)
rr := apiGet(t, tc, cookies, "/api/v1/backup/export")
assert.Equal(t, http.StatusOK, rr.Code)
var bundle app.BackupBundle
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &bundle))
assert.Len(t, bundle.Apps, 2)
}
// TestAPIImportApps tests the API import endpoint.
func TestAPIImportApps(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
backupJSON := `{
"version": 1,
"exportedAt": "2025-01-01T00:00:00Z",
"apps": [{
"name": "api-imported-app",
"repoUrl": "git@example.com:user/repo.git",
"branch": "main",
"dockerfilePath": "Dockerfile",
"envVars": [],
"labels": [],
"volumes": [],
"ports": []
}]
}`
r := apiRouter(tc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader(backupJSON))
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
imported, ok := resp["imported"].([]any)
require.True(t, ok)
assert.Len(t, imported, 1)
assert.Equal(t, "api-imported-app", imported[0])
}
// TestAPIImportAppsInvalidBody tests that the API rejects bad JSON.
func TestAPIImportAppsInvalidBody(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := apiRouter(tc)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
// TestAPIImportAppsUnsupportedVersion tests that the API rejects bad versions.
func TestAPIImportAppsUnsupportedVersion(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := apiRouter(tc)
body := `{"version": 42, "apps": [{"name": "x"}]}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}

View File

@@ -0,0 +1,195 @@
package handlers //nolint:testpackage // tests unexported parsing functions
import (
"database/sql"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseOptionalFloat64(t *testing.T) {
t.Parallel()
t.Run("empty string returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64("")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("whitespace only returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64(" ")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("valid float", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64("0.5")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.InDelta(t, 0.5, result.Float64, 0.001)
})
t.Run("valid integer", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalFloat64("2")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.InDelta(t, 2.0, result.Float64, 0.001)
})
t.Run("negative value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalFloat64("-1")
require.Error(t, err)
})
t.Run("zero value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalFloat64("0")
require.Error(t, err)
})
t.Run("non-numeric rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalFloat64("abc")
require.Error(t, err)
})
}
func TestParseOptionalMemoryBytes(t *testing.T) { //nolint:funlen // table-driven test
t.Parallel()
t.Run("empty string returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("whitespace only returns invalid", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes(" ")
require.NoError(t, err)
assert.False(t, result.Valid)
})
t.Run("plain bytes", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("536870912")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(536870912), result.Int64)
})
t.Run("megabytes suffix", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("256m")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(256*1024*1024), result.Int64)
})
t.Run("megabytes suffix uppercase", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("256M")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(256*1024*1024), result.Int64)
})
t.Run("gigabytes suffix", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("1g")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(1024*1024*1024), result.Int64)
})
t.Run("kilobytes suffix", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("512k")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(512*1024), result.Int64)
})
t.Run("fractional gigabytes", func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes("1.5g")
require.NoError(t, err)
assert.True(t, result.Valid)
assert.Equal(t, int64(1.5*1024*1024*1024), result.Int64)
})
t.Run("negative value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("-256m")
require.Error(t, err)
})
t.Run("zero value rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("0")
require.Error(t, err)
})
t.Run("invalid string rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("abc")
require.Error(t, err)
})
t.Run("negative plain bytes rejected", func(t *testing.T) {
t.Parallel()
_, err := parseOptionalMemoryBytes("-100")
require.Error(t, err)
})
}
func TestAppResourceLimitsRoundTrip(t *testing.T) {
t.Parallel()
// Test that parsing and formatting are consistent
tests := []struct {
input string
expected sql.NullInt64
format string
}{
{"256m", sql.NullInt64{Int64: 256 * 1024 * 1024, Valid: true}, "256m"},
{"1g", sql.NullInt64{Int64: 1024 * 1024 * 1024, Valid: true}, "1g"},
{"512k", sql.NullInt64{Int64: 512 * 1024, Valid: true}, "512k"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
result, err := parseOptionalMemoryBytes(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -7,12 +7,14 @@ import (
"github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/webhook"
)
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).
const maxWebhookBodySize = 1 << 20
// HandleWebhook handles incoming Gitea webhooks.
// HandleWebhook handles incoming webhooks from Gitea, GitHub, or GitLab.
// The webhook source is auto-detected from HTTP headers.
func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
secret := chi.URLParam(request, "secret")
@@ -50,16 +52,17 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
return
}
// Get event type from header
eventType := request.Header.Get("X-Gitea-Event")
if eventType == "" {
eventType = "push"
}
// Auto-detect webhook source from headers
source := webhook.DetectWebhookSource(request.Header)
// Extract event type based on detected source
eventType := webhook.DetectEventType(request.Header, source)
// Process webhook
webhookErr := h.webhook.HandleWebhook(
request.Context(),
application,
source,
eventType,
body,
)

View File

@@ -14,7 +14,8 @@ import (
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
previous_image_id, created_at, updated_at`
previous_image_id, cpu_limit, memory_limit,
created_at, updated_at`
// AppStatus represents the status of an app.
type AppStatus string
@@ -47,6 +48,8 @@ type App struct {
DockerNetwork sql.NullString
NtfyTopic sql.NullString
SlackWebhook sql.NullString
CPULimit sql.NullFloat64
MemoryLimit sql.NullInt64
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -142,14 +145,14 @@ func (a *App) insert(ctx context.Context) error {
id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
previous_image_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
previous_image_id, cpu_limit, memory_limit
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := a.db.Exec(ctx, query,
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
a.PreviousImageID,
a.PreviousImageID, a.CPULimit, a.MemoryLimit,
)
if err != nil {
return err
@@ -165,6 +168,7 @@ func (a *App) update(ctx context.Context) error {
image_id = ?, status = ?,
docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
previous_image_id = ?,
cpu_limit = ?, memory_limit = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
@@ -173,6 +177,7 @@ func (a *App) update(ctx context.Context) error {
a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.PreviousImageID,
a.CPULimit, a.MemoryLimit,
a.ID,
)
@@ -188,6 +193,7 @@ func (a *App) scan(row *sql.Row) error {
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
&a.WebhookSecretHash,
&a.PreviousImageID,
&a.CPULimit, &a.MemoryLimit,
&a.CreatedAt, &a.UpdatedAt,
)
}
@@ -206,6 +212,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
&app.WebhookSecretHash,
&app.PreviousImageID,
&app.CPULimit, &app.MemoryLimit,
&app.CreatedAt, &app.UpdatedAt,
)
if scanErr != nil {

View File

@@ -781,6 +781,96 @@ func TestCascadeDelete(t *testing.T) {
})
}
// Resource Limits Tests.
func TestAppResourceLimits(t *testing.T) { //nolint:funlen // integration test with multiple subtests
t.Parallel()
t.Run("saves and loads CPU limit", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
err := app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.True(t, found.CPULimit.Valid)
assert.InDelta(t, 0.5, found.CPULimit.Float64, 0.001)
})
t.Run("saves and loads memory limit", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
err := app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.True(t, found.MemoryLimit.Valid)
assert.Equal(t, int64(536870912), found.MemoryLimit.Int64)
})
t.Run("null limits by default", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.False(t, found.CPULimit.Valid)
assert.False(t, found.MemoryLimit.Valid)
})
t.Run("clears limits when set to null", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
// Set limits
app.CPULimit = sql.NullFloat64{Float64: 1.0, Valid: true}
app.MemoryLimit = sql.NullInt64{Int64: 1073741824, Valid: true} // 1g
err := app.Save(context.Background())
require.NoError(t, err)
// Clear limits
app.CPULimit = sql.NullFloat64{}
app.MemoryLimit = sql.NullInt64{}
err = app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.False(t, found.CPULimit.Valid)
assert.False(t, found.MemoryLimit.Valid)
})
}
// Helper function to create a test app.
func createTestApp(t *testing.T, testDB *database.Database) *models.App {
t.Helper()

View File

@@ -98,12 +98,6 @@ func (s *Server) SetupRoutes() {
// Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
// Backup/Restore
r.Get("/apps/{id}/export", s.handlers.HandleExportApp())
r.Get("/backup/export", s.handlers.HandleExportAllApps())
r.Get("/backup/import", s.handlers.HandleImportPage())
r.Post("/backup/import", s.handlers.HandleImportApps())
})
})
@@ -121,11 +115,6 @@ func (s *Server) SetupRoutes() {
r.Get("/apps", s.handlers.HandleAPIListApps())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
// Backup/Restore API
r.Get("/apps/{id}/export", s.handlers.HandleAPIExportApp())
r.Get("/backup/export", s.handlers.HandleAPIExportAllApps())
r.Post("/backup/import", s.handlers.HandleAPIImportApps())
})
})

View File

@@ -1,391 +0,0 @@
package app
import (
"context"
"fmt"
"time"
"sneak.berlin/go/upaas/internal/models"
)
// BackupEnvVar represents an environment variable in a backup.
type BackupEnvVar struct {
Key string `json:"key"`
Value string `json:"value"`
}
// BackupLabel represents a Docker label in a backup.
type BackupLabel struct {
Key string `json:"key"`
Value string `json:"value"`
}
// BackupVolume represents a volume mount in a backup.
type BackupVolume struct {
HostPath string `json:"hostPath"`
ContainerPath string `json:"containerPath"`
ReadOnly bool `json:"readOnly"`
}
// BackupPort represents a port mapping in a backup.
type BackupPort struct {
HostPort int `json:"hostPort"`
ContainerPort int `json:"containerPort"`
Protocol string `json:"protocol"`
}
// Backup represents the exported configuration of a single app.
type Backup struct {
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Branch string `json:"branch"`
DockerfilePath string `json:"dockerfilePath"`
DockerNetwork string `json:"dockerNetwork,omitempty"`
NtfyTopic string `json:"ntfyTopic,omitempty"`
SlackWebhook string `json:"slackWebhook,omitempty"`
EnvVars []BackupEnvVar `json:"envVars"`
Labels []BackupLabel `json:"labels"`
Volumes []BackupVolume `json:"volumes"`
Ports []BackupPort `json:"ports"`
}
// BackupBundle represents a complete backup of one or more apps.
type BackupBundle struct {
Version int `json:"version"`
ExportedAt string `json:"exportedAt"`
Apps []Backup `json:"apps"`
}
// backupVersion is the current backup format version.
const backupVersion = 1
// ExportApp exports a single app's configuration as a BackupBundle.
func (svc *Service) ExportApp(
ctx context.Context,
application *models.App,
) (*BackupBundle, error) {
appBackup, err := svc.buildAppBackup(ctx, application)
if err != nil {
return nil, err
}
return &BackupBundle{
Version: backupVersion,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Apps: []Backup{appBackup},
}, nil
}
// ExportAllApps exports all app configurations as a BackupBundle.
func (svc *Service) ExportAllApps(ctx context.Context) (*BackupBundle, error) {
apps, err := models.AllApps(ctx, svc.db)
if err != nil {
return nil, fmt.Errorf("listing apps for export: %w", err)
}
backups := make([]Backup, 0, len(apps))
for _, application := range apps {
appBackup, buildErr := svc.buildAppBackup(ctx, application)
if buildErr != nil {
return nil, buildErr
}
backups = append(backups, appBackup)
}
return &BackupBundle{
Version: backupVersion,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Apps: backups,
}, nil
}
// ImportApps imports app configurations from a BackupBundle.
// It creates new apps (with fresh IDs, SSH keys, and webhook secrets)
// and populates their env vars, labels, volumes, and ports.
// Apps whose names conflict with existing apps are skipped and reported.
func (svc *Service) ImportApps(
ctx context.Context,
bundle *BackupBundle,
) ([]string, []string, error) {
// Build a set of existing app names for conflict detection
existingApps, listErr := models.AllApps(ctx, svc.db)
if listErr != nil {
return nil, nil, fmt.Errorf("listing existing apps: %w", listErr)
}
existingNames := make(map[string]bool, len(existingApps))
for _, a := range existingApps {
existingNames[a.Name] = true
}
var imported, skipped []string
for _, ab := range bundle.Apps {
if existingNames[ab.Name] {
skipped = append(skipped, ab.Name)
continue
}
importErr := svc.importSingleApp(ctx, ab)
if importErr != nil {
return imported, skipped, fmt.Errorf(
"importing app %q: %w", ab.Name, importErr,
)
}
imported = append(imported, ab.Name)
}
return imported, skipped, nil
}
// importSingleApp creates a single app from backup data.
func (svc *Service) importSingleApp(
ctx context.Context,
ab Backup,
) error {
createdApp, createErr := svc.CreateApp(ctx, CreateAppInput{
Name: ab.Name,
RepoURL: ab.RepoURL,
Branch: ab.Branch,
DockerfilePath: ab.DockerfilePath,
DockerNetwork: ab.DockerNetwork,
NtfyTopic: ab.NtfyTopic,
SlackWebhook: ab.SlackWebhook,
})
if createErr != nil {
return fmt.Errorf("creating app: %w", createErr)
}
envErr := svc.importEnvVars(ctx, createdApp.ID, ab.EnvVars)
if envErr != nil {
return envErr
}
labelErr := svc.importLabels(ctx, createdApp.ID, ab.Labels)
if labelErr != nil {
return labelErr
}
volErr := svc.importVolumes(ctx, createdApp.ID, ab.Volumes)
if volErr != nil {
return volErr
}
portErr := svc.importPorts(ctx, createdApp.ID, ab.Ports)
if portErr != nil {
return portErr
}
svc.log.Info("app imported from backup",
"id", createdApp.ID, "name", createdApp.Name)
return nil
}
// importEnvVars adds env vars from backup to an app.
func (svc *Service) importEnvVars(
ctx context.Context,
appID string,
envVars []BackupEnvVar,
) error {
for _, ev := range envVars {
addErr := svc.AddEnvVar(ctx, appID, ev.Key, ev.Value)
if addErr != nil {
return fmt.Errorf("adding env var %q: %w", ev.Key, addErr)
}
}
return nil
}
// importLabels adds labels from backup to an app.
func (svc *Service) importLabels(
ctx context.Context,
appID string,
labels []BackupLabel,
) error {
for _, l := range labels {
addErr := svc.AddLabel(ctx, appID, l.Key, l.Value)
if addErr != nil {
return fmt.Errorf("adding label %q: %w", l.Key, addErr)
}
}
return nil
}
// importVolumes adds volumes from backup to an app.
func (svc *Service) importVolumes(
ctx context.Context,
appID string,
volumes []BackupVolume,
) error {
for _, v := range volumes {
addErr := svc.AddVolume(ctx, appID, v.HostPath, v.ContainerPath, v.ReadOnly)
if addErr != nil {
return fmt.Errorf("adding volume %q: %w", v.ContainerPath, addErr)
}
}
return nil
}
// importPorts adds ports from backup to an app.
func (svc *Service) importPorts(
ctx context.Context,
appID string,
ports []BackupPort,
) error {
for _, p := range ports {
port := models.NewPort(svc.db)
port.AppID = appID
port.HostPort = p.HostPort
port.ContainerPort = p.ContainerPort
port.Protocol = models.PortProtocol(p.Protocol)
if port.Protocol == "" {
port.Protocol = models.PortProtocolTCP
}
saveErr := port.Save(ctx)
if saveErr != nil {
return fmt.Errorf("adding port %d: %w", p.HostPort, saveErr)
}
}
return nil
}
// buildAppBackup collects all configuration for a single app into a Backup.
func (svc *Service) buildAppBackup(
ctx context.Context,
application *models.App,
) (Backup, error) {
envVars, labels, volumes, ports, err := svc.fetchAppResources(ctx, application)
if err != nil {
return Backup{}, err
}
backup := Backup{
Name: application.Name,
RepoURL: application.RepoURL,
Branch: application.Branch,
DockerfilePath: application.DockerfilePath,
EnvVars: convertEnvVars(envVars),
Labels: convertLabels(labels),
Volumes: convertVolumes(volumes),
Ports: convertPorts(ports),
}
if application.DockerNetwork.Valid {
backup.DockerNetwork = application.DockerNetwork.String
}
if application.NtfyTopic.Valid {
backup.NtfyTopic = application.NtfyTopic.String
}
if application.SlackWebhook.Valid {
backup.SlackWebhook = application.SlackWebhook.String
}
return backup, nil
}
// fetchAppResources retrieves all sub-resources for an app.
func (svc *Service) fetchAppResources(
ctx context.Context,
application *models.App,
) ([]*models.EnvVar, []*models.Label, []*models.Volume, []*models.Port, error) {
envVars, err := application.GetEnvVars(ctx)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf(
"getting env vars for %q: %w", application.Name, err,
)
}
labels, err := application.GetLabels(ctx)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf(
"getting labels for %q: %w", application.Name, err,
)
}
volumes, err := application.GetVolumes(ctx)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf(
"getting volumes for %q: %w", application.Name, err,
)
}
ports, err := application.GetPorts(ctx)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf(
"getting ports for %q: %w", application.Name, err,
)
}
return envVars, labels, volumes, ports, nil
}
// convertEnvVars converts model env vars to backup format.
func convertEnvVars(envVars []*models.EnvVar) []BackupEnvVar {
result := make([]BackupEnvVar, 0, len(envVars))
for _, ev := range envVars {
result = append(result, BackupEnvVar{
Key: ev.Key,
Value: ev.Value,
})
}
return result
}
// convertLabels converts model labels to backup format.
func convertLabels(labels []*models.Label) []BackupLabel {
result := make([]BackupLabel, 0, len(labels))
for _, l := range labels {
result = append(result, BackupLabel{
Key: l.Key,
Value: l.Value,
})
}
return result
}
// convertVolumes converts model volumes to backup format.
func convertVolumes(volumes []*models.Volume) []BackupVolume {
result := make([]BackupVolume, 0, len(volumes))
for _, v := range volumes {
result = append(result, BackupVolume{
HostPath: v.HostPath,
ContainerPath: v.ContainerPath,
ReadOnly: v.ReadOnly,
})
}
return result
}
// convertPorts converts model ports to backup format.
func convertPorts(ports []*models.Port) []BackupPort {
result := make([]BackupPort, 0, len(ports))
for _, p := range ports {
result = append(result, BackupPort{
HostPort: p.HostPort,
ContainerPort: p.ContainerPort,
Protocol: string(p.Protocol),
})
}
return result
}

View File

@@ -1,379 +0,0 @@
package app_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app"
)
// backupTestContext bundles test dependencies for backup tests.
type backupTestContext struct {
svc *app.Service
db *database.Database
}
func setupBackupTest(t *testing.T) *backupTestContext {
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)
return &backupTestContext{svc: svc, db: dbInst}
}
// createAppWithFullConfig creates an app with env vars, labels, volumes, and ports.
func createAppWithFullConfig(
t *testing.T,
btc *backupTestContext,
name string,
) *models.App {
t.Helper()
createdApp, err := btc.svc.CreateApp(context.Background(), app.CreateAppInput{
Name: name,
RepoURL: "git@example.com:user/" + name + ".git",
Branch: "develop",
NtfyTopic: "https://ntfy.sh/" + name,
DockerNetwork: "test-network",
})
require.NoError(t, err)
require.NoError(t, btc.svc.AddEnvVar(
context.Background(), createdApp.ID, "DB_HOST", "localhost",
))
require.NoError(t, btc.svc.AddEnvVar(
context.Background(), createdApp.ID, "DB_PORT", "5432",
))
require.NoError(t, btc.svc.AddLabel(
context.Background(), createdApp.ID, "traefik.enable", "true",
))
require.NoError(t, btc.svc.AddVolume(
context.Background(), createdApp.ID, "/data", "/app/data", false,
))
port := models.NewPort(btc.db)
port.AppID = createdApp.ID
port.HostPort = 9090
port.ContainerPort = 8080
port.Protocol = models.PortProtocolTCP
require.NoError(t, port.Save(context.Background()))
return createdApp
}
// createAppWithConfigPort creates an app like createAppWithFullConfig but with
// a custom host port to avoid UNIQUE constraint collisions.
func createAppWithConfigPort(
t *testing.T,
btc *backupTestContext,
name string,
hostPort int,
) *models.App {
t.Helper()
createdApp, err := btc.svc.CreateApp(context.Background(), app.CreateAppInput{
Name: name,
RepoURL: "git@example.com:user/" + name + ".git",
Branch: "develop",
NtfyTopic: "https://ntfy.sh/" + name,
DockerNetwork: "test-network",
})
require.NoError(t, err)
require.NoError(t, btc.svc.AddEnvVar(
context.Background(), createdApp.ID, "DB_HOST", "localhost",
))
require.NoError(t, btc.svc.AddLabel(
context.Background(), createdApp.ID, "traefik.enable", "true",
))
require.NoError(t, btc.svc.AddVolume(
context.Background(), createdApp.ID, "/data2", "/app/data2", false,
))
port := models.NewPort(btc.db)
port.AppID = createdApp.ID
port.HostPort = hostPort
port.ContainerPort = 8080
port.Protocol = models.PortProtocolTCP
require.NoError(t, port.Save(context.Background()))
return createdApp
}
func TestExportApp(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
createdApp := createAppWithFullConfig(t, btc, "export-svc-test")
bundle, err := btc.svc.ExportApp(context.Background(), createdApp)
require.NoError(t, err)
assert.Equal(t, 1, bundle.Version)
assert.NotEmpty(t, bundle.ExportedAt)
require.Len(t, bundle.Apps, 1)
ab := bundle.Apps[0]
assert.Equal(t, "export-svc-test", ab.Name)
assert.Equal(t, "develop", ab.Branch)
assert.Equal(t, "test-network", ab.DockerNetwork)
assert.Equal(t, "https://ntfy.sh/export-svc-test", ab.NtfyTopic)
assert.Len(t, ab.EnvVars, 2)
assert.Len(t, ab.Labels, 1)
assert.Len(t, ab.Volumes, 1)
assert.Len(t, ab.Ports, 1)
assert.Equal(t, 9090, ab.Ports[0].HostPort)
assert.Equal(t, 8080, ab.Ports[0].ContainerPort)
assert.Equal(t, "tcp", ab.Ports[0].Protocol)
}
func TestExportAllApps(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
createAppWithFullConfig(t, btc, "export-all-1")
createAppWithConfigPort(t, btc, "export-all-2", 9091)
bundle, err := btc.svc.ExportAllApps(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, bundle.Version)
assert.Len(t, bundle.Apps, 2)
}
func TestExportAllAppsEmpty(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
bundle, err := btc.svc.ExportAllApps(context.Background())
require.NoError(t, err)
assert.Empty(t, bundle.Apps)
}
func TestImportApps(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
bundle := &app.BackupBundle{
Version: 1,
ExportedAt: "2025-01-01T00:00:00Z",
Apps: []app.Backup{
{
Name: "imported-test",
RepoURL: "git@example.com:user/imported.git",
Branch: "main",
DockerfilePath: "Dockerfile",
DockerNetwork: "my-network",
EnvVars: []app.BackupEnvVar{
{Key: "FOO", Value: "bar"},
},
Labels: []app.BackupLabel{
{Key: "app", Value: "test"},
},
Volumes: []app.BackupVolume{
{HostPath: "/host", ContainerPath: "/container", ReadOnly: true},
},
Ports: []app.BackupPort{
{HostPort: 3000, ContainerPort: 8080, Protocol: "tcp"},
},
},
},
}
imported, skipped, err := btc.svc.ImportApps(context.Background(), bundle)
require.NoError(t, err)
assert.Equal(t, []string{"imported-test"}, imported)
assert.Empty(t, skipped)
// Verify the app was created
apps, _ := btc.svc.ListApps(context.Background())
require.Len(t, apps, 1)
assert.Equal(t, "imported-test", apps[0].Name)
assert.True(t, apps[0].DockerNetwork.Valid)
assert.Equal(t, "my-network", apps[0].DockerNetwork.String)
// Has fresh secrets
assert.NotEmpty(t, apps[0].WebhookSecret)
assert.NotEmpty(t, apps[0].SSHPublicKey)
// Verify sub-resources
envVars, _ := apps[0].GetEnvVars(context.Background())
assert.Len(t, envVars, 1)
labels, _ := apps[0].GetLabels(context.Background())
assert.Len(t, labels, 1)
volumes, _ := apps[0].GetVolumes(context.Background())
assert.Len(t, volumes, 1)
assert.True(t, volumes[0].ReadOnly)
ports, _ := apps[0].GetPorts(context.Background())
assert.Len(t, ports, 1)
assert.Equal(t, 3000, ports[0].HostPort)
}
func TestImportAppsSkipsDuplicates(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
// Create existing app
_, err := btc.svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "existing",
RepoURL: "git@example.com:user/existing.git",
})
require.NoError(t, err)
bundle := &app.BackupBundle{
Version: 1,
ExportedAt: "2025-01-01T00:00:00Z",
Apps: []app.Backup{
{
Name: "existing",
RepoURL: "git@example.com:user/existing.git",
Branch: "main",
DockerfilePath: "Dockerfile",
EnvVars: []app.BackupEnvVar{},
Labels: []app.BackupLabel{},
Volumes: []app.BackupVolume{},
Ports: []app.BackupPort{},
},
{
Name: "brand-new",
RepoURL: "git@example.com:user/new.git",
Branch: "main",
DockerfilePath: "Dockerfile",
EnvVars: []app.BackupEnvVar{},
Labels: []app.BackupLabel{},
Volumes: []app.BackupVolume{},
Ports: []app.BackupPort{},
},
},
}
imported, skipped, err := btc.svc.ImportApps(context.Background(), bundle)
require.NoError(t, err)
assert.Equal(t, []string{"brand-new"}, imported)
assert.Equal(t, []string{"existing"}, skipped)
}
func TestImportAppsPortDefaultProtocol(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
bundle := &app.BackupBundle{
Version: 1,
ExportedAt: "2025-01-01T00:00:00Z",
Apps: []app.Backup{
{
Name: "port-default-test",
RepoURL: "git@example.com:user/repo.git",
Branch: "main",
DockerfilePath: "Dockerfile",
EnvVars: []app.BackupEnvVar{},
Labels: []app.BackupLabel{},
Volumes: []app.BackupVolume{},
Ports: []app.BackupPort{
{HostPort: 80, ContainerPort: 80, Protocol: ""},
},
},
},
}
imported, _, err := btc.svc.ImportApps(context.Background(), bundle)
require.NoError(t, err)
assert.Len(t, imported, 1)
apps, _ := btc.svc.ListApps(context.Background())
ports, _ := apps[0].GetPorts(context.Background())
require.Len(t, ports, 1)
assert.Equal(t, models.PortProtocolTCP, ports[0].Protocol)
}
func TestExportImportRoundTripService(t *testing.T) {
t.Parallel()
btc := setupBackupTest(t)
createAppWithFullConfig(t, btc, "roundtrip-svc")
// Export
bundle, err := btc.svc.ExportAllApps(context.Background())
require.NoError(t, err)
require.Len(t, bundle.Apps, 1)
// Delete original
apps, _ := btc.svc.ListApps(context.Background())
for _, a := range apps {
require.NoError(t, btc.svc.DeleteApp(context.Background(), a))
}
// Import
imported, skipped, err := btc.svc.ImportApps(context.Background(), bundle)
require.NoError(t, err)
assert.Len(t, imported, 1)
assert.Empty(t, skipped)
// Verify round-trip fidelity
restored, _ := btc.svc.ListApps(context.Background())
require.Len(t, restored, 1)
assert.Equal(t, "roundtrip-svc", restored[0].Name)
assert.Equal(t, "develop", restored[0].Branch)
assert.Equal(t, "test-network", restored[0].DockerNetwork.String)
envVars, _ := restored[0].GetEnvVars(context.Background())
assert.Len(t, envVars, 2)
labels, _ := restored[0].GetLabels(context.Background())
assert.Len(t, labels, 1)
volumes, _ := restored[0].GetVolumes(context.Background())
assert.Len(t, volumes, 1)
ports, _ := restored[0].GetPorts(context.Background())
assert.Len(t, ports, 1)
}

View File

@@ -1094,14 +1094,28 @@ func (svc *Service) buildContainerOptions(
network = app.DockerNetwork.String
}
var cpuLimit float64
if app.CPULimit.Valid {
cpuLimit = app.CPULimit.Float64
}
var memoryLimit int64
if app.MemoryLimit.Valid {
memoryLimit = app.MemoryLimit.Int64
}
return docker.CreateContainerOptions{
Name: "upaas-" + app.Name,
Image: imageID.String(),
Env: envMap,
Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes),
Ports: buildPortMappings(ports),
Network: network,
Name: "upaas-" + app.Name,
Image: imageID.String(),
Env: envMap,
Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes),
Ports: buildPortMappings(ports),
Network: network,
CPULimit: cpuLimit,
MemoryLimit: memoryLimit,
}, nil
}

View File

@@ -2,6 +2,7 @@ package deploy_test
import (
"context"
"database/sql"
"log/slog"
"os"
"testing"
@@ -43,3 +44,93 @@ func TestBuildContainerOptionsUsesImageID(t *testing.T) {
t.Errorf("expected Name=%q, got %q", "upaas-myapp", opts.Name)
}
}
func TestBuildContainerOptionsNoResourceLimits(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "nolimits"
err := app.Save(context.Background())
if err != nil {
t.Fatalf("failed to save app: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
svc := deploy.NewTestService(log)
opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, docker.ImageID("test:latest"),
)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.CPULimit != 0 {
t.Errorf("expected CPULimit=0, got %v", opts.CPULimit)
}
if opts.MemoryLimit != 0 {
t.Errorf("expected MemoryLimit=0, got %v", opts.MemoryLimit)
}
}
func TestBuildContainerOptionsCPULimit(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "cpulimit"
app.CPULimit = sql.NullFloat64{Float64: 0.5, Valid: true}
err := app.Save(context.Background())
if err != nil {
t.Fatalf("failed to save app: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
svc := deploy.NewTestService(log)
opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, docker.ImageID("test:latest"),
)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.CPULimit != 0.5 {
t.Errorf("expected CPULimit=0.5, got %v", opts.CPULimit)
}
}
func TestBuildContainerOptionsMemoryLimit(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "memlimit"
app.MemoryLimit = sql.NullInt64{Int64: 536870912, Valid: true} // 512m
err := app.Save(context.Background())
if err != nil {
t.Fatalf("failed to save app: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
svc := deploy.NewTestService(log)
opts, err := svc.BuildContainerOptionsExported(
context.Background(), app, docker.ImageID("test:latest"),
)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.MemoryLimit != 536870912 {
t.Errorf("expected MemoryLimit=536870912, got %v", opts.MemoryLimit)
}
}

View File

@@ -0,0 +1,248 @@
package webhook
import "encoding/json"
// 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"`
CompareURL UnparsedURL `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// GitHubPushPayload represents a GitHub push webhook payload.
//
//nolint:tagliatelle // Field names match GitHub API (snake_case)
type GitHubPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"pusher"`
HeadCommit *struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
} `json:"head_commit"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// GitLabPushPayload represents a GitLab push webhook payload.
//
//nolint:tagliatelle // Field names match GitLab API (snake_case)
type GitLabPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
Project struct {
PathWithNamespace string `json:"path_with_namespace"`
GitHTTPURL UnparsedURL `json:"git_http_url"`
GitSSHURL string `json:"git_ssh_url"`
WebURL UnparsedURL `json:"web_url"`
} `json:"project"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// ParsePushPayload parses a raw webhook payload into a normalized PushEvent
// based on the detected webhook source. Returns an error if JSON unmarshaling
// fails. For SourceUnknown, falls back to Gitea format for backward
// compatibility.
func ParsePushPayload(source Source, payload []byte) (*PushEvent, error) {
switch source {
case SourceGitHub:
return parseGitHubPush(payload)
case SourceGitLab:
return parseGitLabPush(payload)
case SourceGitea, SourceUnknown:
// Gitea and unknown both use Gitea format for backward compatibility.
return parseGiteaPush(payload)
}
// Unreachable for known source values, but satisfies exhaustive checker.
return parseGiteaPush(payload)
}
func parseGiteaPush(payload []byte) (*PushEvent, error) {
var p GiteaPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGiteaCommitURL(p)
return &PushEvent{
Source: SourceGitea,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Repository.FullName,
CloneURL: p.Repository.CloneURL,
HTMLURL: p.Repository.HTMLURL,
CommitURL: commitURL,
Pusher: p.Pusher.Username,
}, nil
}
func parseGitHubPush(payload []byte) (*PushEvent, error) {
var p GitHubPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGitHubCommitURL(p)
return &PushEvent{
Source: SourceGitHub,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Repository.FullName,
CloneURL: p.Repository.CloneURL,
HTMLURL: p.Repository.HTMLURL,
CommitURL: commitURL,
Pusher: p.Pusher.Name,
}, nil
}
func parseGitLabPush(payload []byte) (*PushEvent, error) {
var p GitLabPushPayload
unmarshalErr := json.Unmarshal(payload, &p)
if unmarshalErr != nil {
return nil, unmarshalErr
}
commitURL := extractGitLabCommitURL(p)
return &PushEvent{
Source: SourceGitLab,
Ref: p.Ref,
Before: p.Before,
After: p.After,
Branch: extractBranch(p.Ref),
RepoName: p.Project.PathWithNamespace,
CloneURL: p.Project.GitHTTPURL,
HTMLURL: p.Project.WebURL,
CommitURL: commitURL,
Pusher: p.UserName,
}, nil
}
// 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
}
// extractGiteaCommitURL extracts the commit URL from a Gitea push payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractGiteaCommitURL(payload GiteaPushPayload) UnparsedURL {
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}
// extractGitHubCommitURL extracts the commit URL from a GitHub push payload.
// Prefers head_commit.url, then searches commits, then constructs from repo URL.
func extractGitHubCommitURL(payload GitHubPushPayload) UnparsedURL {
if payload.HeadCommit != nil && payload.HeadCommit.URL != "" {
return payload.HeadCommit.URL
}
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}
// extractGitLabCommitURL extracts the commit URL from a GitLab push payload.
// Prefers commit URL from the commits list, falls back to constructing from
// project web URL.
func extractGitLabCommitURL(payload GitLabPushPayload) UnparsedURL {
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
if payload.Project.WebURL != "" && payload.After != "" {
return UnparsedURL(payload.Project.WebURL.String() + "/-/commit/" + payload.After)
}
return ""
}

View File

@@ -1,5 +1,7 @@
package webhook
import "net/http"
// UnparsedURL is a URL stored as a plain string without parsing.
// Use this instead of string when the value is known to be a URL
// but should not be parsed into a net/url.URL (e.g. webhook URLs,
@@ -8,3 +10,84 @@ type UnparsedURL string
// String implements the fmt.Stringer interface.
func (u UnparsedURL) String() string { return string(u) }
// Source identifies which git hosting platform sent the webhook.
type Source string
const (
// SourceGitea indicates the webhook was sent by a Gitea instance.
SourceGitea Source = "gitea"
// SourceGitHub indicates the webhook was sent by GitHub.
SourceGitHub Source = "github"
// SourceGitLab indicates the webhook was sent by a GitLab instance.
SourceGitLab Source = "gitlab"
// SourceUnknown indicates the webhook source could not be determined.
SourceUnknown Source = "unknown"
)
// String implements the fmt.Stringer interface.
func (s Source) String() string { return string(s) }
// DetectWebhookSource determines the webhook source from HTTP headers.
// It checks for platform-specific event headers in this order:
// Gitea (X-Gitea-Event), GitHub (X-GitHub-Event), GitLab (X-Gitlab-Event).
// Returns SourceUnknown if no recognized header is found.
func DetectWebhookSource(headers http.Header) Source {
if headers.Get("X-Gitea-Event") != "" {
return SourceGitea
}
if headers.Get("X-Github-Event") != "" {
return SourceGitHub
}
if headers.Get("X-Gitlab-Event") != "" {
return SourceGitLab
}
return SourceUnknown
}
// DetectEventType extracts the event type string from HTTP headers
// based on the detected webhook source. Returns "push" as a fallback
// when no event header is found.
func DetectEventType(headers http.Header, source Source) string {
switch source {
case SourceGitea:
if v := headers.Get("X-Gitea-Event"); v != "" {
return v
}
case SourceGitHub:
if v := headers.Get("X-Github-Event"); v != "" {
return v
}
case SourceGitLab:
if v := headers.Get("X-Gitlab-Event"); v != "" {
return v
}
case SourceUnknown:
// Fall through to default
}
return "push"
}
// PushEvent is a normalized representation of a push webhook payload
// from any supported source (Gitea, GitHub, GitLab). The webhook
// service converts source-specific payloads into this format before
// processing.
type PushEvent struct {
Source Source
Ref string
Before string
After string
Branch string
RepoName string
CloneURL UnparsedURL
HTMLURL UnparsedURL
CommitURL UnparsedURL
Pusher string
}

View File

@@ -4,7 +4,6 @@ package webhook
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
@@ -44,68 +43,46 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
}, 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"`
CompareURL UnparsedURL `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
} `json:"author"`
} `json:"commits"`
}
// HandleWebhook processes a webhook request.
// HandleWebhook processes a webhook request from any supported source
// (Gitea, GitHub, or GitLab). The source parameter determines which
// payload format to use for parsing.
func (svc *Service) HandleWebhook(
ctx context.Context,
app *models.App,
source Source,
eventType string,
payload []byte,
) error {
svc.log.Info("processing webhook", "app", app.Name, "event", eventType)
svc.log.Info("processing webhook",
"app", app.Name,
"source", source.String(),
"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
// Parse payload into normalized push event
pushEvent, parseErr := ParsePushPayload(source, payload)
if parseErr != nil {
svc.log.Warn("failed to parse webhook payload",
"error", parseErr,
"source", source.String(),
)
// Continue with empty push event to still log the webhook
pushEvent = &PushEvent{Source: source}
}
// Extract branch from ref
branch := extractBranch(pushPayload.Ref)
commitSHA := pushPayload.After
commitURL := extractCommitURL(pushPayload)
// Check if branch matches
matched := branch == app.Branch
matched := pushEvent.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.CommitURL = sql.NullString{String: commitURL.String(), Valid: commitURL != ""}
event.Branch = pushEvent.Branch
event.CommitSHA = sql.NullString{String: pushEvent.After, Valid: pushEvent.After != ""}
event.CommitURL = sql.NullString{
String: pushEvent.CommitURL.String(),
Valid: pushEvent.CommitURL != "",
}
event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched
event.Processed = false
@@ -117,9 +94,10 @@ func (svc *Service) HandleWebhook(
svc.log.Info("webhook event recorded",
"app", app.Name,
"branch", branch,
"source", source.String(),
"branch", pushEvent.Branch,
"matched", matched,
"commit", commitSHA,
"commit", pushEvent.After,
)
// If branch matches, trigger deployment
@@ -154,33 +132,3 @@ func (svc *Service) triggerDeployment(
_ = 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
}
// extractCommitURL extracts the commit URL from the webhook payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractCommitURL(payload GiteaPushPayload) UnparsedURL {
// Try to find the URL from the head commit (matching After SHA)
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
return commit.URL
}
}
// Fall back to constructing URL from repo HTML URL
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""
}

View File

@@ -3,6 +3,7 @@ package webhook_test
import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"testing"
@@ -102,44 +103,57 @@ func createTestApp(
return app
}
// TestDetectWebhookSource tests auto-detection of webhook source from HTTP headers.
//
//nolint:funlen // table-driven test with comprehensive test cases
func TestExtractBranch(testingT *testing.T) {
func TestDetectWebhookSource(testingT *testing.T) {
testingT.Parallel()
tests := []struct {
name string
ref string
expected string
headers map[string]string
expected webhook.Source
}{
{
name: "extracts main branch",
ref: "refs/heads/main",
expected: "main",
name: "detects Gitea from X-Gitea-Event header",
headers: map[string]string{"X-Gitea-Event": "push"},
expected: webhook.SourceGitea,
},
{
name: "extracts feature branch",
ref: "refs/heads/feature/new-feature",
expected: "feature/new-feature",
name: "detects GitHub from X-GitHub-Event header",
headers: map[string]string{"X-GitHub-Event": "push"},
expected: webhook.SourceGitHub,
},
{
name: "extracts develop branch",
ref: "refs/heads/develop",
expected: "develop",
name: "detects GitLab from X-Gitlab-Event header",
headers: map[string]string{"X-Gitlab-Event": "Push Hook"},
expected: webhook.SourceGitLab,
},
{
name: "returns raw ref if no prefix",
ref: "main",
expected: "main",
name: "returns unknown when no recognized header",
headers: map[string]string{"Content-Type": "application/json"},
expected: webhook.SourceUnknown,
},
{
name: "handles empty ref",
ref: "",
expected: "",
name: "returns unknown for empty headers",
headers: map[string]string{},
expected: webhook.SourceUnknown,
},
{
name: "handles partial prefix",
ref: "refs/heads/",
expected: "",
name: "Gitea takes precedence over GitHub",
headers: map[string]string{
"X-Gitea-Event": "push",
"X-GitHub-Event": "push",
},
expected: webhook.SourceGitea,
},
{
name: "GitHub takes precedence over GitLab",
headers: map[string]string{
"X-GitHub-Event": "push",
"X-Gitlab-Event": "Push Hook",
},
expected: webhook.SourceGitHub,
},
}
@@ -147,123 +161,375 @@ func TestExtractBranch(testingT *testing.T) {
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()
headers := http.Header{}
for key, value := range testCase.headers {
headers.Set(key, value)
}
app := createTestApp(t, dbInst, testCase.expected)
payload := []byte(`{"ref": "` + testCase.ref + `"}`)
err := svc.HandleWebhook(context.Background(), app, "push", payload)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, testCase.expected, events[0].Branch)
result := webhook.DetectWebhookSource(headers)
assert.Equal(t, testCase.expected, result)
})
}
}
func TestHandleWebhookMatchingBranch(t *testing.T) {
// TestDetectEventType tests event type extraction from HTTP headers.
func TestDetectEventType(testingT *testing.T) {
testingT.Parallel()
tests := []struct {
name string
headers map[string]string
source webhook.Source
expected string
}{
{
name: "extracts Gitea event type",
headers: map[string]string{"X-Gitea-Event": "push"},
source: webhook.SourceGitea,
expected: "push",
},
{
name: "extracts GitHub event type",
headers: map[string]string{"X-GitHub-Event": "push"},
source: webhook.SourceGitHub,
expected: "push",
},
{
name: "extracts GitLab event type",
headers: map[string]string{"X-Gitlab-Event": "Push Hook"},
source: webhook.SourceGitLab,
expected: "Push Hook",
},
{
name: "returns push for unknown source",
headers: map[string]string{},
source: webhook.SourceUnknown,
expected: "push",
},
{
name: "returns push when header missing for source",
headers: map[string]string{},
source: webhook.SourceGitea,
expected: "push",
},
}
for _, testCase := range tests {
testingT.Run(testCase.name, func(t *testing.T) {
t.Parallel()
headers := http.Header{}
for key, value := range testCase.headers {
headers.Set(key, value)
}
result := webhook.DetectEventType(headers, testCase.source)
assert.Equal(t, testCase.expected, result)
})
}
}
// TestWebhookSourceString tests the String method on WebhookSource.
func TestWebhookSourceString(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
assert.Equal(t, "gitea", webhook.SourceGitea.String())
assert.Equal(t, "github", webhook.SourceGitHub.String())
assert.Equal(t, "gitlab", webhook.SourceGitLab.String())
assert.Equal(t, "unknown", webhook.SourceUnknown.String())
}
app := createTestApp(t, dbInst, "main")
// TestUnparsedURLString tests the String method on UnparsedURL.
func TestUnparsedURLString(t *testing.T) {
t.Parallel()
u := webhook.UnparsedURL("https://example.com/test")
assert.Equal(t, "https://example.com/test", u.String())
empty := webhook.UnparsedURL("")
assert.Empty(t, empty.String())
}
// TestParsePushPayloadGitea tests parsing of Gitea push payloads.
func TestParsePushPayloadGitea(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456",
"after": "abc123def456789",
"compare_url": "https://gitea.example.com/myorg/myrepo/compare/000...abc",
"repository": {
"full_name": "user/repo",
"clone_url": "https://gitea.example.com/user/repo.git",
"ssh_url": "git@gitea.example.com:user/repo.git"
"full_name": "myorg/myrepo",
"clone_url": "https://gitea.example.com/myorg/myrepo.git",
"ssh_url": "git@gitea.example.com:myorg/myrepo.git",
"html_url": "https://gitea.example.com/myorg/myrepo"
},
"pusher": {"username": "testuser", "email": "test@example.com"},
"commits": [{"id": "abc123def456", "message": "Test commit",
"author": {"name": "Test User", "email": "test@example.com"}}]
"pusher": {"username": "developer", "email": "dev@example.com"},
"commits": [
{
"id": "abc123def456789",
"url": "https://gitea.example.com/myorg/myrepo/commit/abc123def456789",
"message": "Fix bug",
"author": {"name": "Developer", "email": "dev@example.com"}
}
]
}`)
err := svc.HandleWebhook(context.Background(), app, "push", payload)
event, err := webhook.ParsePushPayload(webhook.SourceGitea, payload)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
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, webhook.SourceGitea, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "abc123def456", event.CommitSHA.String)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://gitea.example.com/myorg/myrepo/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
func TestHandleWebhookNonMatchingBranch(t *testing.T) {
// TestParsePushPayloadGitHub tests parsing of GitHub push payloads.
func TestParsePushPayloadGitHub(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
"compare": "https://github.com/myorg/myrepo/compare/000...abc",
"repository": {
"full_name": "myorg/myrepo",
"clone_url": "https://github.com/myorg/myrepo.git",
"ssh_url": "git@github.com:myorg/myrepo.git",
"html_url": "https://github.com/myorg/myrepo"
},
"pusher": {"name": "developer", "email": "dev@example.com"},
"head_commit": {
"id": "abc123def456789",
"url": "https://github.com/myorg/myrepo/commit/abc123def456789",
"message": "Fix bug"
},
"commits": [
{
"id": "abc123def456789",
"url": "https://github.com/myorg/myrepo/commit/abc123def456789",
"message": "Fix bug",
"author": {"name": "Developer", "email": "dev@example.com"}
}
]
}`)
app := createTestApp(t, dbInst, "main")
payload := []byte(`{"ref": "refs/heads/develop", "after": "def789ghi012"}`)
err := svc.HandleWebhook(context.Background(), app, "push", payload)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, 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)
assert.Equal(t, webhook.SourceGitHub, event.Source)
assert.Equal(t, "refs/heads/main", event.Ref)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "myorg/myrepo", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://github.com/myorg/myrepo.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://github.com/myorg/myrepo"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://github.com/myorg/myrepo/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
func TestHandleWebhookInvalidJSON(t *testing.T) {
// TestParsePushPayloadGitLab tests parsing of GitLab push payloads.
func TestParsePushPayloadGitLab(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
payload := []byte(`{
"ref": "refs/heads/develop",
"before": "0000000000000000000000000000000000000000",
"after": "abc123def456789",
"user_name": "developer",
"user_email": "dev@example.com",
"project": {
"path_with_namespace": "mygroup/myproject",
"git_http_url": "https://gitlab.com/mygroup/myproject.git",
"git_ssh_url": "git@gitlab.com:mygroup/myproject.git",
"web_url": "https://gitlab.com/mygroup/myproject"
},
"commits": [
{
"id": "abc123def456789",
"url": "https://gitlab.com/mygroup/myproject/-/commit/abc123def456789",
"message": "Fix bug",
"author": {"name": "Developer", "email": "dev@example.com"}
}
]
}`)
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{invalid json}`))
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, webhook.SourceGitLab, event.Source)
assert.Equal(t, "refs/heads/develop", event.Ref)
assert.Equal(t, "develop", event.Branch)
assert.Equal(t, "abc123def456789", event.After)
assert.Equal(t, "mygroup/myproject", event.RepoName)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/mygroup/myproject.git"), event.CloneURL)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/mygroup/myproject"), event.HTMLURL)
assert.Equal(t,
webhook.UnparsedURL("https://gitlab.com/mygroup/myproject/-/commit/abc123def456789"),
event.CommitURL,
)
assert.Equal(t, "developer", event.Pusher)
}
func TestHandleWebhookEmptyPayload(t *testing.T) {
// TestParsePushPayloadUnknownFallsBackToGitea tests that unknown source uses Gitea parser.
func TestParsePushPayloadUnknownFallsBackToGitea(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
payload := []byte(`{
"ref": "refs/heads/main",
"after": "abc123",
"repository": {"full_name": "user/repo"},
"pusher": {"username": "user"}
}`)
app := createTestApp(t, dbInst, "main")
err := svc.HandleWebhook(context.Background(), app, "push", []byte(`{}`))
event, err := webhook.ParsePushPayload(webhook.SourceUnknown, payload)
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)
assert.Equal(t, webhook.SourceGitea, event.Source)
assert.Equal(t, "main", event.Branch)
assert.Equal(t, "abc123", event.After)
}
// TestParsePushPayloadInvalidJSON tests that invalid JSON returns an error.
func TestParsePushPayloadInvalidJSON(t *testing.T) {
t.Parallel()
sources := []webhook.Source{
webhook.SourceGitea,
webhook.SourceGitHub,
webhook.SourceGitLab,
}
for _, source := range sources {
t.Run(source.String(), func(t *testing.T) {
t.Parallel()
_, err := webhook.ParsePushPayload(source, []byte(`{invalid json}`))
require.Error(t, err)
})
}
}
// TestParsePushPayloadEmptyPayload tests parsing of empty JSON objects.
func TestParsePushPayloadEmptyPayload(t *testing.T) {
t.Parallel()
sources := []webhook.Source{
webhook.SourceGitea,
webhook.SourceGitHub,
webhook.SourceGitLab,
}
for _, source := range sources {
t.Run(source.String(), func(t *testing.T) {
t.Parallel()
event, err := webhook.ParsePushPayload(source, []byte(`{}`))
require.NoError(t, err)
assert.Empty(t, event.Branch)
assert.Empty(t, event.After)
})
}
}
// TestGitHubCommitURLFallback tests commit URL extraction fallback paths for GitHub.
func TestGitHubCommitURLFallback(t *testing.T) {
t.Parallel()
t.Run("uses head_commit URL when available", func(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"after": "abc123",
"head_commit": {"id": "abc123", "url": "https://github.com/u/r/commit/abc123"},
"repository": {"html_url": "https://github.com/u/r"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL)
})
t.Run("falls back to commits list", func(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"after": "abc123",
"commits": [{"id": "abc123", "url": "https://github.com/u/r/commit/abc123"}],
"repository": {"html_url": "https://github.com/u/r"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL)
})
t.Run("constructs URL from repo HTML URL", func(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"after": "abc123",
"repository": {"html_url": "https://github.com/u/r"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitHub, payload)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://github.com/u/r/commit/abc123"), event.CommitURL)
})
}
// TestGitLabCommitURLFallback tests commit URL extraction fallback paths for GitLab.
func TestGitLabCommitURLFallback(t *testing.T) {
t.Parallel()
t.Run("uses commit URL from list", func(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"after": "abc123",
"project": {"web_url": "https://gitlab.com/g/p"},
"commits": [{"id": "abc123", "url": "https://gitlab.com/g/p/-/commit/abc123"}]
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL)
})
t.Run("constructs URL from project web URL", func(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"after": "abc123",
"project": {"web_url": "https://gitlab.com/g/p"}
}`)
event, err := webhook.ParsePushPayload(webhook.SourceGitLab, payload)
require.NoError(t, err)
assert.Equal(t, webhook.UnparsedURL("https://gitlab.com/g/p/-/commit/abc123"), event.CommitURL)
})
}
// TestGiteaPushPayloadParsing tests direct deserialization of the Gitea payload struct.
func TestGiteaPushPayloadParsing(testingT *testing.T) {
testingT.Parallel()
@@ -322,6 +588,354 @@ func TestGiteaPushPayloadParsing(testingT *testing.T) {
})
}
// TestGitHubPushPayloadParsing tests direct deserialization of the GitHub payload struct.
func TestGitHubPushPayloadParsing(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000",
"after": "abc123",
"compare": "https://github.com/o/r/compare/000...abc",
"repository": {
"full_name": "o/r",
"clone_url": "https://github.com/o/r.git",
"ssh_url": "git@github.com:o/r.git",
"html_url": "https://github.com/o/r"
},
"pusher": {"name": "octocat", "email": "octocat@github.com"},
"head_commit": {
"id": "abc123",
"url": "https://github.com/o/r/commit/abc123",
"message": "Update README"
},
"commits": [
{
"id": "abc123",
"url": "https://github.com/o/r/commit/abc123",
"message": "Update README",
"author": {"name": "Octocat", "email": "octocat@github.com"}
}
]
}`)
var p webhook.GitHubPushPayload
err := json.Unmarshal(payload, &p)
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", p.Ref)
assert.Equal(t, "abc123", p.After)
assert.Equal(t, "o/r", p.Repository.FullName)
assert.Equal(t, "octocat", p.Pusher.Name)
assert.NotNil(t, p.HeadCommit)
assert.Equal(t, "abc123", p.HeadCommit.ID)
assert.Len(t, p.Commits, 1)
}
// TestGitLabPushPayloadParsing tests direct deserialization of the GitLab payload struct.
func TestGitLabPushPayloadParsing(t *testing.T) {
t.Parallel()
payload := []byte(`{
"ref": "refs/heads/main",
"before": "0000000000",
"after": "abc123",
"user_name": "gitlab-user",
"user_email": "user@gitlab.com",
"project": {
"path_with_namespace": "group/project",
"git_http_url": "https://gitlab.com/group/project.git",
"git_ssh_url": "git@gitlab.com:group/project.git",
"web_url": "https://gitlab.com/group/project"
},
"commits": [
{
"id": "abc123",
"url": "https://gitlab.com/group/project/-/commit/abc123",
"message": "Fix pipeline",
"author": {"name": "GitLab User", "email": "user@gitlab.com"}
}
]
}`)
var p webhook.GitLabPushPayload
err := json.Unmarshal(payload, &p)
require.NoError(t, err)
assert.Equal(t, "refs/heads/main", p.Ref)
assert.Equal(t, "abc123", p.After)
assert.Equal(t, "group/project", p.Project.PathWithNamespace)
assert.Equal(t, "gitlab-user", p.UserName)
assert.Len(t, p.Commits, 1)
}
// TestExtractBranch tests branch extraction via HandleWebhook integration (extractBranch is unexported).
//
//nolint:funlen // table-driven test with comprehensive test cases
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, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
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, webhook.SourceGitea, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
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, webhook.SourceGitea, "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, webhook.SourceGitea, "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, webhook.SourceGitea, "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)
}
// TestHandleWebhookGitHubSource tests HandleWebhook with a GitHub push payload.
func TestHandleWebhookGitHubSource(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"after": "github123",
"repository": {
"full_name": "org/repo",
"clone_url": "https://github.com/org/repo.git",
"html_url": "https://github.com/org/repo"
},
"pusher": {"name": "octocat", "email": "octocat@github.com"},
"head_commit": {
"id": "github123",
"url": "https://github.com/org/repo/commit/github123",
"message": "Update feature"
}
}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitHub, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "github123", event.CommitSHA.String)
assert.Equal(t, "https://github.com/org/repo/commit/github123", event.CommitURL.String)
}
// TestHandleWebhookGitLabSource tests HandleWebhook with a GitLab push payload.
func TestHandleWebhookGitLabSource(t *testing.T) {
t.Parallel()
svc, dbInst, cleanup := setupTestService(t)
defer cleanup()
app := createTestApp(t, dbInst, "main")
payload := []byte(`{
"ref": "refs/heads/main",
"after": "gitlab456",
"user_name": "gitlab-dev",
"user_email": "dev@gitlab.com",
"project": {
"path_with_namespace": "group/project",
"git_http_url": "https://gitlab.com/group/project.git",
"web_url": "https://gitlab.com/group/project"
},
"commits": [
{
"id": "gitlab456",
"url": "https://gitlab.com/group/project/-/commit/gitlab456",
"message": "Deploy fix"
}
]
}`)
err := svc.HandleWebhook(
context.Background(), app, webhook.SourceGitLab, "push", payload,
)
require.NoError(t, err)
// Allow async deployment goroutine to complete before test cleanup
time.Sleep(100 * time.Millisecond)
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
require.Len(t, events, 1)
event := events[0]
assert.Equal(t, "main", event.Branch)
assert.True(t, event.Matched)
assert.Equal(t, "gitlab456", event.CommitSHA.String)
assert.Equal(t, "https://gitlab.com/group/project/-/commit/gitlab456", event.CommitURL.String)
}
// TestSetupTestService verifies the test helper creates a working test service.
func TestSetupTestService(testingT *testing.T) {
testingT.Parallel()
@@ -341,3 +955,25 @@ func TestSetupTestService(testingT *testing.T) {
require.NoError(t, err)
})
}
// TestPushEventConstruction tests that PushEvent can be constructed directly.
func TestPushEventConstruction(t *testing.T) {
t.Parallel()
event := webhook.PushEvent{
Source: webhook.SourceGitHub,
Ref: "refs/heads/main",
Before: "000",
After: "abc",
Branch: "main",
RepoName: "org/repo",
CloneURL: webhook.UnparsedURL("https://github.com/org/repo.git"),
HTMLURL: webhook.UnparsedURL("https://github.com/org/repo"),
CommitURL: webhook.UnparsedURL("https://github.com/org/repo/commit/abc"),
Pusher: "user",
}
assert.Equal(t, "main", event.Branch)
assert.Equal(t, webhook.SourceGitHub, event.Source)
assert.Equal(t, "abc", event.After)
}

View File

@@ -432,18 +432,6 @@
</div>
</div>
<!-- Backup -->
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Backup</h2>
<p class="text-sm text-gray-500 mb-3">Export this app's configuration (settings, env vars, labels, volumes, ports) as a JSON file for backup or migration.</p>
<a href="/apps/{{.App.ID}}/export" class="btn-secondary">
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Export Config
</a>
</div>
<!-- Danger Zone -->
<div class="card border-2 border-error-500/20 bg-error-50/50 p-6">
<h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2>

View File

@@ -114,6 +114,38 @@
>
</div>
<hr class="border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Resource Limits</h3>
<div class="grid grid-cols-2 gap-4">
<div class="form-group">
<label for="cpu_limit" class="label">CPU Limit (cores)</label>
<input
type="text"
id="cpu_limit"
name="cpu_limit"
value="{{if .App.CPULimit.Valid}}{{.App.CPULimit.Float64}}{{end}}"
class="input"
placeholder="e.g. 0.5, 1, 2"
>
<p class="text-sm text-gray-500 mt-1">Number of CPU cores (e.g. 0.5 = half a core)</p>
</div>
<div class="form-group">
<label for="memory_limit" class="label">Memory Limit</label>
<input
type="text"
id="memory_limit"
name="memory_limit"
value="{{if .App.MemoryLimit.Valid}}{{formatMemoryBytes .App.MemoryLimit.Int64}}{{end}}"
class="input"
placeholder="e.g. 256m, 1g"
>
<p class="text-sm text-gray-500 mt-1">Memory with unit suffix (k, m, g) or plain bytes</p>
</div>
</div>
<div class="flex justify-end gap-3 pt-4">
<a href="/apps/{{.App.ID}}" class="btn-secondary">Cancel</a>
<button type="submit" class="btn-primary">Save Changes</button>

View File

@@ -1,62 +0,0 @@
{{template "base" .}}
{{define "title"}}Import Backup - µPaaS{{end}}
{{define "content"}}
{{template "nav" .}}
<main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-6">
<a href="/" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Dashboard
</a>
</div>
{{template "alert-success" .}}
{{template "alert-error" .}}
<h1 class="text-2xl font-medium text-gray-900 mb-6">Import Backup</h1>
<div class="card p-6 mb-6">
<h2 class="section-title mb-4">Restore from Backup File</h2>
<p class="text-sm text-gray-500 mb-4">
Upload a previously exported µPaaS backup file (JSON) to restore app configurations.
New apps will be created with fresh SSH keys and webhook secrets.
Apps whose names already exist will be skipped.
</p>
<form method="POST" action="/backup/import" enctype="multipart/form-data">
{{ .CSRFField }}
<div class="mb-4">
<label for="backup_file" class="form-label">Backup File</label>
<input type="file" id="backup_file" name="backup_file" accept=".json,application/json"
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded file:border-0
file:text-sm file:font-medium
file:bg-primary-50 file:text-primary-700
hover:file:bg-primary-100
cursor-pointer">
</div>
<button type="submit" class="btn-primary">Import</button>
</form>
</div>
<div class="card p-6">
<h2 class="section-title mb-4">Export All Apps</h2>
<p class="text-sm text-gray-500 mb-4">
Download a backup of all app configurations. This includes app settings,
environment variables, labels, volumes, and port mappings.
Secrets (SSH keys, webhook tokens) are not included — they are regenerated on import.
</p>
<a href="/backup/export" class="btn-secondary">
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Export All Apps
</a>
</div>
</main>
{{end}}

View File

@@ -11,20 +11,12 @@
<div class="section-header">
<h1 class="text-2xl font-medium text-gray-900">Applications</h1>
<div class="flex gap-3">
<a href="/backup/import" class="btn-secondary">
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
Backup / Restore
</a>
<a href="/apps/new" class="btn-primary">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
New App
</a>
</div>
<a href="/apps/new" class="btn-primary">
<svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
New App
</a>
</div>
{{if .AppStats}}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"html/template"
"io"
"strconv"
"sync"
)
@@ -23,6 +24,34 @@ var (
templatesMutex sync.RWMutex
)
// templateFuncs returns the custom template function map.
func templateFuncs() template.FuncMap {
return template.FuncMap{
"formatMemoryBytes": formatMemoryBytes,
}
}
// Memory unit constants.
const (
memGigabyte = 1024 * 1024 * 1024
memMegabyte = 1024 * 1024
memKilobyte = 1024
)
// formatMemoryBytes formats bytes into a human-readable string with unit suffix.
func formatMemoryBytes(bytes int64) string {
switch {
case bytes >= memGigabyte && bytes%memGigabyte == 0:
return strconv.FormatInt(bytes/memGigabyte, 10) + "g"
case bytes >= memMegabyte && bytes%memMegabyte == 0:
return strconv.FormatInt(bytes/memMegabyte, 10) + "m"
case bytes >= memKilobyte && bytes%memKilobyte == 0:
return strconv.FormatInt(bytes/memKilobyte, 10) + "k"
default:
return strconv.FormatInt(bytes, 10)
}
}
// initTemplates parses base template and creates cloned templates for each page.
func initTemplates() {
templatesMutex.Lock()
@@ -32,8 +61,10 @@ func initTemplates() {
return
}
// Parse base template with shared components
baseTemplate = template.Must(template.ParseFS(templatesRaw, "base.html"))
// Parse base template with shared components and custom functions
baseTemplate = template.Must(
template.New("base.html").Funcs(templateFuncs()).ParseFS(templatesRaw, "base.html"),
)
// Pages that extend base
pages := []string{
@@ -45,7 +76,6 @@ func initTemplates() {
"app_edit.html",
"deployments.html",
"webhook_events.html",
"backup_import.html",
}
pageTemplates = make(map[string]*template.Template)

View File

@@ -0,0 +1,34 @@
package templates //nolint:testpackage // tests unexported formatMemoryBytes
import (
"testing"
)
func TestFormatMemoryBytes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
bytes int64
expected string
}{
{"gigabytes", 1024 * 1024 * 1024, "1g"},
{"two gigabytes", 2 * 1024 * 1024 * 1024, "2g"},
{"megabytes", 256 * 1024 * 1024, "256m"},
{"kilobytes", 512 * 1024, "512k"},
{"plain bytes", 12345, "12345"},
{"non-even megabytes", 256*1024*1024 + 1, "268435457"},
{"zero", 0, "0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := formatMemoryBytes(tt.bytes)
if got != tt.expected {
t.Errorf("formatMemoryBytes(%d) = %q, want %q", tt.bytes, got, tt.expected)
}
})
}
}