diff --git a/cmd/uncloud/service/inspect.go b/cmd/uncloud/service/inspect.go index 11973633..f9ac5493 100644 --- a/cmd/uncloud/service/inspect.go +++ b/cmd/uncloud/service/inspect.go @@ -70,13 +70,20 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error { } for _, ctr := range svc.Containers { - createdAt := time.Unix(ctr.Container.Created, 0) + createdAt, err := time.Parse(time.RFC3339Nano, ctr.Container.Created) + if err != nil { + return fmt.Errorf("parse created time: %w", err) + } created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago" machine := machinesNamesByID[ctr.MachineID] if machine == "" { machine = ctr.MachineID } + state, err := ctr.Container.HumanState() + if err != nil { + return fmt.Errorf("get human state: %w", err) + } _, err = fmt.Fprintf( tw, @@ -84,7 +91,7 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error { stringid.TruncateID(ctr.Container.ID), ctr.Container.Image, created, - ctr.Container.Status, + state, machine, ) if err != nil { diff --git a/go.mod b/go.mod index d69e68d3..887c227e 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/OneOfOne/xxhash v1.2.8 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect github.com/armon/go-metrics v0.4.1 // indirect @@ -79,6 +80,7 @@ require ( github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/chzyer/readline v1.5.1 // indirect + github.com/cloudflare/cfssl v1.6.4 // indirect github.com/compose-spec/compose-go/v2 v2.4.5 // indirect github.com/containerd/console v1.0.4 // indirect github.com/containerd/containerd v1.7.24 // indirect diff --git a/go.sum b/go.sum index 2e4d31f2..138a1a5d 100644 --- a/go.sum +++ b/go.sum @@ -45,8 +45,9 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.12.5 h1:bpTInLlDy/nDRWFVcefDZZ1+U8tS+rz3MxjKgu9boo0= github.com/Microsoft/hcsshim v0.12.5/go.mod h1:tIUGego4G1EN5Hb6KC90aDYiUI2dqLSTTOCjVNpOgZ8= -github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= +github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= @@ -166,8 +167,9 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38 github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e85keuznYcH5rqI438v41pKcBl4ZxQ= github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= +github.com/cloudflare/cfssl v1.6.4 h1:NMOvfrEjFfC63K3SGXgAnFdsgkmiq4kATme5BfcqrO8= +github.com/cloudflare/cfssl v1.6.4/go.mod h1:8b3CQMxfWPAeom3zBnGJ6sd+G1NkL5TXqmDXacb+1J0= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= @@ -591,6 +593,8 @@ github.com/jinzhu/gorm v0.0.0-20170222002820-5409931a1bb8/go.mod h1:Vla75njaFJ8c github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d h1:jRQLvyVGL+iVtDElaEIDdKwpPqUIZJfzkNLV34htpEc= github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jmoiron/sqlx v1.3.3 h1:j82X0bf7oQ27XeqxicSZsTU5suPwKElg3oyxNn43iTk= +github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -1058,6 +1062,8 @@ github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsX github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= +github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b h1:FsyNrX12e5BkplJq7wKOLk0+C6LZ+KGXvuEcKUYm5ss= +github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1070,6 +1076,10 @@ github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCR github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc h1:zkGwegkOW709y0oiAraH/3D8njopUR/pARHv4tZZ6pw= +github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc/go.mod h1:FM4U1E3NzlNMRnSUTU3P1UdukWhYGifqEsjk9fn7BCk= +github.com/zmap/zlint/v3 v3.1.0 h1:WjVytZo79m/L1+/Mlphl09WBob6YTGljN5IGWZFpAv0= +github.com/zmap/zlint/v3 v3.1.0/go.mod h1:L7t8s3sEKkb0A2BxGy1IWrxt1ZATa1R4QfJZaQOD3zU= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= diff --git a/internal/api/container.go b/internal/api/container.go index d1ef4aa3..fa93908f 100644 --- a/internal/api/container.go +++ b/internal/api/container.go @@ -3,8 +3,9 @@ package api import ( "fmt" "github.com/docker/docker/api/types" - "regexp" + "github.com/docker/go-units" "strings" + "time" ) const ( @@ -13,42 +14,30 @@ const ( LabelServiceName = "uncloud.service.name" LabelServiceMode = "uncloud.service.mode" LabelServicePorts = "uncloud.service.ports" - - StateCreated = "created" - StateDead = "dead" - StateExited = "exited" - StatePaused = "paused" - StateRemoving = "removing" - StateRestarting = "restarting" - StateRunning = "running" ) type Container struct { - types.Container -} - -func (c *Container) Name() string { - return c.Names[0][1:] // Remove leading slash. + types.ContainerJSON } // ServiceID returns the ID of the service this container belongs to. func (c *Container) ServiceID() string { - return c.Labels[LabelServiceID] + return c.Config.Labels[LabelServiceID] } // ServiceName returns the name of the service this container belongs to. func (c *Container) ServiceName() string { - return c.Labels[LabelServiceName] + return c.Config.Labels[LabelServiceName] } // ServiceMode returns the replication mode of the service this container belongs to. func (c *Container) ServiceMode() string { - return c.Labels[LabelServiceMode] + return c.Config.Labels[LabelServiceMode] } // ServicePorts returns the ports this container publishes as part of its service. func (c *Container) ServicePorts() ([]PortSpec, error) { - encoded, ok := c.Labels[LabelServicePorts] + encoded, ok := c.Config.Labels[LabelServicePorts] if !ok { return nil, nil } @@ -75,41 +64,72 @@ func (c *Container) ServiceSpec() ServiceSpec { return ServiceSpec{} } -// runningStatusRegex matches the status string of a running container. -// - "Up 3 minutes (healthy)" -> groups: ["Up 3 minutes (healthy)", "healthy"] -// - "Up 5 seconds" -> groups: ["Up 5 seconds", ""] -// - "Up 2 hours (unhealthy)" -> groups: ["Up 2 hours (unhealthy)", "unhealthy"] -// - "Up 1 minute (health: starting)" -> groups: ["Up 1 minute (health: starting)", "health: starting"] -// - "Restarting (0) 5 seconds ago" -> no match -// See https://github.com/moby/moby/blob/c130ce1f5d1e38b98a97044a39557de43bc0d58f/container/state.go#L77-L90 -// for more details on how the status string for a running container is formatted. -var runningStatusRegex = regexp.MustCompile(`^Up [^(]+(?:\(([^)]+)\))?$`) - -// Healthy determines if the container is running and healthy based on its status string. +// Healthy determines if the container is running and healthy. // A running container with no health check configured is considered healthy. func (c *Container) Healthy() bool { - if c.State != StateRunning { + if !c.State.Running || c.State.Paused || c.State.Restarting { return false } - matches := runningStatusRegex.FindStringSubmatch(c.Status) - // Not "Up" or invalid format. - if matches == nil { - return false - } - - // If there's no health status (no health check configured so no parentheses), container is considered healthy. - if matches[1] == "" { + // If there's no health status (no health check configured), container is considered healthy. + if c.State.Health == nil { return true } - // If the health status in parentheses is "healthy", the container is considered healthy. - return matches[1] == types.Healthy + return c.State.Health.Status == types.Healthy } -// Stopped determines if the container is stopped and doesn't try to restart. -func (c *Container) Stopped() bool { - return c.State == StateCreated || c.State == StateDead || c.State == StateExited +// HumanState returns a human-readable description of the container's state. Based on the Docker implementation: +// https://github.com/moby/moby/blob/b343d235a0a1f30c8f05b1d651238e72158dc25d/container/state.go#L79-L113 +func (c *Container) HumanState() (string, error) { + startedAt, err := time.Parse(time.RFC3339Nano, c.State.StartedAt) + if err != nil { + return "", fmt.Errorf("parse started time: %w", err) + } + finishedAt, err := time.Parse(time.RFC3339Nano, c.State.FinishedAt) + if err != nil { + return "", fmt.Errorf("parse finished time: %w", err) + } + + if c.State.Running { + if c.State.Paused { + return fmt.Sprintf("Up %s (Paused)", units.HumanDuration(time.Now().UTC().Sub(startedAt))), nil + } + if c.State.Restarting { + return fmt.Sprintf("Restarting (%d) %s ago", + c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil + } + + if c.State.Health != nil { + status := c.State.Health.Status + if status == types.Starting { + status = "health: " + status + } + + return fmt.Sprintf("Up %s (%s)", units.HumanDuration(time.Now().UTC().Sub(startedAt)), status), nil + } + + return fmt.Sprintf("Up %s", units.HumanDuration(time.Now().UTC().Sub(startedAt))), nil + } + + if c.State.Status == "removing" { + return "Removal In Progress", nil + } + + if c.State.Dead { + return "Dead", nil + } + + if startedAt.IsZero() { + return "Created", nil + } + + if finishedAt.IsZero() { + return "", nil + } + + return fmt.Sprintf("Exited (%d) %s ago", + c.State.ExitCode, units.HumanDuration(time.Now().UTC().Sub(finishedAt))), nil } // ConflictingServicePorts returns a list of service ports that conflict with the given ports. diff --git a/internal/api/container_test.go b/internal/api/container_test.go index 0d66b713..7ad68985 100644 --- a/internal/api/container_test.go +++ b/internal/api/container_test.go @@ -2,6 +2,7 @@ package api import ( "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "net/netip" @@ -13,81 +14,111 @@ func TestContainer_Healthy(t *testing.T) { t.Run("exited", func(t *testing.T) { t.Parallel() - c := &Container{Container: types.Container{ - State: "exited", - Status: "Exited (0) 2 minutes ago", + c := &Container{ContainerJSON: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + State: &types.ContainerState{ + Running: false, + Dead: false, + ExitCode: 0, + }, + }, }} assert.False(t, c.Healthy()) }) t.Run("running with no health check", func(t *testing.T) { t.Parallel() - c := &Container{Container: types.Container{ - State: "running", - Status: "Up 5 minutes", + c := &Container{ContainerJSON: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + State: &types.ContainerState{ + Running: true, + }, + }, }} assert.True(t, c.Healthy()) }) t.Run("running and healthy", func(t *testing.T) { t.Parallel() - c := &Container{Container: types.Container{ - State: "running", - Status: "Up 3 minutes (healthy)", + c := &Container{ContainerJSON: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + State: &types.ContainerState{ + Running: true, + Health: &types.Health{ + Status: types.Healthy, + }, + }, + }, }} assert.True(t, c.Healthy()) }) t.Run("running but unhealthy", func(t *testing.T) { t.Parallel() - c := &Container{Container: types.Container{ - State: "running", - Status: "Up 2 hours (unhealthy)", + c := &Container{ContainerJSON: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + State: &types.ContainerState{ + Running: true, + Health: &types.Health{ + Status: types.Unhealthy, + }, + }, + }, }} assert.False(t, c.Healthy()) }) t.Run("running with health starting", func(t *testing.T) { t.Parallel() - c := &Container{Container: types.Container{ - State: "running", - Status: "Up 1 minute (health: starting)", + c := &Container{ContainerJSON: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + State: &types.ContainerState{ + Running: true, + Health: &types.Health{ + Status: "starting", + }, + }, + }, }} assert.False(t, c.Healthy()) }) - t.Run("invalid up format no time", func(t *testing.T) { + t.Run("dead", func(t *testing.T) { t.Parallel() - c := &Container{Container: types.Container{ - State: "running", - Status: "Up", - }} - assert.False(t, c.Healthy()) - }) - - t.Run("invalid up format empty parentheses", func(t *testing.T) { - t.Parallel() - c := &Container{Container: types.Container{ - State: "running", - Status: "Up 5 minutes ()", - }} - assert.False(t, c.Healthy()) - }) - - t.Run("malformed status", func(t *testing.T) { - t.Parallel() - c := &Container{Container: types.Container{ - State: "running", - Status: "Invalid status", + c := &Container{ContainerJSON: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + State: &types.ContainerState{ + Dead: true, + Running: false, + }, + }, }} assert.False(t, c.Healthy()) }) t.Run("restarting", func(t *testing.T) { t.Parallel() - c := &Container{Container: types.Container{ - State: "running", - Status: "Restarting (0) 5 seconds ago", + c := &Container{ContainerJSON: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + State: &types.ContainerState{ + Restarting: true, + Running: true, + ExitCode: 1, + }, + }, + }} + assert.False(t, c.Healthy()) + }) + + t.Run("paused", func(t *testing.T) { + t.Parallel() + c := &Container{ContainerJSON: types.ContainerJSON{ + ContainerJSONBase: &types.ContainerJSONBase{ + State: &types.ContainerState{ + Paused: true, + Running: true, + }, + }, }} assert.False(t, c.Healthy()) }) @@ -248,15 +279,15 @@ func TestContainer_ConflictingServicePorts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - container := &Container{ - Container: types.Container{ + ctr := &Container{ContainerJSON: types.ContainerJSON{ + Config: &container.Config{ Labels: map[string]string{ LabelServicePorts: tt.containerPorts, }, }, - } + }} - got, err := container.ConflictingServicePorts(tt.checkPorts) + got, err := ctr.ConflictingServicePorts(tt.checkPorts) if tt.wantErr { require.Error(t, err) return diff --git a/internal/cli/client/container.go b/internal/cli/client/container.go index 0d8317eb..3d488e90 100644 --- a/internal/cli/client/container.go +++ b/internal/cli/client/container.go @@ -233,7 +233,7 @@ func (cli *Client) InspectContainer(ctx context.Context, serviceID, containerID } for _, c := range svc.Containers { - if c.Container.ID == containerID || c.Container.Name() == containerID { + if c.Container.ID == containerID || c.Container.Name == containerID { ctr = c } } @@ -258,7 +258,7 @@ func (cli *Client) StartContainer(ctx context.Context, serviceID, containerID st ctx = proxyToMachine(ctx, machine.Machine) pw := progress.ContextWriter(ctx) - eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name(), machine.Machine.Name) + eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name) pw.Event(progress.StartingEvent(eventID)) if err = cli.Docker.StartContainer(ctx, ctr.Container.ID, container.StartOptions{}); err != nil { @@ -285,7 +285,7 @@ func (cli *Client) StopContainer( ctx = proxyToMachine(ctx, machine.Machine) pw := progress.ContextWriter(ctx) - eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name(), machine.Machine.Name) + eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name) pw.Event(progress.StoppingEvent(eventID)) if err = cli.Docker.StopContainer(ctx, ctr.Container.ID, opts); err != nil { @@ -312,7 +312,7 @@ func (cli *Client) RemoveContainer( ctx = proxyToMachine(ctx, machine.Machine) pw := progress.ContextWriter(ctx) - eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name(), machine.Machine.Name) + eventID := fmt.Sprintf("Container %s on %s", ctr.Container.Name, machine.Machine.Name) pw.Event(progress.RemovingEvent(eventID)) if err = cli.Docker.RemoveContainer(ctx, ctr.Container.ID, opts); err != nil { diff --git a/internal/cli/client/service.go b/internal/cli/client/service.go index 3532bba8..a0cf57f3 100644 --- a/internal/cli/client/service.go +++ b/internal/cli/client/service.go @@ -288,7 +288,7 @@ func (cli *Client) InspectService(ctx context.Context, id string) (api.Service, } for _, c := range mc.Containers { - ctr := api.Container{Container: c} + ctr := api.Container{ContainerJSON: c} if ctr.ServiceID() == id || ctr.ServiceName() == id { containers = append(containers, api.MachineContainer{ MachineID: machineID, @@ -455,7 +455,7 @@ func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) { } for _, c := range mc.Containers { - ctr := api.Container{Container: c} + ctr := api.Container{ContainerJSON: c} if _, ok := servicesByID[ctr.ServiceID()]; ok { continue } diff --git a/internal/cli/client/strategy.go b/internal/cli/client/strategy.go index 72063e9b..68471ae5 100644 --- a/internal/cli/client/strategy.go +++ b/internal/cli/client/strategy.go @@ -74,6 +74,7 @@ func (s *RollingStrategy) planGlobal( } plan := &SequenceOperation{} + // TODO: figure out how to return a warning if there are machines down. var machinesDown []*pb.MachineInfo for _, m := range machines { // Skip machines that are down but collect them to report a warning later. @@ -93,6 +94,9 @@ func (s *RollingStrategy) planGlobal( return plan, nil } +// reconcileGlobalContainer returns a sequence of operations to reconcile containers on a machine for a global service. +// It ensures exactly one container with the desired spec is running on the machine by creating a new container and +// removing old ones. If there is a host port conflict, it stops the old container before starting a new one. func reconcileGlobalContainer( containers []api.MachineContainer, spec api.ServiceSpec, serviceID, machineID string, ) ([]Operation, error) { @@ -111,7 +115,7 @@ func reconcileGlobalContainer( // Check if there is a container with the same spec already running. If so, remove the rest. upToDate := false for i, c := range containers { - if c.Container.State != api.StateRunning && c.Container.State != api.StateRestarting { + if !c.Container.State.Running || c.Container.State.Paused { // Skip containers that are not running. continue } @@ -138,9 +142,9 @@ func reconcileGlobalContainer( } // The machine has containers but none of them match the new spec. - // Stop the old non-stopped containers that have conflicting ports with the new spec before running a new one. + // Stop the old running containers that have conflicting ports with the new spec before running a new one. for _, c := range containers { - if !c.Container.Stopped() { + if c.Container.State.Running { conflictingPorts, err := c.Container.ConflictingServicePorts(spec.Ports) if err != nil { return nil, fmt.Errorf("check conflicting ports: %w", err) diff --git a/internal/machine/api/pb/docker.pb.go b/internal/machine/api/pb/docker.pb.go index 203908a1..dd959aac 100644 --- a/internal/machine/api/pb/docker.pb.go +++ b/internal/machine/api/pb/docker.pb.go @@ -461,7 +461,7 @@ type MachineContainers struct { unknownFields protoimpl.UnknownFields Metadata *Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` - // JSON serialized []container.Summary. + // JSON serialized []container.ContainerJSON. Containers []byte `protobuf:"bytes,2,opt,name=containers,proto3" json:"containers,omitempty"` } diff --git a/internal/machine/api/pb/docker.proto b/internal/machine/api/pb/docker.proto index 81b58c33..5f01a431 100644 --- a/internal/machine/api/pb/docker.proto +++ b/internal/machine/api/pb/docker.proto @@ -67,7 +67,7 @@ message ListContainersResponse { message MachineContainers { Metadata metadata = 1; - // JSON serialized []container.Summary. + // JSON serialized []container.ContainerJSON. bytes containers = 2; } diff --git a/internal/machine/caddyfile/controller.go b/internal/machine/caddyfile/controller.go index f3aadbbc..4d748af9 100644 --- a/internal/machine/caddyfile/controller.go +++ b/internal/machine/caddyfile/controller.go @@ -89,18 +89,18 @@ func (c *Controller) Run(ctx context.Context) error { } } -// filterAvailableContainers filters out containers that are likely unavailable from this machine. The availability +// filterAvailableContainers filters out containers from this machine that are likely unavailable. The availability // is determined by the cluster membership state of the machine that the container is running on. // TODO: implement machine membership check using Corrossion Admin client. -func (c *Controller) filterAvailableContainers(containerRecords []*store.ContainerRecord) ([]*api.Container, error) { - containers := make([]*api.Container, len(containerRecords)) +func (c *Controller) filterAvailableContainers(containerRecords []store.ContainerRecord) ([]api.Container, error) { + containers := make([]api.Container, len(containerRecords)) for i, cr := range containerRecords { containers[i] = cr.Container } return containers, nil } -func (c *Controller) generateConfig(containers []*api.Container) error { +func (c *Controller) generateConfig(containers []api.Container) error { // Maps hostnames to lists of upstreams (container IP:port pairs). httpHostUpstreams := make(map[string][]string) httpsHostUpstreams := make(map[string][]string) diff --git a/internal/machine/docker/client.go b/internal/machine/docker/client.go index 6f0be8e0..7162700b 100644 --- a/internal/machine/docker/client.go +++ b/internal/machine/docker/client.go @@ -150,7 +150,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St type MachineContainers struct { Metadata *pb.Metadata - Containers []types.Container + Containers []types.ContainerJSON } func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions) ([]MachineContainers, error) { diff --git a/internal/machine/docker/manager.go b/internal/machine/docker/manager.go index 8f725b7b..c77e7851 100644 --- a/internal/machine/docker/manager.go +++ b/internal/machine/docker/manager.go @@ -149,8 +149,9 @@ func (m *Manager) syncContainersToStore(ctx context.Context) error { if err != nil { return fmt.Errorf("list containers from store: %w", err) } + // List only Uncloud service containers identified by their labels. - containers, err := m.client.ContainerList(ctx, dockercontainer.ListOptions{ + containerSummaries, err := m.client.ContainerList(ctx, dockercontainer.ListOptions{ Filters: filters.NewArgs( filters.Arg("label", api.LabelServiceID), filters.Arg("label", api.LabelServiceName), @@ -161,11 +162,21 @@ func (m *Manager) syncContainersToStore(ctx context.Context) error { return fmt.Errorf("list Docker containers: %w", err) } - // Delete containers that are not present in the Docker daemon from the store. + // Inspect each container to get the full container details. + containers := make([]api.Container, len(containerSummaries)) + for i, cs := range containerSummaries { + ctr, err := m.client.ContainerInspect(ctx, cs.ID) + if err != nil { + return fmt.Errorf("inspect container '%s': %w", cs.ID, err) + } + containers[i] = api.Container{ContainerJSON: ctr} + } + + // Delete containers from the store that are no longer present in the Docker daemon. var deleteIDs []string for _, sc := range storeContainers { found := false - for i, _ := range containers { + for i := range containers { if containers[i].ID == sc.Container.ID { found = true break @@ -184,8 +195,7 @@ func (m *Manager) syncContainersToStore(ctx context.Context) error { } // Create or update the current Docker containers in the store. - for _, dc := range containers { - c := &api.Container{Container: dc} + for _, c := range containers { if err = m.store.CreateOrUpdateContainer(ctx, c, m.machineID); err != nil { storeErr = errors.Join(storeErr, fmt.Errorf("create or update container %q: %w", c.ID, err)) } diff --git a/internal/machine/docker/manager_darwin.go b/internal/machine/docker/manager_darwin.go index abb1f5b0..cc11192a 100644 --- a/internal/machine/docker/manager_darwin.go +++ b/internal/machine/docker/manager_darwin.go @@ -9,6 +9,6 @@ import ( ) // EnsureUncloudNetwork is a stub for darwin. -func (d *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) error { +func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) error { return fmt.Errorf("not supported on darwin") } diff --git a/internal/machine/docker/manager_linux.go b/internal/machine/docker/manager_linux.go index adb86d7e..d6744250 100644 --- a/internal/machine/docker/manager_linux.go +++ b/internal/machine/docker/manager_linux.go @@ -14,10 +14,10 @@ import ( // EnsureUncloudNetwork creates the Docker bridge network NetworkName with the provided machine subnet // if it doesn't exist. If the network exists but has a different subnet, it removes and recreates the network. // It also configures iptables to allow container access from the WireGuard network. -func (d *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) error { +func (m *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) error { // Ensure the Docker network 'uncloud' is created with the correct subnet. needsCreation := false - nw, err := d.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}) + nw, err := m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}) if err != nil { if !client.IsErrNotFound(err) { return fmt.Errorf("inspect Docker network %q: %w", NetworkName, err) @@ -29,7 +29,7 @@ func (d *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) slog.Info( "Removing Docker network with old subnet.", "name", NetworkName, "subnet", nw.IPAM.Config[0].Subnet, ) - if err = d.client.NetworkRemove(ctx, NetworkName); err != nil { + if err = m.client.NetworkRemove(ctx, NetworkName); err != nil { // It can still fail if the network is in use by a container. Leave it to the user to resolve the issue. return fmt.Errorf("remove Docker network %q: %w", NetworkName, err) } @@ -37,7 +37,7 @@ func (d *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) } if needsCreation { - if _, err = d.client.NetworkCreate( + if _, err = m.client.NetworkCreate( ctx, NetworkName, dnetwork.CreateOptions{ Driver: "bridge", Scope: "local", @@ -54,7 +54,7 @@ func (d *Manager) EnsureUncloudNetwork(ctx context.Context, subnet netip.Prefix) } slog.Info("Docker network created.", "name", NetworkName, "subnet", subnet.String()) - if nw, err = d.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}); err != nil { + if nw, err = m.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{}); err != nil { return fmt.Errorf("inspect Docker network %q: %w", NetworkName, err) } } diff --git a/internal/machine/docker/server.go b/internal/machine/docker/server.go index 917c7c68..7720ab3e 100644 --- a/internal/machine/docker/server.go +++ b/internal/machine/docker/server.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" @@ -144,10 +145,18 @@ func (s *Server) ListContainers(ctx context.Context, req *pb.ListContainersReque } } - containers, err := s.client.ContainerList(ctx, opts) + containerSummaries, err := s.client.ContainerList(ctx, opts) if err != nil { return nil, status.Errorf(codes.Internal, err.Error()) } + containers := make([]types.ContainerJSON, len(containerSummaries)) + for i, cs := range containerSummaries { + c, err := s.client.ContainerInspect(ctx, cs.ID) + if err != nil { + return nil, status.Errorf(codes.Internal, "inspect container %s: %v", cs.ID, err) + } + containers[i] = c + } containersBytes, err := json.Marshal(containers) if err != nil { diff --git a/internal/machine/store/container.go b/internal/machine/store/container.go index 9a1b7da3..2c15b9a5 100644 --- a/internal/machine/store/container.go +++ b/internal/machine/store/container.go @@ -22,7 +22,7 @@ const ( ) type ContainerRecord struct { - Container *api.Container + Container api.Container MachineID string SyncStatus string UpdatedAt time.Time @@ -47,8 +47,11 @@ type DeleteOptions struct { // CreateOrUpdateContainer creates a new container record or updates an existing one in the store database. // The container is associated with the given machine ID that indicates which machine the container is running on. -func (s *Store) CreateOrUpdateContainer(ctx context.Context, c *api.Container, machineID string) error { - cJSON, err := json.Marshal(c) +func (s *Store) CreateOrUpdateContainer(ctx context.Context, ctr api.Container, machineID string) error { + // Remove the environment variables from the container record before storing it in the database + // to avoid leaking secrets. + ctr.Config.Env = nil + cJSON, err := json.Marshal(ctr) if err != nil { return fmt.Errorf("marshal container: %w", err) } @@ -63,19 +66,19 @@ func (s *Store) CreateOrUpdateContainer(ctx context.Context, c *api.Container, m updated_at = excluded.updated_at WHERE containers.container != excluded.container OR containers.machine_id != excluded.machine_id`, - c.ID, string(cJSON), machineID, SyncStatusSynced) + ctr.ID, string(cJSON), machineID, SyncStatusSynced) if err != nil { return fmt.Errorf("upsert query: %w", err) } if res.RowsAffected > 0 { - slog.Debug("Container record updated in store DB.", "id", c.ID, "machine_id", machineID) + slog.Debug("Container record updated in store DB.", "id", ctr.ID, "machine_id", machineID) } return nil } // ListContainers returns a list of container records from the store database that match the given options. -func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]*ContainerRecord, error) { +func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]ContainerRecord, error) { q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers"). Where(sq.Eq{"sync_status": SyncStatusSynced}) @@ -105,7 +108,7 @@ func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]*Contai } defer rows.Close() - var containers []*ContainerRecord + var containers []ContainerRecord var cJSON, machineID, syncStatus, updatedAtStr string var updatedAt time.Time @@ -121,8 +124,8 @@ func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]*Contai if updatedAt, err = time.Parse(time.DateTime, updatedAtStr); err != nil { return nil, fmt.Errorf("parse updated_at: %w", err) } - containers = append(containers, &ContainerRecord{ - Container: &c, + containers = append(containers, ContainerRecord{ + Container: c, MachineID: machineID, SyncStatus: syncStatus, UpdatedAt: updatedAt, @@ -158,7 +161,7 @@ func (s *Store) DeleteContainers(ctx context.Context, opts DeleteOptions) error // SubscribeContainers returns a list of containers and a channel that signals changes to the list. The channel doesn't // receive any values, it just signals when a container(s) has been added, updated, or deleted in the database. -func (s *Store) SubscribeContainers(ctx context.Context) ([]*ContainerRecord, <-chan struct{}, error) { +func (s *Store) SubscribeContainers(ctx context.Context) ([]ContainerRecord, <-chan struct{}, error) { // TODO: figure out whether we need sync_status at all. q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers"). Where(sq.Eq{"sync_status": SyncStatusSynced}) @@ -172,7 +175,7 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]*ContainerRecord, <- return nil, nil, err } - var containers []*ContainerRecord + var containers []ContainerRecord var cJSON, updatedAtStr string rows := sub.Rows() @@ -188,7 +191,7 @@ func (s *Store) SubscribeContainers(ctx context.Context) ([]*ContainerRecord, <- if cr.UpdatedAt, err = time.Parse(time.DateTime, updatedAtStr); err != nil { return nil, nil, fmt.Errorf("parse updated_at: %w", err) } - containers = append(containers, &cr) + containers = append(containers, cr) } events, err := sub.Changes() if err != nil { diff --git a/internal/machine/store/schema.sql b/internal/machine/store/schema.sql index 5ab1da63..dc2cbce1 100644 --- a/internal/machine/store/schema.sql +++ b/internal/machine/store/schema.sql @@ -22,8 +22,8 @@ CREATE TABLE containers -- container is a JSON-serialized api.Container struct. container TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(container)), machine_id TEXT NOT NULL DEFAULT '', - service_id TEXT AS (json_extract(container, '$.Labels."uncloud.service.id"')), - service_name TEXT AS (json_extract(container, '$.Labels."uncloud.service.name"')), + service_id TEXT AS (json_extract(container, '$.Config.Labels."uncloud.service.id"')), + service_name TEXT AS (json_extract(container, '$.Config.Labels."uncloud.service.name"')), -- sync_status indicates if the record reflects the actual Docker state of the container. sync_status TEXT NOT NULL DEFAULT '', -- updated_at is the last time the record was updated.