Files
upaas/internal/models/app.go
clawbot 67361419f5 feat: CPU/memory resource limits per app (#165)
## Summary

Adds configurable Docker CPU and memory resource constraints per app, closes sneak/upaas#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 sneak/upaas#72

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: sneak/upaas#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

308 lines
7.4 KiB
Go

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