feat: CPU/RAM limits per stage, NPM access list (global + per-project)

Resource limits:
- Add cpu_limit (cores) and memory_limit (MB) fields to Stage model
- Pass limits to Docker container via NanoCPUs and Memory in HostConfig
- Add CPU/Memory fields to stage creation form in project detail
- 0 = unlimited (default)

NPM access list:
- Add npm_access_list_id to Settings (global default) and Project (per-project override)
- Per-project overrides global when > 0
- NPM provider passes access_list_id when configuring proxy hosts
- Add GET /api/settings/npm-access-lists endpoint to list NPM access lists
- Add access list picker on NPM settings page (global)
- Add access list ID field on project edit form (per-project)
- DB migrations for all new columns
This commit is contained in:
2026-04-05 12:44:26 +03:00
parent c6d20ca26e
commit 7550fe9e32
12 changed files with 217 additions and 38 deletions
+27 -8
View File
@@ -13,14 +13,16 @@ import (
// stageRequest is the expected JSON body for creating/updating a stage.
type stageRequest struct {
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy *bool `json:"auto_deploy"`
MaxInstances *int `json:"max_instances"`
Confirm *bool `json:"confirm"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
Name string `json:"name"`
TagPattern string `json:"tag_pattern"`
AutoDeploy *bool `json:"auto_deploy"`
MaxInstances *int `json:"max_instances"`
Confirm *bool `json:"confirm"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
CpuLimit *float64 `json:"cpu_limit"`
MemoryLimit *int `json:"memory_limit"`
}
// createStage handles POST /api/projects/{id}/stages.
@@ -64,6 +66,15 @@ func (s *Server) createStage(w http.ResponseWriter, r *http.Request) {
confirm = *req.Confirm
}
var cpuLimit float64
if req.CpuLimit != nil {
cpuLimit = *req.CpuLimit
}
var memoryLimit int
if req.MemoryLimit != nil {
memoryLimit = *req.MemoryLimit
}
stage, err := s.store.CreateStage(store.Stage{
ProjectID: projectID,
Name: req.Name,
@@ -74,6 +85,8 @@ func (s *Server) createStage(w http.ResponseWriter, r *http.Request) {
PromoteFrom: req.PromoteFrom,
Subdomain: req.Subdomain,
NotificationURL: req.NotificationURL,
CpuLimit: cpuLimit,
MemoryLimit: memoryLimit,
})
if err != nil {
slog.Error("failed to create stage", "error", err)
@@ -131,6 +144,12 @@ func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) {
updated.PromoteFrom = req.PromoteFrom
updated.Subdomain = req.Subdomain
updated.NotificationURL = req.NotificationURL
if req.CpuLimit != nil {
updated.CpuLimit = *req.CpuLimit
}
if req.MemoryLimit != nil {
updated.MemoryLimit = *req.MemoryLimit
}
if err := s.store.UpdateStage(updated); err != nil {
slog.Error("failed to update stage", "error", err)
+20
View File
@@ -49,6 +49,12 @@ type ContainerConfig struct {
// Mounts is a list of bind mounts to attach to the container.
Mounts []mount.Mount
// CpuLimit is the CPU limit in cores (e.g., 0.5, 1, 2). 0 = unlimited.
CpuLimit float64
// MemoryLimit is the memory limit in megabytes. 0 = unlimited.
MemoryLimit int
}
// sanitizeTag replaces characters that are invalid in Docker container names
@@ -101,6 +107,7 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri
PortBindings: portBindings,
RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyDisabled},
Mounts: cfg.Mounts,
Resources: containerResources(cfg.CpuLimit, cfg.MemoryLimit),
}
// Attach to network at creation time if specified.
@@ -128,6 +135,19 @@ func (c *Client) CreateContainer(ctx context.Context, cfg ContainerConfig) (stri
return resp.ID, nil
}
// containerResources builds Docker resource constraints from CPU cores and memory MB.
func containerResources(cpuLimit float64, memoryLimitMB int) container.Resources {
r := container.Resources{}
if cpuLimit > 0 {
// NanoCPUs is in units of 1e-9 CPUs. 1 core = 1e9 nanoCPUs.
r.NanoCPUs = int64(cpuLimit * 1e9)
}
if memoryLimitMB > 0 {
r.Memory = int64(memoryLimitMB) * 1024 * 1024
}
return r
}
// StartContainer starts a stopped container.
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
if _, err := c.api.ContainerStart(ctx, containerID, client.ContainerStartOptions{}); err != nil {
+1
View File
@@ -58,6 +58,7 @@ func (p *NpmProvider) ConfigureRoute(ctx context.Context, domain, targetHost str
BlockExploits: true,
AllowWebsocket: true,
HTTP2Support: true,
AccessListID: opts.AccessListID,
Meta: npm.Meta{},
Locations: []any{},
}
+1
View File
@@ -5,6 +5,7 @@ import "context"
// RouteOptions holds optional configuration for a proxy route.
type RouteOptions struct {
SSLCertificateID int
AccessListID int // NPM access list ID for authentication, 0 = public
ForwardScheme string // "http" or "https", defaults to "http"
}
+12 -8
View File
@@ -8,10 +8,11 @@ type Project struct {
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
Env string `json:"env"` // JSON-encoded map
Volumes string `json:"volumes"` // JSON-encoded map
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Env string `json:"env"` // JSON-encoded map
Volumes string `json:"volumes"` // JSON-encoded map
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Stage represents a deployment stage within a project (e.g. dev, rel, prod).
@@ -23,10 +24,12 @@ type Stage struct {
AutoDeploy bool `json:"auto_deploy"`
MaxInstances int `json:"max_instances"`
Confirm bool `json:"confirm"`
EnableProxy bool `json:"enable_proxy"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
EnableProxy bool `json:"enable_proxy"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
CpuLimit float64 `json:"cpu_limit"` // CPU cores (e.g., 0.5, 1, 2), 0 = unlimited
MemoryLimit int `json:"memory_limit"` // megabytes, 0 = unlimited
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -64,6 +67,7 @@ type Settings struct {
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID string `json:"cloudflare_zone_id"`
NpmRemote bool `json:"npm_remote"`
NpmAccessListID int `json:"npm_access_list_id"`
ProxyProvider string `json:"proxy_provider"`
TraefikEntrypoint string `json:"traefik_entrypoint"`
TraefikCertResolver string `json:"traefik_cert_resolver"`
+11 -7
View File
@@ -8,7 +8,7 @@ import (
"github.com/google/uuid"
)
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, created_at, updated_at`
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, cpu_limit, memory_limit, created_at, updated_at`
// CreateStage inserts a new stage for a project.
func (s *Store) CreateStage(st Stage) (Stage, error) {
@@ -17,9 +17,10 @@ func (s *Store) CreateStage(st Stage) (Stage, error) {
st.UpdatedAt = st.CreatedAt
_, err := s.db.Exec(
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL, st.CreatedAt, st.UpdatedAt,
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
st.CpuLimit, st.MemoryLimit, st.CreatedAt, st.UpdatedAt,
)
if err != nil {
return Stage{}, fmt.Errorf("insert stage: %w", err)
@@ -55,7 +56,8 @@ func (s *Store) GetStageByID(id string) (Stage, error) {
err := s.db.QueryRow(
`SELECT `+stageColumns+` FROM stages WHERE id = ?`, id,
).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL, &st.CreatedAt, &st.UpdatedAt)
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound)
}
@@ -72,10 +74,11 @@ func (s *Store) GetStageByID(id string) (Stage, error) {
func (s *Store) UpdateStage(st Stage) error {
st.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, enable_proxy=?, promote_from=?, subdomain=?, notification_url=?, updated_at=?
`UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, enable_proxy=?, promote_from=?, subdomain=?, notification_url=?, cpu_limit=?, memory_limit=?, updated_at=?
WHERE id=?`,
st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL, st.UpdatedAt, st.ID,
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
st.CpuLimit, st.MemoryLimit, st.UpdatedAt, st.ID,
)
if err != nil {
return fmt.Errorf("update stage: %w", err)
@@ -113,7 +116,8 @@ func scanStage(rows *sql.Rows) (Stage, error) {
var st Stage
var autoDeploy, confirm, enableProxy int
err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL, &st.CreatedAt, &st.UpdatedAt)
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
if err != nil {
return Stage{}, fmt.Errorf("scan stage: %w", err)
}