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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue