feat(labels): support service and container labels, update labels and cpu/memory in-place

This commit is contained in:
Pasha Sviderski
2026-11-02 14:57:33 +10:00
parent b214d23f21
commit 81f30aac26
30 changed files with 1727 additions and 286 deletions
+8 -1
View File
@@ -55,7 +55,7 @@ func (p *Plan) Format() string {
// formatSummary counts all operations across the plan and renders the summary footer.
func (p *Plan) formatSummary() string {
var createCount, startFirstCount, stopFirstCount, removeCount int
var createCount, startFirstCount, stopFirstCount, updateCount, removeCount int
machines := make(map[string]struct{})
for _, op := range p.Volumes {
@@ -76,6 +76,9 @@ func (p *Plan) formatSummary() string {
} else {
startFirstCount++
}
case *operation.UpdateContainerOperation:
machines[o.MachineID] = struct{}{}
updateCount++
case *operation.RemoveContainerOperation:
machines[o.MachineID] = struct{}{}
removeCount++
@@ -99,6 +102,10 @@ func (p *Plan) formatSummary() string {
parts = append(parts,
tui.BoldYellow.Render(strconv.Itoa(stopFirstCount))+" "+tui.Yellow.Render("replace (stop-first)"))
}
if updateCount > 0 {
parts = append(parts,
tui.BoldYellow.Render(strconv.Itoa(updateCount))+" "+tui.Yellow.Render("update (in place)"))
}
if removeCount > 0 {
parts = append(parts,
tui.BoldRed.Render(strconv.Itoa(removeCount))+" "+tui.Red.Render("remove"))
+30
View File
@@ -0,0 +1,30 @@
package compose
import (
"testing"
"github.com/charmbracelet/x/ansi"
"github.com/psviderski/uncloud/pkg/client/deploy"
"github.com/psviderski/uncloud/pkg/client/operation"
"github.com/stretchr/testify/assert"
)
func TestPlan_FormatSummary(t *testing.T) {
t.Parallel()
web := &deploy.ServicePlan{}
web.Operations = []operation.Operation{
&operation.UpdateContainerOperation{MachineID: "m-1"},
&operation.RemoveContainerOperation{MachineID: "m-2"},
}
db := &deploy.ServicePlan{}
db.Operations = []operation.Operation{
&operation.UpdateContainerOperation{MachineID: "m-2"},
}
plan := &Plan{
Volumes: []*operation.CreateVolumeOperation{{MachineID: "m-3"}},
Services: []*deploy.ServicePlan{web, db},
}
assert.Equal(t, "1 create · 2 update (in place) · 1 remove · across 3 machines", ansi.Strip(plan.formatSummary()))
}
-14
View File
@@ -344,20 +344,6 @@ configs:
warnCount: 1,
warnContains: []string{"container_name"},
},
{
name: "deploy labels",
composeYAML: `services:
app:
image: myapp:latest
deploy:
mode: replicated
labels:
- foo
replicas: 2
`,
warnCount: 1,
warnContains: []string{"deploy labels"},
},
{
name: "deploy restart_policy",
composeYAML: `services:
+8 -6
View File
@@ -66,6 +66,10 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
Mode: api.ServiceModeReplicated,
}
if labels := mergeLabels(service.Labels, service.CustomLabels); len(labels) > 0 {
spec.Container.Labels = labels
}
// Map x-caddy extension to spec.Caddy if specified.
if caddy, ok := service.Extensions[CaddyExtensionKey].(Caddy); ok && caddy.Config != "" {
spec.Caddy = &api.CaddySpec{
@@ -124,6 +128,10 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
d := time.Duration(cfg.Monitor)
spec.UpdateConfig.MonitorPeriod = &d
}
if len(service.Deploy.Labels) > 0 {
spec.Labels = maps.Clone(service.Deploy.Labels)
}
}
// TODO: can service.tmpfs be handled as tmpfs volume mounts as well?
@@ -480,9 +488,6 @@ func validateServicesFeatures(project *types.Project) []error {
if service.DNSSearch != nil {
errs = append(errs, err(service.Name, "dns_search"))
}
if service.Labels != nil {
errs = append(errs, err(service.Name, "labels"))
}
if service.Links != nil {
errs = append(errs, err(service.Name, "links"))
}
@@ -517,9 +522,6 @@ func validateServicesFeatures(project *types.Project) []error {
if service.Deploy.RollbackConfig != nil {
errs = append(errs, err(service.Name, "deploy rollback_config"))
}
if len(service.Deploy.Labels) != 0 {
errs = append(errs, err(service.Name, "deploy labels"))
}
}
// we only allow the 'default' network, nothing else.
if x := service.Networks; x != nil {
+75
View File
@@ -875,6 +875,81 @@ services:
}
}
func TestServiceSpecFromCompose_Labels(t *testing.T) {
t.Parallel()
tests := []struct {
name string
composeYAML string
expectedContainerLabels map[string]string
expectedServiceLabels map[string]string
}{
{
name: "no labels",
composeYAML: `
services:
web:
image: nginx
`,
},
{
name: "container labels as list",
composeYAML: `
services:
web:
image: nginx
labels:
- com.example.team=web
- com.example.empty
`,
expectedContainerLabels: map[string]string{
"com.example.team": "web",
"com.example.empty": "",
},
},
{
name: "container and service labels as maps",
composeYAML: `
services:
web:
image: nginx
labels:
com.example.team: web
deploy:
labels:
tier: frontend
`,
expectedContainerLabels: map[string]string{"com.example.team": "web"},
expectedServiceLabels: map[string]string{"tier": "frontend"},
},
{
name: "service labels only",
composeYAML: `
services:
web:
image: nginx
deploy:
labels:
- tier=frontend
`,
expectedServiceLabels: map[string]string{"tier": "frontend"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
require.NoError(t, err)
spec, err := ServiceSpecFromCompose(project, "web")
require.NoError(t, err)
assert.Equal(t, tt.expectedContainerLabels, spec.Container.Labels)
assert.Equal(t, tt.expectedServiceLabels, spec.Labels)
})
}
}
func TestServiceSpecFromCompose_UpdateConfig(t *testing.T) {
t.Parallel()