feat: add CPU and memory resource limits per app
All checks were successful
Check / check (pull_request) Successful in 3m24s
All checks were successful
Check / check (pull_request) Successful in 3m24s
- Add cpu_limit (REAL) and memory_limit (INTEGER) columns to apps table via migration 007 - Add CPULimit and MemoryLimit fields to App model with full CRUD support - Add resource limits fields to app edit form with human-friendly memory input (e.g. 256m, 1g, 512k) - Pass CPU and memory limits to Docker container creation via NanoCPUs and Memory host config fields - Extract Docker container creation helpers (buildEnvSlice, buildMounts, buildResources) for cleaner code - Add formatMemoryBytes template function for display - Add comprehensive tests for parsing, formatting, model persistence, and container options
This commit is contained in:
@@ -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 {
|
||||
|
||||
195
internal/handlers/resource_limits_test.go
Normal file
195
internal/handlers/resource_limits_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user