diff --git a/web/src/lib/components/workload/DeployStrategyField.svelte b/web/src/lib/components/workload/DeployStrategyField.svelte
new file mode 100644
index 0000000..2cad36b
--- /dev/null
+++ b/web/src/lib/components/workload/DeployStrategyField.svelte
@@ -0,0 +1,468 @@
+
+
+
+ {$t('apps.new.deployStrategy.denoCaveat')}
{$t('apps.new.dockerfileFoot')}
diff --git a/web/src/lib/components/workload/ImageSourceForm.svelte b/web/src/lib/components/workload/ImageSourceForm.svelte index da58168..819c82f 100644 --- a/web/src/lib/components/workload/ImageSourceForm.svelte +++ b/web/src/lib/components/workload/ImageSourceForm.svelte @@ -26,6 +26,7 @@ import * as api from '$lib/api'; import { IconSearch, IconLoader } from '$lib/components/icons'; import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte'; + import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte'; import { t } from '$lib/i18n'; interface Props { @@ -388,6 +389,7 @@{$t('apps.new.imageMaxHint')}
+{$t('apps.new.imageFoot')}
diff --git a/web/src/lib/components/workload/StaticSourceForm.svelte b/web/src/lib/components/workload/StaticSourceForm.svelte index 4fa8a49..de5c851 100644 --- a/web/src/lib/components/workload/StaticSourceForm.svelte +++ b/web/src/lib/components/workload/StaticSourceForm.svelte @@ -15,6 +15,7 @@ import type { FolderEntry } from '$lib/api'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte'; + import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte'; import { t } from '$lib/i18n'; interface Props { @@ -118,6 +119,12 @@ {@html $t('apps.new.sourceReportCommitStatusDesc')} +{$t('apps.new.staticFoot')}
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index f3d74ef..2d65bdd 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1501,6 +1501,15 @@ "staticRenderMarkdownDesc": "— auto-render.md files as HTML pages.",
"sourceReportCommitStatus": "Report commit status",
"sourceReportCommitStatusDesc": "— report deploy status back to the Git provider as a commit status on the deployed commit.",
+ "deployStrategy": {
+ "label": "Deploy strategy",
+ "recreateName": "Recreate",
+ "recreateDesc": "Stop the old container, then start the new one. A brief window of downtime during the swap.",
+ "blueGreenName": "Zero-downtime",
+ "blueGreenDesc": "Start the new container, health-check it, then switch traffic and retire the old one. No downtime.",
+ "defaultBadge": "default",
+ "denoCaveat": "Deno sites with persistent storage fall back to recreate to avoid two writers on the same volume."
+ },
"staticFoot": "The webhook secret for git push triggers lives on the workload's Webhook panel after creation.",
"staticDetectProvider": "Detect",
"staticDetectedOk": "Detected: {provider}",
diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json
index 664907f..52e1f1a 100644
--- a/web/src/lib/i18n/ru.json
+++ b/web/src/lib/i18n/ru.json
@@ -1501,6 +1501,15 @@
"staticRenderMarkdownDesc": "— автоматически отдавать .md файлы как HTML-страницы.",
"sourceReportCommitStatus": "Отправлять статус коммита",
"sourceReportCommitStatusDesc": "— отправлять статус деплоя обратно в Git-провайдер как статус коммита для развёрнутого коммита.",
+ "deployStrategy": {
+ "label": "Стратегия деплоя",
+ "recreateName": "Пересоздание",
+ "recreateDesc": "Остановить старый контейнер, затем запустить новый. Короткий простой во время замены.",
+ "blueGreenName": "Без простоя",
+ "blueGreenDesc": "Запустить новый контейнер, проверить health-check, переключить трафик и убрать старый. Без простоя.",
+ "defaultBadge": "по умолчанию",
+ "denoCaveat": "Deno-сайты с постоянным хранилищем используют пересоздание, чтобы избежать двух писателей на одном томе."
+ },
"staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.",
"staticDetectProvider": "Определить",
"staticDetectedOk": "Определено: {provider}",
diff --git a/web/src/lib/workload/sourceForms.test.ts b/web/src/lib/workload/sourceForms.test.ts
index c9af273..85a44a6 100644
--- a/web/src/lib/workload/sourceForms.test.ts
+++ b/web/src/lib/workload/sourceForms.test.ts
@@ -46,7 +46,8 @@ describe('image source', () => {
registryName: 'docker.io',
cpuLimit: 2,
memoryLimit: 512,
- maxInstances: 3
+ maxInstances: 3,
+ deployStrategy: ''
});
});
@@ -294,3 +295,56 @@ describe('dockerfile source', () => {
expect(isDockerfileValid({ ...base, port: -1 })).toBe(false);
});
});
+
+describe('deploy_strategy (cross-source)', () => {
+ it('seeds recognized strategies and drops junk to ""', () => {
+ expect(seedImageState(JSON.stringify({ image: 'x', deploy_strategy: 'recreate' })).deployStrategy).toBe('recreate');
+ expect(seedImageState(JSON.stringify({ image: 'x', deploy_strategy: 'blue-green' })).deployStrategy).toBe('blue-green');
+ expect(seedImageState(JSON.stringify({ image: 'x', deploy_strategy: 'rolling' })).deployStrategy).toBe('');
+ expect(seedDockerfileState(JSON.stringify({ deploy_strategy: 'blue-green' })).deployStrategy).toBe('blue-green');
+ expect(seedStaticState(JSON.stringify({ deploy_strategy: 'recreate' })).deployStrategy).toBe('recreate');
+ expect(seedStaticState(JSON.stringify({ deploy_strategy: 'nope' })).deployStrategy).toBe('');
+ });
+
+ it('omits deploy_strategy when empty so existing configs stay byte-identical', () => {
+ expect('deploy_strategy' in imageToConfig(emptyImageState(), '{}')).toBe(false);
+ expect('deploy_strategy' in dockerfileToConfig(emptyDockerfileState(), '{}')).toBe(false);
+ expect('deploy_strategy' in staticToConfig(emptyStaticState(), '{}')).toBe(false);
+ });
+
+ it('emits deploy_strategy at the end of the owned block when set', () => {
+ const img = imageToConfig({ ...emptyImageState(), deployStrategy: 'recreate' }, '{}');
+ expect(img.deploy_strategy).toBe('recreate');
+ expect(Object.keys(img).at(-1)).toBe('deploy_strategy');
+
+ const df = dockerfileToConfig({ ...emptyDockerfileState(), deployStrategy: 'blue-green' }, '{}');
+ expect(df.deploy_strategy).toBe('blue-green');
+ expect(Object.keys(df).at(-1)).toBe('deploy_strategy');
+
+ const st = staticToConfig({ ...emptyStaticState(), deployStrategy: 'blue-green' }, '{}');
+ expect(st.deploy_strategy).toBe('blue-green');
+ expect(Object.keys(st).at(-1)).toBe('deploy_strategy');
+ });
+
+ it('dockerfile owns deploy_strategy: form value wins, stale value scrubbed', () => {
+ // Form value overrides an existing stored strategy (owned key).
+ const overridden = dockerfileToConfig(
+ { ...emptyDockerfileState(), deployStrategy: 'blue-green' },
+ JSON.stringify({ deploy_strategy: 'recreate', healthcheck: '/up' })
+ );
+ expect(overridden.deploy_strategy).toBe('blue-green');
+ expect(overridden.healthcheck).toBe('/up'); // unknown key still preserved
+
+ // Clearing back to default scrubs the stored key (no orphan recreate).
+ const cleared = dockerfileToConfig(
+ emptyDockerfileState(),
+ JSON.stringify({ deploy_strategy: 'blue-green' })
+ );
+ expect('deploy_strategy' in cleared).toBe(false);
+ });
+
+ it('round-trips an explicit strategy through serialize -> seed', () => {
+ const s = seedImageState(JSON.stringify({ image: 'app', deploy_strategy: 'recreate' }));
+ expect(seedImageState(stringifyConfig(imageToConfig(s, '{}'))).deployStrategy).toBe('recreate');
+ });
+});
diff --git a/web/src/lib/workload/sourceForms.ts b/web/src/lib/workload/sourceForms.ts
index e715325..fbe5e02 100644
--- a/web/src/lib/workload/sourceForms.ts
+++ b/web/src/lib/workload/sourceForms.ts
@@ -21,6 +21,18 @@
export type GitProvider = 'gitea' | 'github' | 'gitlab';
+/**
+ * Per-workload deploy strategy.
+ *
+ * `''` means "use the source's historical default" (image → blue-green,
+ * dockerfile / static → recreate). It is the canonical value we persist
+ * whenever the operator has NOT deviated from that default, so existing
+ * `source_config` blobs stay byte-identical and the key is only ever
+ * written when it actually differs. The backend's `effectiveStrategy()`
+ * resolves `''` to the same per-source default, so the two are equivalent.
+ */
+export type DeployStrategy = '' | 'recreate' | 'blue-green';
+
/** Image source: deploy a pre-built image from a registry. */
export interface ImageFormState {
ref: string;
@@ -31,6 +43,8 @@ export interface ImageFormState {
cpuLimit: number;
memoryLimit: number;
maxInstances: number;
+ /** "" = source default (blue-green for image); else explicit. */
+ deployStrategy: DeployStrategy;
}
/** Compose source: a docker-compose stack. */
@@ -61,6 +75,8 @@ export interface StaticFormState extends GitSourceState {
renderMarkdown: boolean;
/** Report deploy outcome back to the git provider as a commit status. */
reportCommitStatus: boolean;
+ /** "" = source default (recreate for static); else explicit. */
+ deployStrategy: DeployStrategy;
}
/** Dockerfile source: build an image from a Dockerfile in a repo. */
@@ -70,6 +86,8 @@ export interface DockerfileFormState extends GitSourceState {
port: number;
/** Report deploy outcome back to the git provider as a commit status. */
reportCommitStatus: boolean;
+ /** "" = source default (recreate for dockerfile); else explicit. */
+ deployStrategy: DeployStrategy;
}
// ── Defaults ────────────────────────────────────────────────────────
@@ -83,7 +101,8 @@ export function emptyImageState(): ImageFormState {
registryName: '',
cpuLimit: 0,
memoryLimit: 0,
- maxInstances: 1
+ maxInstances: 1,
+ deployStrategy: ''
};
}
@@ -108,7 +127,8 @@ export function emptyStaticState(): StaticFormState {
folderPath: '',
mode: 'static',
renderMarkdown: false,
- reportCommitStatus: false
+ reportCommitStatus: false,
+ deployStrategy: ''
};
}
@@ -118,7 +138,8 @@ export function emptyDockerfileState(): DockerfileFormState {
contextPath: '',
dockerfilePath: 'Dockerfile',
port: 0,
- reportCommitStatus: false
+ reportCommitStatus: false,
+ deployStrategy: ''
};
}
@@ -167,6 +188,11 @@ function normProvider(value: unknown): GitProvider {
return value === 'github' || value === 'gitlab' ? value : 'gitea';
}
+/** Recognized explicit strategies; anything else (incl. absent) -> "". */
+function normStrategy(value: unknown): DeployStrategy {
+ return value === 'recreate' || value === 'blue-green' ? value : '';
+}
+
// ── Seed: source_config JSON -> form state ──────────────────────────
export function seedImageState(jsonText: string): ImageFormState {
@@ -179,7 +205,8 @@ export function seedImageState(jsonText: string): ImageFormState {
registryName: strOr(o.registry_name, ''),
cpuLimit: numOr(o.cpu_limit, 0),
memoryLimit: numOr(o.memory_limit, 0),
- maxInstances: numOr(o.max_instances, 1)
+ maxInstances: numOr(o.max_instances, 1),
+ deployStrategy: normStrategy(o.deploy_strategy)
};
}
@@ -204,7 +231,8 @@ export function seedStaticState(jsonText: string): StaticFormState {
mode: o.mode === 'deno' ? 'deno' : 'static',
renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false,
reportCommitStatus:
- typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false
+ typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false,
+ deployStrategy: normStrategy(o.deploy_strategy)
};
}
@@ -221,7 +249,8 @@ export function seedDockerfileState(jsonText: string): DockerfileFormState {
dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'),
port: numOr(o.port, 0),
reportCommitStatus:
- typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false
+ typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false,
+ deployStrategy: normStrategy(o.deploy_strategy)
};
}
@@ -252,7 +281,7 @@ function preserveEnvVolumes(existingJson: string): {
export function imageToConfig(s: ImageFormState, existingJson: string): Record