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:
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm install:*)"
|
"Bash(npm install:*)",
|
||||||
|
"Bash(go build:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)]">
|
||||||
|
|||||||
Reference in New Issue
Block a user