From daada3bbe9c7df1971c836190305ea607b30222f Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Tue, 3 Dec 2024 10:56:29 +1000 Subject: [PATCH] add service inspect command, introduce squirrel for sql query building --- cmd/uncloud/main.go | 1 + cmd/uncloud/service/inspect.go | 95 +++++++++++++++++++ go.mod | 3 + go.sum | 6 ++ internal/cli/client/client.go | 6 +- internal/cli/client/service.go | 35 ++++--- internal/machine/docker/manager.go | 8 +- internal/machine/machine.go | 29 ++++-- internal/machine/store/container.go | 46 ++++----- internal/{docker => service}/container.go | 12 ++- .../{docker => service}/container_test.go | 2 +- internal/service/service.go | 27 +++--- 12 files changed, 205 insertions(+), 65 deletions(-) create mode 100644 cmd/uncloud/service/inspect.go rename internal/{docker => service}/container.go (83%) rename internal/{docker => service}/container_test.go (99%) diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index 2e1c8547..fa259271 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -43,6 +43,7 @@ func main() { cmd.AddCommand( machine.NewRootCommand(), service.NewRootCommand(), + service.NewInspectCommand(), service.NewRunCommand(), ) cobra.CheckErr(cmd.Execute()) diff --git a/cmd/uncloud/service/inspect.go b/cmd/uncloud/service/inspect.go new file mode 100644 index 00000000..3f49129e --- /dev/null +++ b/cmd/uncloud/service/inspect.go @@ -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() +} diff --git a/go.mod b/go.mod index f426d7cf..d1e7b64f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 02f752de..41d36bf4 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cli/client/client.go b/internal/cli/client/client.go index 0bb3c0b8..156d7064 100644 --- a/internal/cli/client/client.go +++ b/internal/cli/client/client.go @@ -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()) } diff --git a/internal/cli/client/service.go b/internal/cli/client/service.go index 90ec84ea..a92fef19 100644 --- a/internal/cli/client/service.go +++ b/internal/cli/client/service.go @@ -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 } diff --git a/internal/machine/docker/manager.go b/internal/machine/docker/manager.go index a0c0cb05..3dd26b81 100644 --- a/internal/machine/docker/manager.go +++ b/internal/machine/docker/manager.go @@ -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)) } diff --git a/internal/machine/machine.go b/internal/machine/machine.go index 719057cd..9373c56e 100644 --- a/internal/machine/machine.go +++ b/internal/machine/machine.go @@ -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 } diff --git a/internal/machine/store/container.go b/internal/machine/store/container.go index ca66d84e..f7f772b0 100644 --- a/internal/machine/store/container.go +++ b/internal/machine/store/container.go @@ -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) } diff --git a/internal/docker/container.go b/internal/service/container.go similarity index 83% rename from internal/docker/container.go rename to internal/service/container.go index ddb57aaa..52d41995 100644 --- a/internal/docker/container.go +++ b/internal/service/container.go @@ -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", ""] diff --git a/internal/docker/container_test.go b/internal/service/container_test.go similarity index 99% rename from internal/docker/container_test.go rename to internal/service/container_test.go index 19007ac5..304244b9 100644 --- a/internal/docker/container_test.go +++ b/internal/service/container_test.go @@ -1,4 +1,4 @@ -package docker +package service import ( "github.com/docker/docker/api/types" diff --git a/internal/service/service.go b/internal/service/service.go index e0dbc02b..f01362c5 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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 }