Files
upaas/templates/templates.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

131 lines
3.2 KiB
Go

// Package templates provides HTML template handling.
package templates
import (
"embed"
"fmt"
"html/template"
"io"
"strconv"
"sync"
)
//go:embed *.html
var templatesRaw embed.FS
// Template cache variables are global to enable efficient template reuse
// across requests without re-parsing on each call.
var (
//nolint:gochecknoglobals // singleton pattern for template cache
baseTemplate *template.Template
//nolint:gochecknoglobals // singleton pattern for template cache
pageTemplates map[string]*template.Template
//nolint:gochecknoglobals // protects template cache access
templatesMutex sync.RWMutex
)
// 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()
defer templatesMutex.Unlock()
if pageTemplates != nil {
return
}
// 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{
"setup.html",
"login.html",
"dashboard.html",
"app_new.html",
"app_detail.html",
"app_edit.html",
"deployments.html",
"webhook_events.html",
}
pageTemplates = make(map[string]*template.Template)
for _, page := range pages {
// Clone base template and parse page-specific template into it
clone := template.Must(baseTemplate.Clone())
pageTemplates[page] = template.Must(clone.ParseFS(templatesRaw, page))
}
}
// GetParsed returns a template executor that routes to the correct page template.
func GetParsed() *TemplateExecutor {
initTemplates()
return &TemplateExecutor{}
}
// TemplateExecutor executes templates using the correct cloned template set.
type TemplateExecutor struct{}
// ExecuteTemplate executes the named template with the given data.
func (t *TemplateExecutor) ExecuteTemplate(
writer io.Writer,
name string,
data any,
) error {
templatesMutex.RLock()
tmpl, ok := pageTemplates[name]
templatesMutex.RUnlock()
if !ok {
// Fallback for non-page templates
err := baseTemplate.ExecuteTemplate(writer, name, data)
if err != nil {
return fmt.Errorf("execute base template %s: %w", name, err)
}
return nil
}
// Execute the "base" template from the cloned set
// (which has page-specific overrides)
err := tmpl.ExecuteTemplate(writer, "base", data)
if err != nil {
return fmt.Errorf("execute page template %s: %w", name, err)
}
return nil
}