feat: optional NPM proxy per stage

Add enable_proxy boolean to stages (default true). When disabled,
the deployer skips NPM proxy host creation — useful for internal
services, workers, or externally-routed containers. UI shows
toggle in Add Stage form and "No Proxy" badge on stage header.
This commit is contained in:
2026-03-29 12:58:13 +03:00
parent be6ad15efc
commit e94c4f9116
8 changed files with 71 additions and 40 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm install:*)" "Bash(npm install:*)",
"Bash(go build:*)"
] ]
} }
} }
+15 -10
View File
@@ -142,24 +142,29 @@ func (d *Deployer) blueGreenDeploy(
} }
// Step 5: Swap NPM proxy to green. // Step 5: Swap NPM proxy to green.
if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil { var npmProxyID int
slog.Warn("update deploy status", "error", err) if stage.EnableProxy {
} if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil {
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
npmProxyID, err := d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
if err != nil { if err != nil {
return containerID, 0, instanceID, fmt.Errorf("configure proxy: %w", err) return containerID, 0, instanceID, fmt.Errorf("configure proxy: %w", err)
}
inst.NpmProxyID = npmProxyID
d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info")
} else {
d.logDeploy(deployID, "Blue-green: proxy skipped (disabled for this stage)", "info")
} }
inst.NpmProxyID = npmProxyID
inst.Subdomain = subdomain inst.Subdomain = subdomain
if err := d.store.UpdateInstance(inst); err != nil { if err := d.store.UpdateInstance(inst); err != nil {
slog.Warn("update instance with proxy ID", "error", err) slog.Warn("update instance with proxy ID", "error", err)
} }
d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info")
// Step 6: Stop the blue container. // Step 6: Stop the blue container.
if blueInstance != nil { if blueInstance != nil {
d.logDeploy(deployID, fmt.Sprintf("Blue-green: stopping blue instance %s (tag: %s)", blueInstance.ID, blueInstance.ImageTag), "info") d.logDeploy(deployID, fmt.Sprintf("Blue-green: stopping blue instance %s (tag: %s)", blueInstance.ID, blueInstance.ImageTag), "info")
+22 -14
View File
@@ -336,22 +336,30 @@ func (d *Deployer) executeDeploy(
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running") d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
d.logDeploy(deployID, "Container started", "info") d.logDeploy(deployID, "Container started", "info")
// Step 4: Configure NPM proxy. // Step 4: Configure NPM proxy (optional per stage).
if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil { if stage.EnableProxy {
slog.Warn("update deploy status", "error", err) if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil {
} slog.Warn("update deploy status", "error", err)
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") }
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
if err != nil { if err != nil {
return containerID, npmProxyID, instanceID, fmt.Errorf("configure proxy: %w", err) return containerID, npmProxyID, instanceID, fmt.Errorf("configure proxy: %w", err)
} }
// Update instance with NPM proxy ID. // Update instance with NPM proxy ID.
inst.NpmProxyID = npmProxyID inst.NpmProxyID = npmProxyID
inst.Subdomain = subdomain inst.Subdomain = subdomain
if err := d.store.UpdateInstance(inst); err != nil { if err := d.store.UpdateInstance(inst); err != nil {
slog.Warn("update instance with proxy ID", "error", err) slog.Warn("update instance with proxy ID", "error", err)
}
} else {
d.logDeploy(deployID, "Proxy creation skipped (disabled for this stage)", "info")
inst.Subdomain = subdomain
if err := d.store.UpdateInstance(inst); err != nil {
slog.Warn("update instance", "error", err)
}
} }
// Step 5: Health check. // Step 5: Health check.
+1
View File
@@ -23,6 +23,7 @@ type Stage struct {
AutoDeploy bool `json:"auto_deploy"` AutoDeploy bool `json:"auto_deploy"`
MaxInstances int `json:"max_instances"` MaxInstances int `json:"max_instances"`
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
EnableProxy bool `json:"enable_proxy"`
PromoteFrom string `json:"promote_from"` PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"` Subdomain string `json:"subdomain"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
+14 -13
View File
@@ -8,6 +8,8 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, created_at, updated_at`
// CreateStage inserts a new stage for a project. // CreateStage inserts a new stage for a project.
func (s *Store) CreateStage(st Stage) (Stage, error) { func (s *Store) CreateStage(st Stage) (Stage, error) {
st.ID = uuid.New().String() st.ID = uuid.New().String()
@@ -15,10 +17,9 @@ func (s *Store) CreateStage(st Stage) (Stage, error) {
st.UpdatedAt = st.CreatedAt st.UpdatedAt = st.CreatedAt
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at) `INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances, st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.CreatedAt, st.UpdatedAt, BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.CreatedAt, st.UpdatedAt,
) )
if err != nil { if err != nil {
return Stage{}, fmt.Errorf("insert stage: %w", err) return Stage{}, fmt.Errorf("insert stage: %w", err)
@@ -29,8 +30,7 @@ func (s *Store) CreateStage(st Stage) (Stage, error) {
// GetStagesByProjectID returns all stages for a given project. // GetStagesByProjectID returns all stages for a given project.
func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) { func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at `SELECT `+stageColumns+` FROM stages WHERE project_id = ? ORDER BY name`, projectID,
FROM stages WHERE project_id = ? ORDER BY name`, projectID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("query stages: %w", err) return nil, fmt.Errorf("query stages: %w", err)
@@ -51,12 +51,11 @@ func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) {
// GetStageByID returns a single stage by its ID. // GetStageByID returns a single stage by its ID.
func (s *Store) GetStageByID(id string) (Stage, error) { func (s *Store) GetStageByID(id string) (Stage, error) {
var st Stage var st Stage
var autoDeploy, confirm int var autoDeploy, confirm, enableProxy int
err := s.db.QueryRow( err := s.db.QueryRow(
`SELECT id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at `SELECT `+stageColumns+` FROM stages WHERE id = ?`, id,
FROM stages WHERE id = ?`, id,
).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances, ).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &st.PromoteFrom, &st.Subdomain, &st.CreatedAt, &st.UpdatedAt) &confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.CreatedAt, &st.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound) return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound)
} }
@@ -65,6 +64,7 @@ func (s *Store) GetStageByID(id string) (Stage, error) {
} }
st.AutoDeploy = autoDeploy != 0 st.AutoDeploy = autoDeploy != 0
st.Confirm = confirm != 0 st.Confirm = confirm != 0
st.EnableProxy = enableProxy != 0
return st, nil return st, nil
} }
@@ -72,10 +72,10 @@ func (s *Store) GetStageByID(id string) (Stage, error) {
func (s *Store) UpdateStage(st Stage) error { func (s *Store) UpdateStage(st Stage) error {
st.UpdatedAt = Now() st.UpdatedAt = Now()
result, err := s.db.Exec( result, err := s.db.Exec(
`UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, promote_from=?, subdomain=?, updated_at=? `UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, enable_proxy=?, promote_from=?, subdomain=?, updated_at=?
WHERE id=?`, WHERE id=?`,
st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.UpdatedAt, st.ID, BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.UpdatedAt, st.ID,
) )
if err != nil { if err != nil {
return fmt.Errorf("update stage: %w", err) return fmt.Errorf("update stage: %w", err)
@@ -111,13 +111,14 @@ func BoolToInt(b bool) int {
// scanStage scans a stage row from a *sql.Rows cursor. // scanStage scans a stage row from a *sql.Rows cursor.
func scanStage(rows *sql.Rows) (Stage, error) { func scanStage(rows *sql.Rows) (Stage, error) {
var st Stage var st Stage
var autoDeploy, confirm int var autoDeploy, confirm, enableProxy int
err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances, err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &st.PromoteFrom, &st.Subdomain, &st.CreatedAt, &st.UpdatedAt) &confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.CreatedAt, &st.UpdatedAt)
if err != nil { if err != nil {
return Stage{}, fmt.Errorf("scan stage: %w", err) return Stage{}, fmt.Errorf("scan stage: %w", err)
} }
st.AutoDeploy = autoDeploy != 0 st.AutoDeploy = autoDeploy != 0
st.Confirm = confirm != 0 st.Confirm = confirm != 0
st.EnableProxy = enableProxy != 0
return st, nil return st, nil
} }
+3
View File
@@ -77,6 +77,8 @@ func (s *Store) runMigrations() error {
`ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`, `ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
// Add base_volume_path to settings (2026-03-28). // Add base_volume_path to settings (2026-03-28).
`ALTER TABLE settings ADD COLUMN base_volume_path TEXT NOT NULL DEFAULT ''`, `ALTER TABLE settings ADD COLUMN base_volume_path TEXT NOT NULL DEFAULT ''`,
// Add enable_proxy to stages (2026-03-29). Default true for backwards compat.
`ALTER TABLE stages ADD COLUMN enable_proxy INTEGER NOT NULL DEFAULT 1`,
} }
for _, m := range migrations { for _, m := range migrations {
@@ -126,6 +128,7 @@ CREATE TABLE IF NOT EXISTS stages (
auto_deploy INTEGER NOT NULL DEFAULT 0, auto_deploy INTEGER NOT NULL DEFAULT 0,
max_instances INTEGER NOT NULL DEFAULT 1, max_instances INTEGER NOT NULL DEFAULT 1,
confirm INTEGER NOT NULL DEFAULT 0, confirm INTEGER NOT NULL DEFAULT 0,
enable_proxy INTEGER NOT NULL DEFAULT 1,
promote_from TEXT NOT NULL DEFAULT '', promote_from TEXT NOT NULL DEFAULT '',
subdomain TEXT NOT NULL DEFAULT '', subdomain TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
+1
View File
@@ -21,6 +21,7 @@ export interface Stage {
auto_deploy: boolean; auto_deploy: boolean;
max_instances: number; max_instances: number;
confirm: boolean; confirm: boolean;
enable_proxy: boolean;
promote_from: string; promote_from: string;
subdomain: string; subdomain: string;
created_at: string; created_at: string;
+13 -2
View File
@@ -32,6 +32,7 @@
let stageName = $state(''); let stageName = $state('');
let stageTagPattern = $state('*'); let stageTagPattern = $state('*');
let stageAutoDeploy = $state(true); let stageAutoDeploy = $state(true);
let stageEnableProxy = $state(true);
let stageMaxInstances = $state('1'); let stageMaxInstances = $state('1');
let addingStage = $state(false); let addingStage = $state(false);
@@ -43,10 +44,11 @@
name: stageName.trim(), name: stageName.trim(),
tag_pattern: stageTagPattern.trim() || '*', tag_pattern: stageTagPattern.trim() || '*',
auto_deploy: stageAutoDeploy, auto_deploy: stageAutoDeploy,
enable_proxy: stageEnableProxy,
max_instances: parseInt(stageMaxInstances) || 1, max_instances: parseInt(stageMaxInstances) || 1,
}); });
toasts.success(`Stage "${stageName}" created`); toasts.success(`Stage "${stageName}" created`);
stageName = ''; stageTagPattern = '*'; stageAutoDeploy = true; stageMaxInstances = '1'; stageName = ''; stageTagPattern = '*'; stageAutoDeploy = true; stageEnableProxy = true; stageMaxInstances = '1';
showAddStage = false; showAddStage = false;
await loadProject(); await loadProject();
} catch (e) { } catch (e) {
@@ -338,7 +340,7 @@
{#if showAddStage} {#if showAddStage}
<div class="mt-3 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 animate-scale-in"> <div class="mt-3 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 animate-scale-in">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-5">
<FormField label="Name *" name="stageName" bind:value={stageName} placeholder="dev" /> <FormField label="Name *" name="stageName" bind:value={stageName} placeholder="dev" />
<FormField label="Tag Pattern" name="stagePattern" bind:value={stageTagPattern} placeholder="dev-*" helpText="Glob pattern (e.g., dev-*, v*)" /> <FormField label="Tag Pattern" name="stagePattern" bind:value={stageTagPattern} placeholder="dev-*" helpText="Glob pattern (e.g., dev-*, v*)" />
<FormField label="Max Instances" name="stageMax" type="number" bind:value={stageMaxInstances} /> <FormField label="Max Instances" name="stageMax" type="number" bind:value={stageMaxInstances} />
@@ -348,6 +350,12 @@
<ToggleSwitch bind:checked={stageAutoDeploy} label="Auto Deploy" /> <ToggleSwitch bind:checked={stageAutoDeploy} label="Auto Deploy" />
</div> </div>
</div> </div>
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium text-[var(--text-primary)]">NPM Proxy</label>
<div class="flex items-center h-[38px]">
<ToggleSwitch bind:checked={stageEnableProxy} label="NPM Proxy" />
</div>
</div>
</div> </div>
<div class="mt-3 flex justify-end"> <div class="mt-3 flex justify-end">
<button <button
@@ -382,6 +390,9 @@
{#if stage.confirm} {#if stage.confirm}
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('projectDetail.requiresConfirm')}</span> <span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('projectDetail.requiresConfirm')}</span>
{/if} {/if}
{#if !stage.enable_proxy}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">No Proxy</span>
{/if}
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="text-xs text-[var(--text-tertiary)]"> <span class="text-xs text-[var(--text-tertiary)]">