add service inspect command, introduce squirrel for sql query building

This commit is contained in:
Pavel Sviderski
2024-12-03 10:56:29 +10:00
parent 21d55dc69a
commit daada3bbe9
12 changed files with 205 additions and 65 deletions
+1
View File
@@ -43,6 +43,7 @@ func main() {
cmd.AddCommand(
machine.NewRootCommand(),
service.NewRootCommand(),
service.NewInspectCommand(),
service.NewRunCommand(),
)
cobra.CheckErr(cmd.Execute())
+95
View File
@@ -0,0 +1,95 @@
package service
import (
"context"
"fmt"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/go-units"
"google.golang.org/protobuf/types/known/emptypb"
"os"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
client "uncloud/internal/cli"
)
type inspectOptions struct {
service string
cluster string
}
func NewInspectCommand() *cobra.Command {
opts := inspectOptions{}
cmd := &cobra.Command{
Use: "inspect",
Short: "Display detailed information on a service.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*client.CLI)
opts.service = args[0]
return inspect(cmd.Context(), uncli, &opts)
},
}
cmd.Flags().StringVarP(
&opts.cluster, "cluster", "c", "",
"Name of the cluster. (default is the current cluster)",
)
return cmd
}
func inspect(ctx context.Context, uncli *client.CLI, opts *inspectOptions) error {
cli, err := uncli.ConnectCluster(ctx, opts.cluster)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer cli.Close()
svc, err := cli.InspectService(ctx, opts.service)
if err != nil {
return fmt.Errorf("inspect service: %w", err)
}
resp, err := cli.ListMachines(ctx, &emptypb.Empty{})
if err != nil {
return fmt.Errorf("list machines: %w", err)
}
machinesNamesByID := make(map[string]string)
for _, m := range resp.Machines {
machinesNamesByID[m.Machine.Id] = m.Machine.Name
}
fmt.Printf("ID: %s\n", svc.ID)
fmt.Printf("Name: %s\n", svc.Name)
fmt.Println()
// Print the list of containers in a table format.
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
if _, err = fmt.Fprintln(tw, "CONTAINER ID\tIMAGE\tCREATED\tSTATUS\tMACHINE"); err != nil {
return fmt.Errorf("write header: %w", err)
}
for _, ctr := range svc.Containers {
createdAt := time.Unix(ctr.Container.Created, 0)
created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
machine := machinesNamesByID[ctr.MachineID]
if machine == "" {
machine = ctr.MachineID
}
_, err = fmt.Fprintf(
tw,
"%s\t%s\t%s\t%s\t%s\n",
stringid.TruncateID(ctr.Container.ID),
ctr.Container.Image,
created,
ctr.Container.Status,
machine,
)
if err != nil {
return fmt.Errorf("write row: %w", err)
}
}
return tw.Flush()
}
+3
View File
@@ -47,6 +47,7 @@ require (
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
@@ -111,6 +112,8 @@ require (
github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
github.com/libp2p/go-libp2p v0.35.4 // indirect
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
+6
View File
@@ -18,6 +18,8 @@ github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYr
github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk=
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
@@ -346,6 +348,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8=
github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg=
github.com/libp2p/go-flow-metrics v0.1.0 h1:0iPhMI8PskQwzh57jB9WxIuIOQ0r+15PChFGkx3Q3WM=
+4 -2
View File
@@ -9,6 +9,8 @@ import (
"uncloud/internal/machine/docker"
)
var NotFound = errors.New("not found")
// Client is a client for the machine API.
type Client struct {
connector Connector
@@ -46,6 +48,6 @@ func New(ctx context.Context, connector Connector) (*Client, error) {
return c, nil
}
func (c *Client) Close() error {
return errors.Join(c.conn.Close(), c.connector.Close())
func (cli *Client) Close() error {
return errors.Join(cli.conn.Close(), cli.connector.Close())
}
+24 -11
View File
@@ -7,11 +7,12 @@ import (
"github.com/distribution/reference"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"slices"
"strings"
"uncloud/internal/docker"
"uncloud/internal/machine/api/pb"
machinedocker "uncloud/internal/machine/docker"
"uncloud/internal/secret"
@@ -34,7 +35,7 @@ type RunServiceResponse struct {
MachineName string
}
func (c *Client) RunService(ctx context.Context, opts *ServiceOptions) (RunServiceResponse, error) {
func (cli *Client) RunService(ctx context.Context, opts *ServiceOptions) (RunServiceResponse, error) {
var resp RunServiceResponse
image, err := reference.ParseDockerRef(opts.Image)
@@ -51,7 +52,7 @@ func (c *Client) RunService(ctx context.Context, opts *ServiceOptions) (RunServi
}
// Find a machine to run the service on.
listResp, err := c.ListMachines(ctx, &emptypb.Empty{})
listResp, err := cli.ListMachines(ctx, &emptypb.Empty{})
if err != nil {
return resp, fmt.Errorf("list machines: %w", err)
}
@@ -114,8 +115,8 @@ func (c *Client) RunService(ctx context.Context, opts *ServiceOptions) (RunServi
config := &container.Config{
Image: opts.Image,
Labels: map[string]string{
docker.LabelServiceID: serviceID,
docker.LabelServiceName: serviceName,
service.LabelServiceID: serviceID,
service.LabelServiceName: serviceName,
},
}
netConfig := &network.NetworkingConfig{
@@ -124,11 +125,11 @@ func (c *Client) RunService(ctx context.Context, opts *ServiceOptions) (RunServi
},
}
// TODO: pull image if it doesn't exist on the machine.
createResp, err := c.CreateContainer(ctx, config, nil, netConfig, nil, containerName)
createResp, err := cli.CreateContainer(ctx, config, nil, netConfig, nil, containerName)
if err != nil {
return resp, fmt.Errorf("create container: %w", err)
}
if err = c.StartContainer(ctx, createResp.ID, container.StartOptions{}); err != nil {
if err = cli.StartContainer(ctx, createResp.ID, container.StartOptions{}); err != nil {
return resp, fmt.Errorf("start container: %w", err)
}
@@ -157,11 +158,23 @@ func firstAvailableMachine(machines []*pb.MachineMember) (*pb.MachineMember, err
return nil, errors.New("no available machine to run the service")
}
func (c *Client) InspectService(ctx context.Context, id string) error {
_, err := c.MachineClient.InspectService(ctx, &pb.InspectServiceRequest{Id: id})
// InspectService returns detailed information about a service and its containers.
func (cli *Client) InspectService(ctx context.Context, id string) (service.Service, error) {
var svc service.Service
resp, err := cli.MachineClient.InspectService(ctx, &pb.InspectServiceRequest{Id: id})
if err != nil {
return err
if s, ok := status.FromError(err); ok {
if s.Code() == codes.NotFound {
return svc, NotFound
}
}
return svc, err
}
return errors.New("not implemented")
svc, err = service.FromProto(resp.Service)
if err != nil {
return svc, fmt.Errorf("from proto: %w", err)
}
return svc, nil
}
+4 -4
View File
@@ -10,8 +10,8 @@ import (
"github.com/docker/docker/client"
"log/slog"
"time"
"uncloud/internal/docker"
"uncloud/internal/machine/store"
"uncloud/internal/service"
)
const (
@@ -152,8 +152,8 @@ func (m *Manager) syncContainersToStore(ctx context.Context) error {
// List only Uncloud service containers identified by their labels.
containers, err := m.client.ContainerList(ctx, dockercontainer.ListOptions{
Filters: filters.NewArgs(
filters.Arg("label", docker.LabelServiceID),
filters.Arg("label", docker.LabelServiceName),
filters.Arg("label", service.LabelServiceID),
filters.Arg("label", service.LabelServiceName),
),
})
if err != nil {
@@ -185,7 +185,7 @@ func (m *Manager) syncContainersToStore(ctx context.Context) error {
// Create or update the current Docker containers in the store.
for _, dc := range containers {
c := &docker.Container{Container: dc}
c := &service.Container{Container: dc}
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))
}
+23 -6
View File
@@ -2,6 +2,7 @@ package machine
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/docker/docker/client"
@@ -686,6 +687,7 @@ func (m *Machine) Inspect(_ context.Context, _ *emptypb.Empty) (*pb.MachineInfo,
}, nil
}
// InspectService returns detailed information about a service and its containers.
func (m *Machine) InspectService(
ctx context.Context, req *pb.InspectServiceRequest,
) (*pb.InspectServiceResponse, error) {
@@ -698,11 +700,26 @@ func (m *Machine) InspectService(
if err != nil {
return nil, status.Errorf(codes.Internal, "list containers: %v", err)
}
if len(records) == 0 {
return nil, status.Error(codes.NotFound, "service not found")
}
fmt.Println("## records: ", records)
return &pb.InspectServiceResponse{
Service: &pb.Service{
Id: req.Id,
},
}, nil
containers := make([]*pb.Service_Container, len(records))
for i, r := range records {
containerJSON, err := json.Marshal(r.Container)
if err != nil {
return nil, status.Errorf(codes.Internal, "marshal container: %v", err)
}
containers[i] = &pb.Service_Container{
MachineId: r.MachineID,
Container: containerJSON,
}
}
svc := &pb.Service{
Id: records[0].Container.ServiceID(),
Name: records[0].Container.ServiceName(),
Containers: containers,
}
return &pb.InspectServiceResponse{Service: svc}, nil
}
+20 -26
View File
@@ -4,10 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
sq "github.com/Masterminds/squirrel"
"log/slog"
"strings"
"time"
"uncloud/internal/docker"
"uncloud/internal/service"
)
const (
@@ -21,7 +22,7 @@ const (
)
type ContainerRecord struct {
Container *docker.Container
Container *service.Container
MachineID string
SyncStatus string
UpdatedAt time.Time
@@ -46,7 +47,7 @@ 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 *docker.Container, machineID string) error {
func (s *Store) CreateOrUpdateContainer(ctx context.Context, c *service.Container, machineID string) error {
cJSON, err := json.Marshal(c)
if err != nil {
return fmt.Errorf("marshal container: %w", err)
@@ -75,34 +76,27 @@ func (s *Store) CreateOrUpdateContainer(ctx context.Context, c *docker.Container
// 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) {
query := "SELECT container, machine_id, sync_status, updated_at FROM containers"
var args []any
q := sq.Select("container", "machine_id", "sync_status", "updated_at").From("containers")
var whereConditions []string
if len(opts.MachineIDs) > 0 {
whereConditions = append(whereConditions, "machine_id IN (?"+strings.Repeat(", ?", len(opts.MachineIDs)-1)+")")
args = make([]any, len(opts.MachineIDs))
for i, id := range opts.MachineIDs {
args[i] = id
q = q.Where(sq.Eq{"machine_id": opts.MachineIDs})
}
if opts.ServiceIDOrName.ID != "" || opts.ServiceIDOrName.Name != "" {
var conditions []sq.Sqlizer
if opts.ServiceIDOrName.ID != "" {
conditions = append(conditions, sq.Eq{"service_id": opts.ServiceIDOrName.ID})
}
if opts.ServiceIDOrName.Name != "" {
conditions = append(conditions, sq.Eq{"service_name": opts.ServiceIDOrName.Name})
}
q = q.Where(sq.Or(conditions))
}
var serviceConditions []string
var serviceArgs []any
if opts.ServiceIDOrName.ID != "" {
serviceConditions = append(serviceConditions, "service_id = ?")
serviceArgs = append(serviceArgs, opts.ServiceIDOrName.ID)
query, args, err := q.ToSql()
if err != nil {
return nil, fmt.Errorf("build query: %w", err)
}
if opts.ServiceIDOrName.Name != "" {
serviceConditions = append(serviceConditions, "service_name = ?")
serviceArgs = append(serviceArgs, opts.ServiceIDOrName.Name)
}
if len(serviceConditions) > 0 {
whereConditions = append(whereConditions, "("+strings.Join(serviceConditions, " OR ")+")")
args = append(args, serviceArgs...)
}
query += " WHERE " + strings.Join(whereConditions, " AND ")
rows, err := s.corro.QueryContext(ctx, query, args...)
if err != nil {
@@ -119,7 +113,7 @@ func (s *Store) ListContainers(ctx context.Context, opts ListOptions) ([]*Contai
return nil, fmt.Errorf("scan container record: %w", err)
}
var c docker.Container
var c service.Container
if err = json.Unmarshal([]byte(cJSON), &c); err != nil {
return nil, fmt.Errorf("unmarshal container: %w", err)
}
@@ -1,4 +1,4 @@
package docker
package service
import (
"github.com/docker/docker/api/types"
@@ -14,6 +14,16 @@ type Container struct {
types.Container
}
// ServiceID returns the service ID that the container is part of.
func (c *Container) ServiceID() string {
return c.Labels[LabelServiceID]
}
// ServiceName returns the service name that the container is part of.
func (c *Container) ServiceName() string {
return c.Labels[LabelServiceName]
}
// 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", ""]
@@ -1,4 +1,4 @@
package docker
package service
import (
"github.com/docker/docker/api/types"
+13 -14
View File
@@ -3,7 +3,6 @@ package service
import (
"encoding/json"
"fmt"
"github.com/docker/docker/api/types"
"uncloud/internal/machine/api/pb"
)
@@ -15,19 +14,19 @@ const (
type Service struct {
ID string
Name string
Containers []Container
Containers []MachineContainer
}
type Container struct {
type MachineContainer struct {
MachineID string
Container types.Container
Container Container
}
func FromProto(s *pb.Service) (Service, error) {
var err error
containers := make([]Container, len(s.Containers))
for i, c := range s.Containers {
containers[i], err = containerFromProto(c)
containers := make([]MachineContainer, len(s.Containers))
for i, sc := range s.Containers {
containers[i], err = machineContainerFromProto(sc)
if err != nil {
return Service{}, err
}
@@ -40,14 +39,14 @@ func FromProto(s *pb.Service) (Service, error) {
}, nil
}
func containerFromProto(c *pb.Service_Container) (Container, error) {
var dockerCtr types.Container
if err := json.Unmarshal(c.Container, &dockerCtr); err != nil {
return Container{}, fmt.Errorf("unmarshal container: %w", err)
func machineContainerFromProto(sc *pb.Service_Container) (MachineContainer, error) {
var c Container
if err := json.Unmarshal(sc.Container, &c); err != nil {
return MachineContainer{}, fmt.Errorf("unmarshal container: %w", err)
}
return Container{
MachineID: c.MachineId,
Container: dockerCtr,
return MachineContainer{
MachineID: sc.MachineId,
Container: c,
}, nil
}