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": {
"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.
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", "")
var npmProxyID int
if stage.EnableProxy {
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", "")
npmProxyID, err := d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
if err != nil {
return containerID, 0, instanceID, fmt.Errorf("configure proxy: %w", err)
npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
if err != nil {
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
if err := d.store.UpdateInstance(inst); err != nil {
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.
if blueInstance != nil {
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.logDeploy(deployID, "Container started", "info")
// Step 4: Configure NPM proxy.
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", "")
// Step 4: Configure NPM proxy (optional per stage).
if stage.EnableProxy {
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", "")
npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
if err != nil {
return containerID, npmProxyID, instanceID, fmt.Errorf("configure proxy: %w", err)
}
npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
if err != nil {
return containerID, npmProxyID, instanceID, fmt.Errorf("configure proxy: %w", err)
}
// Update instance with NPM proxy ID.
inst.NpmProxyID = npmProxyID
inst.Subdomain = subdomain
if err := d.store.UpdateInstance(inst); err != nil {
slog.Warn("update instance with proxy ID", "error", err)
// Update instance with NPM proxy ID.
inst.NpmProxyID = npmProxyID
inst.Subdomain = subdomain
if err := d.store.UpdateInstance(inst); err != nil {
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.
+1
View File
@@ -23,6 +23,7 @@ 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"`
CreatedAt string `json:"created_at"`
+14 -13
View File
@@ -8,6 +8,8 @@ import (
"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.
func (s *Store) CreateStage(st Stage) (Stage, error) {
st.ID = uuid.New().String()
@@ -15,10 +17,9 @@ func (s *Store) CreateStage(st Stage) (Stage, error) {
st.UpdatedAt = st.CreatedAt
_, 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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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 {
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.
func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) {
rows, err := s.db.Query(
`SELECT id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at
FROM stages WHERE project_id = ? ORDER BY name`, projectID,
`SELECT `+stageColumns+` FROM stages WHERE project_id = ? ORDER BY name`, projectID,
)
if err != nil {
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.
func (s *Store) GetStageByID(id string) (Stage, error) {
var st Stage
var autoDeploy, confirm int
var autoDeploy, confirm, enableProxy int
err := s.db.QueryRow(
`SELECT id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at
FROM stages WHERE id = ?`, id,
`SELECT `+stageColumns+` FROM stages WHERE id = ?`, id,
).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) {
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.Confirm = confirm != 0
st.EnableProxy = enableProxy != 0
return st, nil
}
@@ -72,10 +72,10 @@ 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=?, 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=?`,
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 {
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.
func scanStage(rows *sql.Rows) (Stage, error) {
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,
&confirm, &st.PromoteFrom, &st.Subdomain, &st.CreatedAt, &st.UpdatedAt)
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.CreatedAt, &st.UpdatedAt)
if err != nil {
return Stage{}, fmt.Errorf("scan stage: %w", err)
}
st.AutoDeploy = autoDeploy != 0
st.Confirm = confirm != 0
st.EnableProxy = enableProxy != 0
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 ''`,
// Add base_volume_path to settings (2026-03-28).
`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 {
@@ -126,6 +128,7 @@ CREATE TABLE IF NOT EXISTS stages (
auto_deploy INTEGER NOT NULL DEFAULT 0,
max_instances INTEGER NOT NULL DEFAULT 1,
confirm INTEGER NOT NULL DEFAULT 0,
enable_proxy INTEGER NOT NULL DEFAULT 1,
promote_from TEXT NOT NULL DEFAULT '',
subdomain TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
+1
View File
@@ -21,6 +21,7 @@ export interface Stage {
auto_deploy: boolean;
max_instances: number;
confirm: boolean;
enable_proxy: boolean;
promote_from: string;
subdomain: string;
created_at: string;
+13 -2
View File
@@ -32,6 +32,7 @@
let stageName = $state('');
let stageTagPattern = $state('*');
let stageAutoDeploy = $state(true);
let stageEnableProxy = $state(true);
let stageMaxInstances = $state('1');
let addingStage = $state(false);
@@ -43,10 +44,11 @@
name: stageName.trim(),
tag_pattern: stageTagPattern.trim() || '*',
auto_deploy: stageAutoDeploy,
enable_proxy: stageEnableProxy,
max_instances: parseInt(stageMaxInstances) || 1,
});
toasts.success(`Stage "${stageName}" created`);
stageName = ''; stageTagPattern = '*'; stageAutoDeploy = true; stageMaxInstances = '1';
stageName = ''; stageTagPattern = '*'; stageAutoDeploy = true; stageEnableProxy = true; stageMaxInstances = '1';
showAddStage = false;
await loadProject();
} catch (e) {
@@ -338,7 +340,7 @@
{#if showAddStage}
<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="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} />
@@ -348,6 +350,12 @@
<ToggleSwitch bind:checked={stageAutoDeploy} label="Auto Deploy" />
</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 class="mt-3 flex justify-end">
<button
@@ -382,6 +390,9 @@
{#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>
{/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 class="flex items-center gap-3">
<span class="text-xs text-[var(--text-tertiary)]">