From c6d20ca26ea822f5d1b88bf39fe2c7ca4cd7d94a Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 5 Apr 2026 12:38:20 +0300 Subject: [PATCH] feat: NPM access list support (global default + per-project override) --- internal/api/projects.go | 38 ++++++++++++++++++----------- internal/api/router.go | 1 + internal/api/settings.go | 44 ++++++++++++++++++++++++++++++++++ internal/deployer/bluegreen.go | 9 ++++++- internal/deployer/deployer.go | 11 ++++++++- internal/npm/client.go | 9 +++++++ internal/npm/types.go | 6 +++++ internal/store/projects.go | 22 ++++++++--------- internal/store/settings.go | 8 +++---- internal/store/store.go | 10 ++++++++ 10 files changed, 127 insertions(+), 31 deletions(-) diff --git a/internal/api/projects.go b/internal/api/projects.go index f4a6121..498005f 100644 --- a/internal/api/projects.go +++ b/internal/api/projects.go @@ -13,13 +13,14 @@ import ( // projectRequest is the expected JSON body for creating/updating a project. type projectRequest struct { - Name string `json:"name"` - Registry string `json:"registry"` - Image string `json:"image"` - Port int `json:"port"` - Healthcheck string `json:"healthcheck"` - Env string `json:"env"` - Volumes string `json:"volumes"` + Name string `json:"name"` + Registry string `json:"registry"` + Image string `json:"image"` + Port int `json:"port"` + Healthcheck string `json:"healthcheck"` + Env string `json:"env"` + Volumes string `json:"volumes"` + NpmAccessListID *int `json:"npm_access_list_id,omitempty"` } // listProjects handles GET /api/projects. @@ -55,14 +56,20 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { req.Volumes = "{}" } + npmAccessListID := 0 + if req.NpmAccessListID != nil { + npmAccessListID = *req.NpmAccessListID + } + project, err := s.store.CreateProject(store.Project{ - Name: req.Name, - Registry: req.Registry, - Image: req.Image, - Port: req.Port, - Healthcheck: req.Healthcheck, - Env: req.Env, - Volumes: req.Volumes, + Name: req.Name, + Registry: req.Registry, + Image: req.Image, + Port: req.Port, + Healthcheck: req.Healthcheck, + Env: req.Env, + Volumes: req.Volumes, + NpmAccessListID: npmAccessListID, }) if err != nil { slog.Error("failed to create project", "error", err) @@ -147,6 +154,9 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) { if req.Volumes != "" { updated.Volumes = req.Volumes } + if req.NpmAccessListID != nil { + updated.NpmAccessListID = *req.NpmAccessListID + } if err := s.store.UpdateProject(updated); err != nil { slog.Error("failed to update project", "error", err) diff --git a/internal/api/router.go b/internal/api/router.go index cdee380..33dae15 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -260,6 +260,7 @@ func (s *Server) Router() chi.Router { }) r.Get("/settings", s.getSettings) r.Get("/settings/npm-certificates", s.listNpmCertificates) + r.Get("/settings/npm-access-lists", s.listNpmAccessLists) // Volume scope metadata (read-only). r.Get("/volumes/scopes", s.listVolumeScopes) diff --git a/internal/api/settings.go b/internal/api/settings.go index 890d8df..2ea8464 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -35,6 +35,7 @@ type settingsRequest struct { DNSProvider *string `json:"dns_provider,omitempty"` CloudflareAPIToken string `json:"cloudflare_api_token"` CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"` + NpmAccessListID *int `json:"npm_access_list_id,omitempty"` NpmRemote *bool `json:"npm_remote,omitempty"` ProxyProvider *string `json:"proxy_provider,omitempty"` TraefikEntrypoint *string `json:"traefik_entrypoint,omitempty"` @@ -65,6 +66,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { "npm_email": settings.NpmEmail, "has_npm_password": settings.NpmPassword != "", "npm_remote": settings.NpmRemote, + "npm_access_list_id": settings.NpmAccessListID, "polling_interval": settings.PollingInterval, "ssl_certificate_id": settings.SSLCertificateID, "stale_threshold_days": settings.StaleThresholdDays, @@ -191,6 +193,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { if req.NpmRemote != nil { updated.NpmRemote = *req.NpmRemote } + if req.NpmAccessListID != nil { + updated.NpmAccessListID = *req.NpmAccessListID + } // Traefik provider settings. if req.TraefikEntrypoint != nil { @@ -340,6 +345,45 @@ func (s *Server) listNpmCertificates(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, wildcards) } +// listNpmAccessLists handles GET /api/settings/npm-access-lists. +// It authenticates to NPM using the stored credentials and returns all access lists. +func (s *Server) listNpmAccessLists(w http.ResponseWriter, r *http.Request) { + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + if settings.NpmURL == "" || settings.NpmEmail == "" || settings.NpmPassword == "" { + respondError(w, http.StatusBadRequest, "NPM credentials not configured") + return + } + + npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to decrypt npm password: "+err.Error()) + return + } + + client := npm.New(settings.NpmURL) + if err := client.Authenticate(r.Context(), settings.NpmEmail, npmPassword); err != nil { + respondError(w, http.StatusBadGateway, "failed to authenticate to NPM: "+err.Error()) + return + } + + lists, err := client.ListAccessLists(r.Context()) + if err != nil { + respondError(w, http.StatusBadGateway, "failed to list access lists: "+err.Error()) + return + } + + if lists == nil { + lists = []npm.AccessList{} + } + + respondJSON(w, http.StatusOK, lists) +} + // isWildcardCert returns true if any of the certificate's domain names contains "*". func isWildcardCert(cert npm.Certificate) bool { for _, d := range cert.DomainNames { diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go index 7e3fdbd..b7eff99 100644 --- a/internal/deployer/bluegreen.go +++ b/internal/deployer/bluegreen.go @@ -87,6 +87,8 @@ func (d *Deployer) blueGreenDeploy( Stage: stage.Name, InstanceID: instanceID, Mounts: mounts, + CpuLimit: stage.CpuLimit, + MemoryLimit: stage.MemoryLimit, } // Set proxy labels for providers that use Docker labels (e.g., Traefik). @@ -162,7 +164,12 @@ func (d *Deployer) blueGreenDeploy( } d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") - proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain) + accessListID := settings.NpmAccessListID + if project.NpmAccessListID > 0 { + accessListID = project.NpmAccessListID + } + + proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain, accessListID) if err != nil { return containerID, "", instanceID, fmt.Errorf("configure proxy: %w", err) } diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 2510f66..f81754e 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -325,6 +325,8 @@ func (d *Deployer) executeDeploy( Stage: stage.Name, InstanceID: instanceID, Mounts: mounts, + CpuLimit: stage.CpuLimit, + MemoryLimit: stage.MemoryLimit, } // Set proxy labels for providers that use Docker labels (e.g., Traefik). @@ -389,7 +391,12 @@ func (d *Deployer) executeDeploy( } d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") - proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain) + accessListID := settings.NpmAccessListID + if project.NpmAccessListID > 0 { + accessListID = project.NpmAccessListID + } + + proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain, accessListID) if err != nil { return containerID, proxyRouteID, instanceID, fmt.Errorf("configure proxy: %w", err) } @@ -445,6 +452,7 @@ func (d *Deployer) configureProxy( containerName string, containerPort int, subdomain string, + accessListID int, ) (string, error) { fqdn := subdomain + "." + settings.Domain @@ -470,6 +478,7 @@ func (d *Deployer) configureProxy( routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, forwardHost, forwardPort, proxy.RouteOptions{ SSLCertificateID: settings.SSLCertificateID, + AccessListID: accessListID, }) if err != nil { return "", fmt.Errorf("configure proxy route: %w", err) diff --git a/internal/npm/client.go b/internal/npm/client.go index 18cb03a..48daeef 100644 --- a/internal/npm/client.go +++ b/internal/npm/client.go @@ -175,6 +175,15 @@ func (c *Client) FindProxyHostByDomain(ctx context.Context, domain string) (Prox return ProxyHost{}, false, nil } +// ListAccessLists returns all access lists from NPM. +func (c *Client) ListAccessLists(ctx context.Context) ([]AccessList, error) { + var lists []AccessList + if err := c.doJSON(ctx, http.MethodGet, "/nginx/access-lists", nil, &lists); err != nil { + return nil, fmt.Errorf("list access lists: %w", err) + } + return lists, nil +} + // ListCertificates returns all SSL certificates from NPM. func (c *Client) ListCertificates(ctx context.Context) ([]Certificate, error) { var certs []Certificate diff --git a/internal/npm/types.go b/internal/npm/types.go index aa584e1..44ebb60 100644 --- a/internal/npm/types.go +++ b/internal/npm/types.go @@ -50,6 +50,12 @@ type ProxyHost struct { ModifiedOn string `json:"modified_on"` } +// AccessList represents an access list as returned by the NPM API. +type AccessList struct { + ID int `json:"id"` + Name string `json:"name"` +} + // Certificate represents an SSL certificate as returned by the NPM API. type Certificate struct { ID int `json:"id"` diff --git a/internal/store/projects.go b/internal/store/projects.go index fb5a422..aca4959 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -15,9 +15,9 @@ func (s *Store) CreateProject(p Project) (Project, error) { p.UpdatedAt = p.CreatedAt _, err := s.db.Exec( - `INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.CreatedAt, p.UpdatedAt, + `INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.NpmAccessListID, p.CreatedAt, p.UpdatedAt, ) if err != nil { return Project{}, fmt.Errorf("insert project: %w", err) @@ -29,9 +29,9 @@ func (s *Store) CreateProject(p Project) (Project, error) { func (s *Store) GetProjectByID(id string) (Project, error) { var p Project err := s.db.QueryRow( - `SELECT id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at + `SELECT id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at FROM projects WHERE id = ?`, id, - ).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.CreatedAt, &p.UpdatedAt) + ).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.CreatedAt, &p.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound) } @@ -44,7 +44,7 @@ func (s *Store) GetProjectByID(id string) (Project, error) { // GetAllProjects returns every project ordered by name. func (s *Store) GetAllProjects() ([]Project, error) { rows, err := s.db.Query( - `SELECT id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at + `SELECT id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at FROM projects ORDER BY name`, ) if err != nil { @@ -55,7 +55,7 @@ func (s *Store) GetAllProjects() ([]Project, error) { projects := []Project{} for rows.Next() { var p Project - if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.CreatedAt, &p.UpdatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } projects = append(projects, p) @@ -66,7 +66,7 @@ func (s *Store) GetAllProjects() ([]Project, error) { // GetProjectsByImage returns all projects using the given image, newest first. func (s *Store) GetProjectsByImage(image string) ([]Project, error) { rows, err := s.db.Query( - `SELECT id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at + `SELECT id, name, registry, image, port, healthcheck, env, volumes, npm_access_list_id, created_at, updated_at FROM projects WHERE image = ? ORDER BY created_at DESC`, image, ) if err != nil { @@ -77,7 +77,7 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) { projects := []Project{} for rows.Next() { var p Project - if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.CreatedAt, &p.UpdatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.NpmAccessListID, &p.CreatedAt, &p.UpdatedAt); err != nil { return nil, fmt.Errorf("scan project: %w", err) } projects = append(projects, p) @@ -89,9 +89,9 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) { func (s *Store) UpdateProject(p Project) error { p.UpdatedAt = Now() result, err := s.db.Exec( - `UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, updated_at=? + `UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, npm_access_list_id=?, updated_at=? WHERE id=?`, - p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.UpdatedAt, p.ID, + p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, p.NpmAccessListID, p.UpdatedAt, p.ID, ) if err != nil { return fmt.Errorf("update project: %w", err) diff --git a/internal/store/settings.go b/internal/store/settings.go index b09fccb..4f845fb 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -14,7 +14,7 @@ func (s *Store) GetSettings() (Settings, error) { base_volume_path, ssl_certificate_id, stale_threshold_days, allowed_volume_paths, wildcard_dns, dns_provider, cloudflare_api_token, cloudflare_zone_id, - npm_remote, proxy_provider, + npm_remote, npm_access_list_id, proxy_provider, traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url, backup_enabled, backup_interval_hours, backup_retention_count, updated_at @@ -24,7 +24,7 @@ func (s *Store) GetSettings() (Settings, error) { &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, &st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider, &st.CloudflareAPIToken, &st.CloudflareZoneID, - &npmRemote, &st.ProxyProvider, + &npmRemote, &st.NpmAccessListID, &st.ProxyProvider, &st.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL, &backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount, &st.UpdatedAt) @@ -59,7 +59,7 @@ func (s *Store) UpdateSettings(st Settings) error { base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, allowed_volume_paths=?, wildcard_dns=?, dns_provider=?, cloudflare_api_token=?, cloudflare_zone_id=?, - npm_remote=?, proxy_provider=?, + npm_remote=?, npm_access_list_id=?, proxy_provider=?, traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?, backup_enabled=?, backup_interval_hours=?, backup_retention_count=?, updated_at=? @@ -69,7 +69,7 @@ func (s *Store) UpdateSettings(st Settings) error { st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, st.AllowedVolumePaths, wildcardDNS, st.DNSProvider, st.CloudflareAPIToken, st.CloudflareZoneID, - npmRemote, st.ProxyProvider, + npmRemote, st.NpmAccessListID, st.ProxyProvider, st.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL, backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount, st.UpdatedAt, diff --git a/internal/store/store.go b/internal/store/store.go index dbc624b..2944e06 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -113,6 +113,12 @@ func (s *Store) runMigrations() error { `UPDATE settings SET network = 'docker-watcher' WHERE network = ''`, // NPM remote mode: forward to server_ip instead of container name. `ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`, + // Resource limits per stage. + `ALTER TABLE stages ADD COLUMN cpu_limit REAL NOT NULL DEFAULT 0`, + `ALTER TABLE stages ADD COLUMN memory_limit INTEGER NOT NULL DEFAULT 0`, + // NPM access list support (global default + per-project override). + `ALTER TABLE settings ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE projects ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`, } for _, m := range migrations { @@ -167,6 +173,7 @@ CREATE TABLE IF NOT EXISTS projects ( healthcheck TEXT NOT NULL DEFAULT '', env TEXT NOT NULL DEFAULT '{}', volumes TEXT NOT NULL DEFAULT '{}', + npm_access_list_id INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -183,6 +190,8 @@ CREATE TABLE IF NOT EXISTS stages ( promote_from TEXT NOT NULL DEFAULT '', subdomain TEXT NOT NULL DEFAULT '', notification_url TEXT NOT NULL DEFAULT '', + cpu_limit REAL NOT NULL DEFAULT 0, + memory_limit INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(project_id, name) @@ -213,6 +222,7 @@ CREATE TABLE IF NOT EXISTS settings ( base_volume_path TEXT NOT NULL DEFAULT '', ssl_certificate_id INTEGER NOT NULL DEFAULT 0, npm_remote INTEGER NOT NULL DEFAULT 0, + npm_access_list_id INTEGER NOT NULL DEFAULT 0, traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure', traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt', traefik_network TEXT NOT NULL DEFAULT '',