From d4727db8f9c64189def520317d1c8c269d0e02c7 Mon Sep 17 00:00:00 2001 From: p4u1 Date: Thu, 14 Dec 2023 13:15:24 +0000 Subject: [PATCH] feat: abra app logs shows task errors (!395) The log command now checks for the ready state in the task list. If it is not ready. It shows the task logs. This might look like this: ``` ERRO[0000] Service abra-test-recipe_default_app: State rejected: No such image: ngaaaax:1.21.0 ERRO[0000] Service abra-test-recipe_default_app: State preparing: ERRO[0000] Service abra-test-recipe_default_app: State rejected: No such image: ngaaaax:1.21.0 ERRO[0000] Service abra-test-recipe_default_app: State rejected: No such image: ngaaaax:1.21.0 ERRO[0000] Service abra-test-recipe_default_app: State rejected: No such image: ngaaaax:1.21.0 ``` Closes https://git.coopcloud.tech/coop-cloud/organising/issues/518 Reviewed-on: https://git.coopcloud.tech/coop-cloud/abra/pulls/395 Reviewed-by: decentral1se Co-authored-by: p4u1 Co-committed-by: p4u1 --- cli/app/logs.go | 145 +++++++++++++++++++++------------------------- pkg/config/app.go | 57 +++++++++++------- 2 files changed, 102 insertions(+), 100 deletions(-) diff --git a/cli/app/logs.go b/cli/app/logs.go index b1566f3f..b06634a2 100644 --- a/cli/app/logs.go +++ b/cli/app/logs.go @@ -2,75 +2,26 @@ package app import ( "context" - "fmt" "io" "os" + "slices" "sync" + "time" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/recipe" - "coopcloud.tech/abra/pkg/service" "coopcloud.tech/abra/pkg/upstream/stack" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/swarm" dockerClient "github.com/docker/docker/client" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) -var logOpts = types.ContainerLogsOptions{ - ShowStderr: true, - ShowStdout: true, - Since: "", - Until: "", - Timestamps: true, - Follow: true, - Tail: "20", - Details: false, -} - -// stackLogs lists logs for all stack services -func stackLogs(c *cli.Context, app config.App, client *dockerClient.Client) { - filters, err := app.Filters(true, false) - if err != nil { - logrus.Fatal(err) - } - - serviceOpts := types.ServiceListOptions{Filters: filters} - services, err := client.ServiceList(context.Background(), serviceOpts) - if err != nil { - logrus.Fatal(err) - } - - var wg sync.WaitGroup - for _, service := range services { - wg.Add(1) - go func(s string) { - if internal.StdErrOnly { - logOpts.ShowStdout = false - } - - logs, err := client.ServiceLogs(context.Background(), s, logOpts) - if err != nil { - logrus.Fatal(err) - } - defer logs.Close() - - _, err = io.Copy(os.Stdout, logs) - if err != nil && err != io.EOF { - logrus.Fatal(err) - } - }(service.ID) - } - - wg.Wait() - - os.Exit(0) -} - var appLogsCommand = cli.Command{ Name: "logs", Aliases: []string{"l"}, @@ -105,46 +56,84 @@ var appLogsCommand = cli.Command{ logrus.Fatalf("%s is not deployed?", app.Name) } - logOpts.Since = internal.SinceLogs - serviceName := c.Args().Get(1) - if serviceName == "" { - logrus.Debugf("tailing logs for all %s services", app.Recipe) - stackLogs(c, app, cl) - } else { - logrus.Debugf("tailing logs for %s", serviceName) - if err := tailServiceLogs(c, cl, app, serviceName); err != nil { - logrus.Fatal(err) - } + serviceNames := []string{} + if serviceName != "" { + serviceNames = []string{serviceName} + } + err = tailLogs(cl, app, serviceNames) + if err != nil { + logrus.Fatal(err) } return nil }, } -func tailServiceLogs(c *cli.Context, cl *dockerClient.Client, app config.App, serviceName string) error { - filters := filters.NewArgs() - filters.Add("name", fmt.Sprintf("%s_%s", app.StackName(), serviceName)) - - chosenService, err := service.GetService(context.Background(), cl, filters, internal.NoInput) +// tailLogs prints logs for the given app with optional service names to be +// filtered on. It also checks if the latest task is not runnning and then +// prints the past tasks. +func tailLogs(cl *dockerClient.Client, app config.App, serviceNames []string) error { + f, err := app.Filters(true, false, serviceNames...) if err != nil { - logrus.Fatal(err) + return err } - if internal.StdErrOnly { - logOpts.ShowStdout = false - } - - logs, err := cl.ServiceLogs(context.Background(), chosenService.ID, logOpts) + services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: f}) if err != nil { - logrus.Fatal(err) + return err } - defer logs.Close() - _, err = io.Copy(os.Stdout, logs) - if err != nil && err != io.EOF { - logrus.Fatal(err) + var wg sync.WaitGroup + for _, service := range services { + filters := filters.NewArgs() + filters.Add("name", service.Spec.Name) + tasks, err := cl.TaskList(context.Background(), types.TaskListOptions{Filters: f}) + if err != nil { + return err + } + if len(tasks) > 0 { + // Need to sort the tasks by the CreatedAt field in the inverse order. + // Otherwise they are in the reversed order and not sorted properly. + slices.SortFunc[[]swarm.Task](tasks, func(t1, t2 swarm.Task) int { + return int(t2.Meta.CreatedAt.Unix() - t1.Meta.CreatedAt.Unix()) + }) + lastTask := tasks[0].Status + if lastTask.State != swarm.TaskStateRunning { + for _, task := range tasks { + logrus.Errorf("[%s] %s State %s: %s", service.Spec.Name, task.Meta.CreatedAt.Format(time.RFC3339), task.Status.State, task.Status.Err) + } + } + } + + // Collect the logs in a go routine, so the logs from all services are + // collected in parallel. + wg.Add(1) + go func(serviceID string) { + logs, err := cl.ServiceLogs(context.Background(), serviceID, types.ContainerLogsOptions{ + ShowStderr: true, + ShowStdout: !internal.StdErrOnly, + Since: internal.SinceLogs, + Until: "", + Timestamps: true, + Follow: true, + Tail: "20", + Details: false, + }) + if err != nil { + logrus.Fatal(err) + } + defer logs.Close() + + _, err = io.Copy(os.Stdout, logs) + if err != nil && err != io.EOF { + logrus.Fatal(err) + } + }(service.ID) } + // Wait for all log streams to be closed. + wg.Wait() + return nil } diff --git a/pkg/config/app.go b/pkg/config/app.go index b3efed2a..a0d3e869 100644 --- a/pkg/config/app.go +++ b/pkg/config/app.go @@ -77,14 +77,32 @@ func StackName(appName string) string { return stackName } -// Filters retrieves exact app filters for querying the container runtime. Due -// to upstream issues, filtering works different depending on what you're +// Filters retrieves app filters for querying the container runtime. By default +// it filters on all services in the app. It is also possible to pass an +// otional list of service names, which get filtered instead. +// +// Due to upstream issues, filtering works different depending on what you're // querying. So, for example, secrets don't work with regex! The caller needs // to implement their own validation that the right secrets are matched. In // order to handle these cases, we provide the `appendServiceNames` / // `exactMatch` modifiers. -func (a App) Filters(appendServiceNames, exactMatch bool) (filters.Args, error) { +func (a App) Filters(appendServiceNames, exactMatch bool, services ...string) (filters.Args, error) { filters := filters.NewArgs() + if len(services) > 0 { + for _, serviceName := range services { + filters.Add("name", ServiceFilter(a.StackName(), serviceName, exactMatch)) + } + return filters, nil + } + + if appendServiceNames { + f := fmt.Sprintf("%s", a.StackName()) + if exactMatch { + f = fmt.Sprintf("^%s", f) + } + filters.Add("name", f) + return filters, nil + } composeFiles, err := GetComposeFiles(a.Recipe, a.Env) if err != nil { @@ -98,28 +116,23 @@ func (a App) Filters(appendServiceNames, exactMatch bool) (filters.Args, error) } for _, service := range compose.Services { - var filter string - - if appendServiceNames { - if exactMatch { - filter = fmt.Sprintf("^%s_%s", a.StackName(), service.Name) - } else { - filter = fmt.Sprintf("%s_%s", a.StackName(), service.Name) - } - } else { - if exactMatch { - filter = fmt.Sprintf("^%s", a.StackName()) - } else { - filter = fmt.Sprintf("%s", a.StackName()) - } - } - - filters.Add("name", filter) + f := ServiceFilter(a.StackName(), service.Name, exactMatch) + filters.Add("name", f) } return filters, nil } +// ServiceFilter creates a filter string for filtering a service in the docker +// container runtime. When exact match is true, it uses regex to match the +// string exactly. +func ServiceFilter(stack, service string, exact bool) string { + if exact { + return fmt.Sprintf("^%s_%s", stack, service) + } + return fmt.Sprintf("%s_%s", stack, service) +} + // ByServer sort a slice of Apps type ByServer []App @@ -340,7 +353,7 @@ func TemplateAppEnvSample(recipeName, appName, server, domain string) error { return fmt.Errorf("%s already exists?", appEnvPath) } - err = ioutil.WriteFile(appEnvPath, envSample, 0664) + err = ioutil.WriteFile(appEnvPath, envSample, 0o664) if err != nil { return err } @@ -602,7 +615,7 @@ func GetLabel(compose *composetypes.Config, stackName string, label string) stri // GetTimeoutFromLabel reads the timeout value from docker label "coop-cloud.${STACK_NAME}.TIMEOUT" and returns 50 as default value func GetTimeoutFromLabel(compose *composetypes.Config, stackName string) (int, error) { - var timeout = 50 // Default Timeout + timeout := 50 // Default Timeout var err error = nil if timeoutLabel := GetLabel(compose, stackName, "timeout"); timeoutLabel != "" { logrus.Debugf("timeout label: %s", timeoutLabel)