From 1e31e3b58419b795e36c7b40e5adfbe7ca9c3e87 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 19:54:03 +0200 Subject: [PATCH] feat(flows): add update_caddy_config flow with validate, reload/force-restart, and smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update flows.example/update_caddy_config.yml with caddy_validate → caddy_reload → smoke test steps and hostname comments - Add flows.example/update_caddy_config_force.yml for docker compose hard restart variant - Add /flows/update-caddy-config UI page with reload/force-restart toggle, dry-run mode showing pending Caddyfile preview, hostname detection, and audit log link Co-Authored-By: Claude Sonnet 4.6 --- .../_components/flow-panel.tsx | 262 ++++++++++++++++++ app/flows/update-caddy-config/page.tsx | 36 +++ .../flows.example/update_caddy_config.yml | 35 ++- .../update_caddy_config_force.yml | 27 ++ 4 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 app/flows/update-caddy-config/_components/flow-panel.tsx create mode 100644 app/flows/update-caddy-config/page.tsx create mode 100644 ops-agent/flows.example/update_caddy_config_force.yml diff --git a/app/flows/update-caddy-config/_components/flow-panel.tsx b/app/flows/update-caddy-config/_components/flow-panel.tsx new file mode 100644 index 0000000..30d7874 --- /dev/null +++ b/app/flows/update-caddy-config/_components/flow-panel.tsx @@ -0,0 +1,262 @@ +'use client' + +import { useState, useCallback } from 'react' +import Link from 'next/link' +import { useFlowRun } from '@/hooks/useFlowRun' +import StreamingTerminal from '@/components/StreamingTerminal' +import ConfirmDialog from '@/components/ConfirmDialog' + +type RestartMode = 'reload' | 'force' + +const FLOW_KEYS: Record = { + reload: 'update_caddy_config', + force: 'update_caddy_config_force', +} + +const STEP_LABELS: Record = { + reload: [ + 'caddy validate (syntax check)', + 'caddy reload (zero-downtime via admin API)', + 'smoke test: curl -I each hostname (expect 200/301/308/401)', + ], + force: [ + 'caddy validate (syntax check)', + 'docker compose up --force-recreate caddy (hard restart)', + 'smoke test: curl -I each hostname (expect 200/301/308/401)', + ], +} + +function parseCaddyfileHostnames(content: string): string[] { + const hostnames: string[] = [] + // Match top-level server block labels: hostname { or hostname:port { + for (const match of content.matchAll(/^([a-zA-Z0-9][a-zA-Z0-9\-.:]+)\s*\{/gm)) { + const candidate = match[1].trim() + // Skip obvious non-hostnames like "tls", "log", "handle", etc. + if (!candidate.includes('.') && !candidate.includes(':')) continue + // Strip port suffix for display + const host = candidate.split(':')[0] + if (!hostnames.includes(host)) hostnames.push(host) + } + return hostnames +} + +function CaddyfileDiff({ content, error }: { content: string; error: string | null }) { + if (error) { + return ( +
+ Could not load Caddyfile: {error} +
+ ) + } + if (!content.trim()) { + return ( +
+ Caddyfile is empty or not found +
+ ) + } + return ( +
+      {content}
+    
+ ) +} + +type Props = { + caddyfile: string + caddyfileError: string | null +} + +export default function FlowPanel({ caddyfile, caddyfileError }: Props) { + const [mode, setMode] = useState('reload') + const [pendingDryRun, setPendingDryRun] = useState(null) + const [completedFlowRunId, setCompletedFlowRunId] = useState(null) + const [showConfig, setShowConfig] = useState(false) + + const handleComplete = useCallback((flowRunId: string) => { + setCompletedFlowRunId(flowRunId) + }, []) + + const flowRun = useFlowRun(handleComplete) + + const handleConfirm = useCallback(() => { + if (pendingDryRun === null) return + const dryRun = pendingDryRun + setPendingDryRun(null) + setCompletedFlowRunId(null) + flowRun.startFlow(FLOW_KEYS[mode], dryRun) + }, [pendingDryRun, flowRun, mode]) + + const handleReset = useCallback(() => { + flowRun.reset() + setCompletedFlowRunId(null) + }, [flowRun]) + + const hostnames = parseCaddyfileHostnames(caddyfile) + const steps = STEP_LABELS[mode] + const flowKey = FLOW_KEYS[mode] + + return ( +
+ {/* Description */} +
+
+

+ Validates the Caddyfile on disk, then reloads or restarts Caddy, and + smoke-tests every public hostname. Edit the config first via the{' '} + + Caddy editor + + . +

+

+ config: /srv/scrum4me/caddy/Caddyfile +

+
+ + {/* Restart mode toggle */} +
+ Restart mode: + + +
+ + {/* Step list */} +
    + {steps.map((step, i) => ( +
  1. + {i + 1}. + {step} +
  2. + ))} +
+ + {/* Detected hostnames */} + {hostnames.length > 0 && ( +
+

+ Hostnames detected in Caddyfile (will be smoke-tested): +

+
    + {hostnames.map((h) => ( +
  • + • {h} +
  • + ))} +
+
+ )} +
+ + {/* Config preview (dry-run diff) */} +
+ + {showConfig && ( +
+

+ This is the config that will be validated and applied: +

+ + {!caddyfileError && ( +

+ To change the config before applying,{' '} + + edit in the Caddy editor + {' '} + first. +

+ )} +
+ )} +
+ + {/* Action buttons */} +
+ + + {flowRun.status !== 'idle' && flowRun.status !== 'running' && ( + + )} +
+ + {/* Terminal output */} + {flowRun.status !== 'idle' && ( +
+
+ Output + {completedFlowRunId && ( + + View in audit log → + + )} +
+ +
+ )} + + {/* Confirm dialog */} + 0 ? `\nHostnames: ${hostnames.join(', ')}` : ''}` + : `flow: ${flowKey}\n\nSteps:\n${steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}${hostnames.length > 0 ? `\n\nHostnames to smoke-test: ${hostnames.join(', ')}` : ''}` + } + onConfirm={handleConfirm} + onCancel={() => setPendingDryRun(null)} + /> +
+ ) +} diff --git a/app/flows/update-caddy-config/page.tsx b/app/flows/update-caddy-config/page.tsx new file mode 100644 index 0000000..0ceb2aa --- /dev/null +++ b/app/flows/update-caddy-config/page.tsx @@ -0,0 +1,36 @@ +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/session' +import { execAgent } from '@/lib/agent-client' +import FlowPanel from './_components/flow-panel' + +export const dynamic = 'force-dynamic' + +export default async function UpdateCaddyConfigPage() { + const user = await getCurrentUser() + if (!user) redirect('/login') + + let caddyfile = '' + let caddyfileError: string | null = null + try { + caddyfile = await execAgent('caddy_show_config') + } catch (err) { + caddyfileError = err instanceof Error ? err.message : 'failed to load Caddyfile' + } + + return ( +
+
+
+ + ← Home + + / +

Update Caddy Config

+
+ + +
+
+ ) +} diff --git a/ops-agent/flows.example/update_caddy_config.yml b/ops-agent/flows.example/update_caddy_config.yml index 7c5cee9..b307826 100644 --- a/ops-agent/flows.example/update_caddy_config.yml +++ b/ops-agent/flows.example/update_caddy_config.yml @@ -1,16 +1,34 @@ -# Reload Caddy after a config change. +# Validate and reload the Caddy configuration (zero-downtime). # Copy to /etc/ops-agent/flows/update_caddy_config.yml on the host. # -# Assumes the new Caddyfile is already written to /srv/scrum4me/caddy/Caddyfile -# (e.g. via the caddy_write_config command from the Ops Dashboard editor). +# Prerequisites: +# - The new Caddyfile must already be written to /srv/scrum4me/caddy/Caddyfile +# (e.g. via the Caddy editor in the Ops Dashboard, or edited by hand). # # Steps: -# 1. Validate the Caddyfile -# 2. Reload Caddy (zero-downtime config swap) -# 3. Smoke-test HTTPS connectivity +# 1. Validate the Caddyfile syntax (caddy validate) +# 2. Reload Caddy via its admin API — zero-downtime config swap +# 3. Smoke-test public hostnames: curl -I, expect 200/301/308/401 +# +# For a hard container restart instead of a graceful reload, use +# update_caddy_config_force.yml (needed after port/TLS listener changes). +# +# Smoke-test commands must be registered in commands.yml. +# Add one curl_smoke_ entry per public hostname. Example: +# +# curl_smoke_scrum4me_web: +# cmd: ["curl", "-sI", "--max-time", "10", "https://scrum4me.example.com/api/health"] +# description: "Smoke test scrum4me-web HTTPS endpoint" +# +# Then add one step per hostname below: +# +# - command_key: curl_smoke_scrum4me_web +# on_failure: continue +# - command_key: curl_smoke_other_site +# on_failure: continue name: Update Caddy Config -description: Validate and reload the Caddy configuration +description: Validate the Caddyfile and reload Caddy (zero-downtime via admin API) steps: - command_key: caddy_validate on_failure: abort @@ -18,5 +36,8 @@ steps: - command_key: caddy_reload on_failure: abort + # Add one smoke-test step per public hostname served by Caddy. + # Accepted exit codes: 0 (200/301/308) or 22 (4xx, use --fail to control). + # on_failure: continue keeps the flow going even if a hostname is temporarily slow. - command_key: curl_smoke_scrum4me_web on_failure: continue diff --git a/ops-agent/flows.example/update_caddy_config_force.yml b/ops-agent/flows.example/update_caddy_config_force.yml new file mode 100644 index 0000000..5205773 --- /dev/null +++ b/ops-agent/flows.example/update_caddy_config_force.yml @@ -0,0 +1,27 @@ +# Validate the Caddyfile and recreate the Caddy container (hard restart). +# Copy to /etc/ops-agent/flows/update_caddy_config_force.yml on the host. +# +# Use this flow instead of update_caddy_config.yml when a graceful reload +# is insufficient — e.g. after adding a new TLS listener, changing ports, +# or updating the Docker image itself. +# +# Steps: +# 1. Validate the Caddyfile syntax (caddy validate) +# 2. Recreate the Caddy container via docker compose (hard restart) +# 3. Smoke-test public hostnames: curl -I, expect 200/301/308/401 +# +# See update_caddy_config.yml for instructions on registering smoke-test +# commands in commands.yml. + +name: Update Caddy Config (Force Restart) +description: Validate the Caddyfile and recreate the Caddy container via docker compose +steps: + - command_key: caddy_validate + on_failure: abort + + - command_key: caddy_compose_restart + on_failure: abort + + # Add one smoke-test step per public hostname served by Caddy. + - command_key: curl_smoke_scrum4me_web + on_failure: continue