feat(flows): add update_caddy_config flow with validate, reload/force-restart, and smoke test
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
6bee8e8741
commit
1e31e3b584
4 changed files with 353 additions and 7 deletions
262
app/flows/update-caddy-config/_components/flow-panel.tsx
Normal file
262
app/flows/update-caddy-config/_components/flow-panel.tsx
Normal file
|
|
@ -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<RestartMode, string> = {
|
||||||
|
reload: 'update_caddy_config',
|
||||||
|
force: 'update_caddy_config_force',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<RestartMode, string[]> = {
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
Could not load Caddyfile: {error}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!content.trim()) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border px-4 py-6 text-sm text-muted-foreground text-center">
|
||||||
|
Caddyfile is empty or not found
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<pre className="overflow-x-auto rounded-lg border border-border bg-zinc-950 p-4 text-xs font-mono text-zinc-100 leading-relaxed max-h-80">
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
caddyfile: string
|
||||||
|
caddyfileError: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FlowPanel({ caddyfile, caddyfileError }: Props) {
|
||||||
|
const [mode, setMode] = useState<RestartMode>('reload')
|
||||||
|
const [pendingDryRun, setPendingDryRun] = useState<boolean | null>(null)
|
||||||
|
const [completedFlowRunId, setCompletedFlowRunId] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Description */}
|
||||||
|
<div className="rounded-lg border border-border p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Validates the Caddyfile on disk, then reloads or restarts Caddy, and
|
||||||
|
smoke-tests every public hostname. Edit the config first via the{' '}
|
||||||
|
<Link href="/caddy/edit" className="underline hover:text-foreground transition-colors">
|
||||||
|
Caddy editor
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground font-mono">
|
||||||
|
config: /srv/scrum4me/caddy/Caddyfile
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Restart mode toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Restart mode:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('reload')}
|
||||||
|
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
mode === 'reload'
|
||||||
|
? 'bg-foreground text-background'
|
||||||
|
: 'border border-border hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Reload (zero-downtime)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('force')}
|
||||||
|
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
mode === 'force'
|
||||||
|
? 'bg-foreground text-background'
|
||||||
|
: 'border border-border hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Force Restart (docker compose)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step list */}
|
||||||
|
<ol className="space-y-1">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<li key={i} className="flex gap-2 text-xs font-mono text-muted-foreground">
|
||||||
|
<span className="text-border min-w-[1.5rem]">{i + 1}.</span>
|
||||||
|
<span>{step}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{/* Detected hostnames */}
|
||||||
|
{hostnames.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Hostnames detected in Caddyfile (will be smoke-tested):
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{hostnames.map((h) => (
|
||||||
|
<li key={h} className="text-xs font-mono text-muted-foreground pl-4">
|
||||||
|
• {h}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config preview (dry-run diff) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfig((v) => !v)}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{showConfig ? '▾' : '▸'} {showConfig ? 'Hide' : 'Preview'} pending Caddyfile
|
||||||
|
</button>
|
||||||
|
{showConfig && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This is the config that will be validated and applied:
|
||||||
|
</p>
|
||||||
|
<CaddyfileDiff content={caddyfile} error={caddyfileError} />
|
||||||
|
{!caddyfileError && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
To change the config before applying,{' '}
|
||||||
|
<Link href="/caddy/edit" className="underline hover:text-foreground">
|
||||||
|
edit in the Caddy editor
|
||||||
|
</Link>{' '}
|
||||||
|
first.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingDryRun(false)}
|
||||||
|
disabled={flowRun.status === 'running'}
|
||||||
|
className="rounded-lg bg-foreground text-background px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||||
|
>
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingDryRun(true)}
|
||||||
|
disabled={flowRun.status === 'running'}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted/50 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Dry Run
|
||||||
|
</button>
|
||||||
|
{flowRun.status !== 'idle' && flowRun.status !== 'running' && (
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal output */}
|
||||||
|
{flowRun.status !== 'idle' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Output</span>
|
||||||
|
{completedFlowRunId && (
|
||||||
|
<Link
|
||||||
|
href={`/audit/${completedFlowRunId}`}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
View in audit log →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<StreamingTerminal
|
||||||
|
lines={flowRun.lines}
|
||||||
|
status={flowRun.status}
|
||||||
|
error={flowRun.error}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm dialog */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDryRun !== null}
|
||||||
|
title={
|
||||||
|
pendingDryRun
|
||||||
|
? `Dry Run: Update Caddy Config (${mode === 'reload' ? 'Reload' : 'Force Restart'})`
|
||||||
|
: `Run: Update Caddy Config (${mode === 'reload' ? 'Reload' : 'Force Restart'})`
|
||||||
|
}
|
||||||
|
commandPreview={
|
||||||
|
pendingDryRun
|
||||||
|
? `[DRY RUN] flow: ${flowKey}\n\nAll steps will be shown without executing.\n\nPending config: /srv/scrum4me/caddy/Caddyfile${hostnames.length > 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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
app/flows/update-caddy-config/page.tsx
Normal file
36
app/flows/update-caddy-config/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="min-h-screen bg-background p-6">
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
← Home
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Update Caddy Config</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FlowPanel caddyfile={caddyfile} caddyfileError={caddyfileError} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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.
|
# 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
|
# Prerequisites:
|
||||||
# (e.g. via the caddy_write_config command from the Ops Dashboard editor).
|
# - 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:
|
# Steps:
|
||||||
# 1. Validate the Caddyfile
|
# 1. Validate the Caddyfile syntax (caddy validate)
|
||||||
# 2. Reload Caddy (zero-downtime config swap)
|
# 2. Reload Caddy via its admin API — zero-downtime config swap
|
||||||
# 3. Smoke-test HTTPS connectivity
|
# 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_<name> 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
|
name: Update Caddy Config
|
||||||
description: Validate and reload the Caddy configuration
|
description: Validate the Caddyfile and reload Caddy (zero-downtime via admin API)
|
||||||
steps:
|
steps:
|
||||||
- command_key: caddy_validate
|
- command_key: caddy_validate
|
||||||
on_failure: abort
|
on_failure: abort
|
||||||
|
|
@ -18,5 +36,8 @@ steps:
|
||||||
- command_key: caddy_reload
|
- command_key: caddy_reload
|
||||||
on_failure: abort
|
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
|
- command_key: curl_smoke_scrum4me_web
|
||||||
on_failure: continue
|
on_failure: continue
|
||||||
|
|
|
||||||
27
ops-agent/flows.example/update_caddy_config_force.yml
Normal file
27
ops-agent/flows.example/update_caddy_config_force.yml
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue