From 1fb8a3d7a0adc15470f0197cac8a868068f00aa5 Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Thu, 5 Dec 2024 17:21:54 +1000 Subject: [PATCH] add ListServices method and ls CLI command --- cmd/uncloud/machine/list.go | 6 +-- cmd/uncloud/main.go | 1 + cmd/uncloud/service/inspect.go | 14 +++---- cmd/uncloud/service/list.go | 53 +++++++++++++++++++++++++++ cmd/uncloud/service/root.go | 1 + cmd/uncloud/service/run.go | 6 +-- internal/cli/client/service.go | 67 ++++++++++++++++++++++++++++++++++ test/e2e/service_test.go | 15 ++++++++ 8 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 cmd/uncloud/service/list.go diff --git a/cmd/uncloud/machine/list.go b/cmd/uncloud/machine/list.go index 36c2e0bf..04aa474a 100644 --- a/cmd/uncloud/machine/list.go +++ b/cmd/uncloud/machine/list.go @@ -32,13 +32,13 @@ func NewListCommand() *cobra.Command { } func runList(ctx context.Context, uncli *cli.CLI, clusterName string) error { - c, err := uncli.ConnectCluster(ctx, clusterName) + client, err := uncli.ConnectCluster(ctx, clusterName) if err != nil { return fmt.Errorf("connect to cluster: %w", err) } - defer c.Close() + defer client.Close() - machines, err := c.ListMachines(ctx) + machines, err := client.ListMachines(ctx) if err != nil { return fmt.Errorf("list machines: %w", err) } diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index fa259271..7affd4f1 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -44,6 +44,7 @@ func main() { machine.NewRootCommand(), service.NewRootCommand(), service.NewInspectCommand(), + service.NewListCommand(), service.NewRunCommand(), ) cobra.CheckErr(cmd.Execute()) diff --git a/cmd/uncloud/service/inspect.go b/cmd/uncloud/service/inspect.go index e5137ad0..989b7cc2 100644 --- a/cmd/uncloud/service/inspect.go +++ b/cmd/uncloud/service/inspect.go @@ -10,7 +10,7 @@ import ( "time" "github.com/spf13/cobra" - client "uncloud/internal/cli" + "uncloud/internal/cli" ) type inspectOptions struct { @@ -25,7 +25,7 @@ func NewInspectCommand() *cobra.Command { 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) + uncli := cmd.Context().Value("cli").(*cli.CLI) opts.service = args[0] return inspect(cmd.Context(), uncli, &opts) }, @@ -37,19 +37,19 @@ func NewInspectCommand() *cobra.Command { return cmd } -func inspect(ctx context.Context, uncli *client.CLI, opts *inspectOptions) error { - cli, err := uncli.ConnectCluster(ctx, opts.cluster) +func inspect(ctx context.Context, uncli *cli.CLI, opts *inspectOptions) error { + client, err := uncli.ConnectCluster(ctx, opts.cluster) if err != nil { return fmt.Errorf("connect to cluster: %w", err) } - defer cli.Close() + defer client.Close() - svc, err := cli.InspectService(ctx, opts.service) + svc, err := client.InspectService(ctx, opts.service) if err != nil { return fmt.Errorf("inspect service: %w", err) } - machines, err := cli.ListMachines(ctx) + machines, err := client.ListMachines(ctx) if err != nil { return fmt.Errorf("list machines: %w", err) } diff --git a/cmd/uncloud/service/list.go b/cmd/uncloud/service/list.go new file mode 100644 index 00000000..d49e482e --- /dev/null +++ b/cmd/uncloud/service/list.go @@ -0,0 +1,53 @@ +package service + +import ( + "context" + "fmt" + "github.com/spf13/cobra" + "os" + "text/tabwriter" + "uncloud/internal/cli" +) + +func NewListCommand() *cobra.Command { + var cluster string + cmd := &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List services.", + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return runList(cmd.Context(), uncli, cluster) + }, + } + cmd.Flags().StringVarP( + &cluster, "cluster", "c", "", + "Name of the cluster. (default is the current cluster)", + ) + return cmd +} + +func runList(ctx context.Context, uncli *cli.CLI, clusterName string) error { + client, err := uncli.ConnectCluster(ctx, clusterName) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + services, err := client.ListServices(ctx) + if err != nil { + return fmt.Errorf("list services: %w", err) + } + + // Print the list of services in a table format. + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + if _, err = fmt.Fprintln(tw, "SERVICE ID\tNAME\tMODE\tREPLICAS"); err != nil { + return fmt.Errorf("write header: %w", err) + } + for _, s := range services { + if _, err = fmt.Fprintf(tw, "%s\t%s\t%s\t%d\n", s.ID, s.Name, s.Mode, len(s.Containers)); err != nil { + return fmt.Errorf("write row: %w", err) + } + } + return tw.Flush() +} diff --git a/cmd/uncloud/service/root.go b/cmd/uncloud/service/root.go index 3d355afa..df430d2b 100644 --- a/cmd/uncloud/service/root.go +++ b/cmd/uncloud/service/root.go @@ -10,6 +10,7 @@ func NewRootCommand() *cobra.Command { Short: "Manage services in an Uncloud cluster.", } cmd.AddCommand( + NewListCommand(), NewRunCommand(), ) return cmd diff --git a/cmd/uncloud/service/run.go b/cmd/uncloud/service/run.go index 95642abd..c82a3611 100644 --- a/cmd/uncloud/service/run.go +++ b/cmd/uncloud/service/run.go @@ -68,11 +68,11 @@ func runRun(ctx context.Context, uncli *cli.CLI, opts runOptions) error { return fmt.Errorf("invalid replication mode: %q", opts.mode) } - c, err := uncli.ConnectCluster(ctx, opts.cluster) + client, err := uncli.ConnectCluster(ctx, opts.cluster) if err != nil { return fmt.Errorf("connect to cluster: %w", err) } - defer c.Close() + defer client.Close() spec := api.ServiceSpec{ Container: api.ContainerSpec{ @@ -82,7 +82,7 @@ func runRun(ctx context.Context, uncli *cli.CLI, opts runOptions) error { Mode: opts.mode, Name: opts.name, } - resp, err := c.RunService(ctx, spec) + resp, err := client.RunService(ctx, spec) if err != nil { return fmt.Errorf("run service: %w", err) } diff --git a/internal/cli/client/service.go b/internal/cli/client/service.go index 9fe53666..70929a9a 100644 --- a/internal/cli/client/service.go +++ b/internal/cli/client/service.go @@ -437,3 +437,70 @@ func (cli *Client) RemoveService(ctx context.Context, id string) error { } return err } + +// ListServices returns a list of all services and their containers. +func (cli *Client) ListServices(ctx context.Context) ([]api.Service, error) { + machines, err := cli.ListMachines(ctx) + if err != nil { + return nil, fmt.Errorf("list machines: %w", err) + } + + // Broadcast the container list request to all available machines. + md := metadata.New(nil) + for _, m := range machines { + if m.State == pb.MachineMember_UP || m.State == pb.MachineMember_SUSPECT { + machineIP, _ := m.Machine.Network.ManagementIp.ToAddr() + md.Append("machines", machineIP.String()) + } + // TODO: warning about machines that are DOWN. + } + listCtx := metadata.NewOutgoingContext(ctx, md) + + // List only uncloud-managed containers that belong to some service. + opts := container.ListOptions{ + All: true, + Filters: filters.NewArgs( + filters.Arg("label", api.LabelServiceID), + filters.Arg("label", api.LabelManaged), + ), + } + machineContainers, err := cli.ListContainers(listCtx, opts) + if err != nil { + return nil, fmt.Errorf("list containers: %w", err) + } + + // TODO: optimise by extracting services from the list of all containers instead of inspecting each service. + // Most of the code can be reused in both InspectService and ListServices. + servicesByID := make(map[string]api.Service) + for _, mc := range machineContainers { + if mc.Metadata != nil && mc.Metadata.Error != "" { + // TODO: return failed machines in the response. + fmt.Printf("WARNING: failed to list containers on machine '%s': %s\n", + mc.Metadata.Machine, mc.Metadata.Error) + continue + } + + for _, c := range mc.Containers { + ctr := api.Container{Container: c} + if _, ok := servicesByID[ctr.ServiceID()]; ok { + continue + } + + svc, err := cli.InspectService(ctx, ctr.ServiceID()) + if err != nil { + if errors.Is(err, ErrNotFound) { + continue + } + return nil, fmt.Errorf("inspect service: %w", err) + } + + servicesByID[ctr.ServiceID()] = svc + } + } + + services := make([]api.Service, 0, len(servicesByID)) + for _, svc := range servicesByID { + services = append(services, svc) + } + return services, nil +} diff --git a/test/e2e/service_test.go b/test/e2e/service_test.go index 9af83fcd..f802d98c 100644 --- a/test/e2e/service_test.go +++ b/test/e2e/service_test.go @@ -56,6 +56,21 @@ func TestRunService(t *testing.T) { assert.Equal(t, name, svc.Name) assert.Equal(t, api.ServiceModeReplicated, svc.Mode) assert.Len(t, svc.Containers, 1) + + services, err := cli.ListServices(ctx) + require.NoError(t, err) + + assert.GreaterOrEqual(t, len(services), 1) + found := false + for _, s := range services { + if s.ID == svc.ID { + assert.Equal(t, name, s.Name) + assert.Equal(t, api.ServiceModeReplicated, s.Mode) + assert.Len(t, s.Containers, 1) + found = true + } + } + assert.True(t, found) }) t.Run("global mode", func(t *testing.T) {