feat(caddy): make cluster storage for Caddy default, remember 'uc caddy deploy' options

This commit is contained in:
Pasha Sviderski
2026-11-05 16:03:34 +10:00
parent 76b04dd56c
commit 22b1c68798
13 changed files with 1173 additions and 98 deletions
+80 -9
View File
@@ -134,6 +134,22 @@ func (sp *ServicePlan) Format() string {
specTable.Row(mod, "image:", formatImageDiff(oldSpec.Container.Image, sp.Spec.Container.Image))
}
// An existing service without an old spec only has new containers added to up-to-date ones so its Caddy config and
// labels didn't change.
specChanged := sp.IsNewService || oldSpec != nil
var old api.ServiceSpec
if oldSpec != nil {
old = *oldSpec
}
// Caddy config row.
if specChanged {
mod, value := formatCaddyConfigDiff(old.CaddyConfig(), sp.Spec.CaddyConfig(), sp.IsNewService)
if value != "" {
specTable.Row(mod, "caddy config:", value)
}
}
// Replicas row for replicated services.
if sp.Spec.Mode == api.ServiceModeReplicated {
replicasStr := fmt.Sprintf("%d", sp.Spec.Replicas)
@@ -153,15 +169,11 @@ func (sp *ServicePlan) Format() string {
specDiff := specTable.String() + "\n"
// Render labels as nested blocks outside the table to not widen its attribute column. An existing service without
// an old spec only has new containers added to up-to-date ones so its labels didn't change.
if sp.IsNewService || oldSpec != nil {
var oldLabels, oldContainerLabels map[string]string
if oldSpec != nil {
oldLabels, oldContainerLabels = oldSpec.Labels, oldSpec.Container.Labels
}
specDiff += formatLabelsDiff("labels:", oldLabels, sp.Spec.Labels, sp.IsNewService)
specDiff += formatLabelsDiff("container labels:", oldContainerLabels, sp.Spec.Container.Labels, sp.IsNewService)
// Render the labels outside the table because they span multiple lines.
if specChanged {
specDiff += formatLabelsDiff("labels:", old.Labels, sp.Spec.Labels, sp.IsNewService)
specDiff += formatLabelsDiff("container labels:", old.Container.Labels, sp.Spec.Container.Labels,
sp.IsNewService)
}
treePrefix := tui.Faint.Render(" │ ")
@@ -288,6 +300,65 @@ func formatImageDiff(oldImage, newImage string) string {
tui.FormatImage(newImage, tui.Green)
}
// formatCaddyConfigDiff returns the modifier and the number of added and removed lines in the Caddy config. It omits
// the config content because it can be large. It returns an empty value if the configs are the same.
func formatCaddyConfigDiff(oldConfig, newConfig string, isNewService bool) (mod, value string) {
if oldConfig == newConfig {
return "", ""
}
// Count lines regardless of their order, which is enough for a summary and doesn't require a diff algorithm.
remaining := make(map[string]int)
var removed int
if oldConfig != "" {
for line := range strings.SplitSeq(oldConfig, "\n") {
remaining[line]++
removed++
}
}
var added int
if newConfig != "" {
for line := range strings.SplitSeq(newConfig, "\n") {
if remaining[line] > 0 {
remaining[line]--
removed--
} else {
added++
}
}
}
var parts []string
if added > 0 {
parts = append(parts, tui.Green.Render("+"+strconv.Itoa(added)))
}
if removed > 0 {
parts = append(parts, tui.Red.Render("-"+strconv.Itoa(removed)))
}
switch {
case added+removed == 0:
value = "changed"
case added+removed == 1:
value = parts[0] + " line"
default:
value = strings.Join(parts, " ") + " lines"
}
switch {
case isNewService:
mod = ""
case oldConfig == "":
mod = tui.Green.Render("+")
case newConfig == "":
mod = tui.Red.Render("-")
default:
mod = "~"
}
return mod, value
}
// formatLabelsDiff renders a block with the attribute name followed by a nested line for each added, changed, or
// removed label, sorted by key. It returns an empty string if the labels are the same.
func formatLabelsDiff(attribute string, oldLabels, newLabels map[string]string, isNewService bool) string {
+121
View File
@@ -83,6 +83,21 @@ func TestServicePlan_Format(t *testing.T) {
}
}
withCaddy := func(config string) api.ServiceSpec {
s := spec("nginx:1.25", nil, nil)
s.Caddy = &api.CaddySpec{Config: config}
return s
}
replaceFrom := func(old api.ServiceSpec) func(api.ServiceSpec) []operation.Operation {
return func(new api.ServiceSpec) []operation.Operation {
return []operation.Operation{&operation.ReplaceContainerOperation{
Spec: new,
MachineName: "machine-1",
OldContainer: newContainer(old),
}}
}
}
tests := []struct {
name string
isNewService bool
@@ -90,6 +105,112 @@ func TestServicePlan_Format(t *testing.T) {
ops func(spec api.ServiceSpec) []operation.Operation
expected []string
}{
{
name: "caddy config lines added and removed",
spec: withCaddy("{\n\tstorage uncloud\n}"),
ops: replaceFrom(withCaddy("{\n\tdebug\n\tauto_https off\n}")),
expected: []string{
"~ update service web",
" │ image: nginx:1.25",
" │ ~ caddy config: +1 -2 lines",
" │",
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
},
},
{
name: "caddy config single line added",
spec: withCaddy("{\n\tstorage uncloud\n\tdebug\n}"),
ops: replaceFrom(withCaddy("{\n\tstorage uncloud\n}")),
expected: []string{
"~ update service web",
" │ image: nginx:1.25",
" │ ~ caddy config: +1 line",
" │",
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
},
},
{
name: "caddy config added to existing service",
spec: withCaddy("{\n\tstorage uncloud\n}"),
ops: replaceFrom(spec("nginx:1.25", nil, nil)),
expected: []string{
"~ update service web",
" │ image: nginx:1.25",
" │ + caddy config: +3 lines",
" │",
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
},
},
{
name: "caddy config removed",
spec: spec("nginx:1.25", nil, nil),
ops: replaceFrom(withCaddy("{\n\tstorage uncloud\n}")),
expected: []string{
"~ update service web",
" │ image: nginx:1.25",
" │ - caddy config: -3 lines",
" │",
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
},
},
{
name: "caddy config with only reordered lines",
spec: withCaddy("a {\n}\nb {\n}"),
ops: replaceFrom(withCaddy("b {\n}\na {\n}")),
expected: []string{
"~ update service web",
" │ image: nginx:1.25",
" │ ~ caddy config: changed",
" │",
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
},
},
{
name: "caddy config unchanged ignoring surrounding whitespace",
spec: withCaddy("{\n\tdebug\n}"),
ops: replaceFrom(withCaddy("\n{\n\tdebug\n}\n")),
expected: []string{
"~ update service web",
" │ image: nginx:1.25",
" │",
" ╰── +/- replace container web/0f6df6593e1b on machine-1",
},
},
{
name: "new service with caddy config",
isNewService: true,
spec: withCaddy("{\n\tstorage uncloud\n}"),
ops: func(new api.ServiceSpec) []operation.Operation {
return []operation.Operation{&operation.RunContainerOperation{Spec: new, MachineName: "machine-1"}}
},
expected: []string{
"+ create service web",
" │ image: nginx:1.25",
" │ caddy config: +3 lines",
" │",
" ╰── + run container web on machine-1",
},
},
{
name: "caddy config before replicas",
isNewService: true,
spec: func() api.ServiceSpec {
s := withCaddy("{\n\tstorage uncloud\n}")
s.Replicas = 3
return s
}(),
ops: func(new api.ServiceSpec) []operation.Operation {
return []operation.Operation{&operation.RunContainerOperation{Spec: new, MachineName: "machine-1"}}
},
expected: []string{
"+ create service web",
" │ image: nginx:1.25",
" │ caddy config: +3 lines",
" │ replicas: 3",
" │",
" ╰── + run container web on machine-1",
},
},
{
name: "service labels added, changed, and removed in place",
spec: spec("nginx:1.25", map[string]string{"env": "prod", "team": "platform", "same": "1"}, nil),