diff --git a/.ci/Jenkinsfile b/.ci/Jenkinsfile index bd83b96f47..7db07ce651 100644 --- a/.ci/Jenkinsfile +++ b/.ci/Jenkinsfile @@ -97,10 +97,10 @@ pipeline { dir("${BASE_DIR}") { script { def basicTasks = [ - 'stack-command-default': generateTestCommandStage(command: 'test-stack-command-default', artifacts: ['build/elastic-stack-dump/stack/*/logs/*.log', 'build/elastic-stack-dump/stack/*/logs/fleet-server-internal/*']), - 'stack-command-oldest': generateTestCommandStage(command: 'test-stack-command-oldest', artifacts: ['build/elastic-stack-dump/stack/*/logs/*.log', 'build/elastic-stack-dump/stack/*/logs/fleet-server-internal/*']), - 'stack-command-7x': generateTestCommandStage(command: 'test-stack-command-7x', artifacts: ['build/elastic-stack-dump/stack/*/logs/*.log', 'build/elastic-stack-dump/stack/*/logs/fleet-server-internal/*']), - 'stack-command-8x': generateTestCommandStage(command: 'test-stack-command-8x', artifacts: ['build/elastic-stack-dump/stack/*/logs/*.log', 'build/elastic-stack-dump/stack/*/logs/fleet-server-internal/*']), + 'stack-command-default': generateTestCommandStage(command: 'test-stack-command-default', artifacts: ['build/elastic-stack-dump/stack/*/logs/*.log', 'build/elastic-stack-dump/stack/*/logs/fleet-server-internal/*', 'build/elastic-stack-status/*/*']), + 'stack-command-oldest': generateTestCommandStage(command: 'test-stack-command-oldest', artifacts: ['build/elastic-stack-dump/stack/*/logs/*.log', 'build/elastic-stack-dump/stack/*/logs/fleet-server-internal/*', 'build/elastic-stack-status/*/*']), + 'stack-command-7x': generateTestCommandStage(command: 'test-stack-command-7x', artifacts: ['build/elastic-stack-dump/stack/*/logs/*.log', 'build/elastic-stack-dump/stack/*/logs/fleet-server-internal/*', 'build/elastic-stack-status/*/*']), + 'stack-command-8x': generateTestCommandStage(command: 'test-stack-command-8x', artifacts: ['build/elastic-stack-dump/stack/*/logs/*.log', 'build/elastic-stack-dump/stack/*/logs/fleet-server-internal/*', 'build/elastic-stack-status/*/*']), 'check-packages-with-kind': generateTestCommandStage(command: 'test-check-packages-with-kind', artifacts: ['build/test-results/*.xml', 'build/kubectl-dump.txt', 'build/elastic-stack-dump/check-*/logs/*.log', 'build/elastic-stack-dump/check-*/logs/fleet-server-internal/*'], junitArtifacts: true, publishCoverage: true), 'check-packages-other': generateTestCommandStage(command: 'test-check-packages-other', artifacts: ['build/test-results/*.xml', 'build/elastic-stack-dump/check-*/logs/*.log', 'build/elastic-stack-dump/check-*/logs/fleet-server-internal/*'], junitArtifacts: true, publishCoverage: true), 'check-packages-with-custom-agent': generateTestCommandStage(command: 'test-check-packages-with-custom-agent', artifacts: ['build/test-results/*.xml', 'build/elastic-stack-dump/check-*/logs/*.log', 'build/elastic-stack-dump/check-*/logs/fleet-server-internal/*'], junitArtifacts: true, publishCoverage: true), diff --git a/cmd/stack.go b/cmd/stack.go index 3a5fc0a476..f65932a1ee 100644 --- a/cmd/stack.go +++ b/cmd/stack.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + "github.com/jedib0t/go-pretty/table" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -246,6 +247,21 @@ func setupStackCommand() *cobraext.Command { } dumpCommand.Flags().StringP(cobraext.StackDumpOutputFlagName, "", "elastic-stack-dump", cobraext.StackDumpOutputFlagDescription) + statusCommand := &cobra.Command{ + Use: "status", + Short: "Show status of the stack services", + RunE: func(cmd *cobra.Command, args []string) error { + servicesStatus, err := stack.Status() + if err != nil { + return errors.Wrap(err, "failed getting stack status") + } + + cmd.Println("Status of Elastic stack services:") + printStatus(cmd, servicesStatus) + return nil + }, + } + cmd := &cobra.Command{ Use: "stack", Short: "Manage the Elastic stack", @@ -257,7 +273,8 @@ func setupStackCommand() *cobraext.Command { downCommand, updateCommand, shellInitCommand, - dumpCommand) + dumpCommand, + statusCommand) return cobraext.NewCommand(cmd, cobraext.ContextGlobal) } @@ -300,3 +317,18 @@ func printInitConfig(cmd *cobra.Command, profile *profile.Profile) error { cmd.Printf("Password: %s\n", initConfig.ElasticsearchPassword) return nil } + +func printStatus(cmd *cobra.Command, servicesStatus []stack.ServiceStatus) { + if len(servicesStatus) == 0 { + cmd.Printf(" - No service running\n") + return + } + t := table.NewWriter() + t.AppendHeader(table.Row{"Service", "Version", "Status"}) + + for _, service := range servicesStatus { + t.AppendRow(table.Row{service.Name, service.Version, service.Status}) + } + t.SetStyle(table.StyleRounded) + cmd.Println(t.Render()) +} diff --git a/internal/compose/compose.go b/internal/compose/compose.go index 03c821d1bb..b40d4590ff 100644 --- a/internal/compose/compose.go +++ b/internal/compose/compose.go @@ -42,6 +42,7 @@ type Project struct { type Config struct { Services map[string]service } + type service struct { Ports []portMapping Environment map[string]string diff --git a/internal/docker/docker.go b/internal/docker/docker.go index f780c890c2..1bd6b8f0e5 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -27,6 +27,10 @@ type NetworkDescription struct { // ContainerDescription describes the Docker container. type ContainerDescription struct { + Config struct { + Image string + Labels map[string]string + } ID string State struct { Status string @@ -86,6 +90,22 @@ func ContainerID(containerName string) (string, error) { return containerIDs[0], nil } +// ContainerIDsWithLabel function returns all the container IDs filtering per label. +func ContainerIDsWithLabel(key, value string) ([]string, error) { + label := fmt.Sprintf("%s=%s", key, value) + cmd := exec.Command("docker", "ps", "-a", "--filter", "label="+label, "--format", "{{.ID}}") + errOutput := new(bytes.Buffer) + cmd.Stderr = errOutput + + logger.Debugf("output command: %s", cmd) + output, err := cmd.Output() + if err != nil { + return []string{}, errors.Wrapf(err, "error getting containers with label \"%s\" (stderr=%q)", label, errOutput.String()) + } + containerIDs := strings.Fields(string(output)) + return containerIDs, nil +} + // InspectNetwork function returns the network description for the selected network. func InspectNetwork(network string) ([]NetworkDescription, error) { cmd := exec.Command("docker", "network", "inspect", network) diff --git a/internal/stack/compose.go b/internal/stack/compose.go index 2d26510441..38b150df3f 100644 --- a/internal/stack/compose.go +++ b/internal/stack/compose.go @@ -6,14 +6,32 @@ package stack import ( "fmt" + "strings" "github.com/pkg/errors" "github.com/elastic/elastic-package/internal/compose" + "github.com/elastic/elastic-package/internal/docker" "github.com/elastic/elastic-package/internal/install" + "github.com/elastic/elastic-package/internal/logger" "github.com/elastic/elastic-package/internal/profile" ) +type ServiceStatus struct { + Name string + Status string + Version string +} + +const readyServicesSuffix = "is_ready" + +const ( + // serviceLabelDockerCompose is the label with the service name created by docker-compose + serviceLabelDockerCompose = "com.docker.compose.service" + // projectLabelDockerCompose is the label with the project name created by docker-compose + projectLabelDockerCompose = "com.docker.compose.project" +) + type envBuilder struct { vars []string } @@ -162,7 +180,60 @@ func withIsReadyServices(services []string) []string { var allServices []string for _, aService := range services { - allServices = append(allServices, aService, fmt.Sprintf("%s_is_ready", aService)) + allServices = append(allServices, aService, fmt.Sprintf("%s_%s", aService, readyServicesSuffix)) } return allServices } + +func dockerComposeStatus() ([]ServiceStatus, error) { + var services []ServiceStatus + // query directly to docker to avoid load environment variables (e.g. STACK_VERSION_VARIANT) and profiles + containerIDs, err := docker.ContainerIDsWithLabel(projectLabelDockerCompose, DockerComposeProjectName) + if err != nil { + return nil, err + } + + if len(containerIDs) == 0 { + return services, nil + } + + containerDescriptions, err := docker.InspectContainers(containerIDs...) + if err != nil { + return nil, err + } + + for _, containerDescription := range containerDescriptions { + service, err := newServiceStatus(&containerDescription) + if err != nil { + return nil, err + } + logger.Debugf("Adding Service: \"%v\"", service.Name) + services = append(services, *service) + } + + return services, nil +} + +func newServiceStatus(description *docker.ContainerDescription) (*ServiceStatus, error) { + service := ServiceStatus{ + Name: description.Config.Labels[serviceLabelDockerCompose], + Status: description.State.Status, + Version: getVersionFromDockerImage(description.Config.Image), + } + if description.State.Status == "running" { + service.Status = fmt.Sprintf("%v (%v)", service.Status, description.State.Health.Status) + } + if description.State.Status == "exited" { + service.Status = fmt.Sprintf("%v (%v)", service.Status, description.State.ExitCode) + } + + return &service, nil +} + +func getVersionFromDockerImage(dockerImage string) string { + fields := strings.Split(dockerImage, ":") + if len(fields) == 2 { + return fields[1] + } + return "latest" +} diff --git a/internal/stack/compose_test.go b/internal/stack/compose_test.go new file mode 100644 index 0000000000..5ad495b1b6 --- /dev/null +++ b/internal/stack/compose_test.go @@ -0,0 +1,169 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package stack + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/elastic/elastic-package/internal/docker" +) + +func TestGetVersionFromDockerImage(t *testing.T) { + cases := []struct { + dockerImage string + expected string + }{ + {"docker.test/test:1.42.0", "1.42.0"}, + {"docker.test/test", "latest"}, + } + + for _, c := range cases { + t.Run(c.dockerImage, func(t *testing.T) { + version := getVersionFromDockerImage(c.dockerImage) + assert.Equal(t, c.expected, version) + }) + } +} + +func TestNewServiceStatus(t *testing.T) { + cases := []struct { + name string + description docker.ContainerDescription + expected ServiceStatus + }{ + { + name: "commonService", + description: docker.ContainerDescription{ + Config: struct { + Image string + Labels map[string]string + }{ + Image: "docker.test:1.42.0", + Labels: map[string]string{"com.docker.compose.service": "myservice", "foo": "bar"}, + }, + ID: "123456789ab", + State: struct { + Status string + ExitCode int + Health *struct { + Status string + Log []struct { + Start time.Time + ExitCode int + Output string + } + } + }{ + Status: "running", + ExitCode: 0, + Health: &struct { + Status string + Log []struct { + Start time.Time + ExitCode int + Output string + } + }{ + Status: "healthy", + }, + }, + }, + expected: ServiceStatus{ + Name: "myservice", + Status: "running (healthy)", + Version: "1.42.0", + }, + }, + { + name: "exitedService", + description: docker.ContainerDescription{ + Config: struct { + Image string + Labels map[string]string + }{ + Image: "docker.test:1.42.0", + Labels: map[string]string{"com.docker.compose.service": "myservice", "foo": "bar"}, + }, + ID: "123456789ab", + State: struct { + Status string + ExitCode int + Health *struct { + Status string + Log []struct { + Start time.Time + ExitCode int + Output string + } + } + }{ + Status: "exited", + ExitCode: 128, + Health: nil, + }, + }, + expected: ServiceStatus{ + Name: "myservice", + Status: "exited (128)", + Version: "1.42.0", + }, + }, + { + name: "startingService", + description: docker.ContainerDescription{ + Config: struct { + Image string + Labels map[string]string + }{ + Image: "docker.test:1.42.0", + Labels: map[string]string{"com.docker.compose.service": "myservice", "foo": "bar"}, + }, + ID: "123456789ab", + State: struct { + Status string + ExitCode int + Health *struct { + Status string + Log []struct { + Start time.Time + ExitCode int + Output string + } + } + }{ + Status: "running", + ExitCode: 0, + Health: &struct { + Status string + Log []struct { + Start time.Time + ExitCode int + Output string + } + }{ + Status: "starting", + }, + }, + }, + expected: ServiceStatus{ + Name: "myservice", + Status: "running (starting)", + Version: "1.42.0", + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + serviceStatus, err := newServiceStatus(&c.description) + require.NoError(t, err) + assert.Equal(t, &c.expected, serviceStatus) + }) + } +} diff --git a/internal/stack/status.go b/internal/stack/status.go new file mode 100644 index 0000000000..ecde8a1e0a --- /dev/null +++ b/internal/stack/status.go @@ -0,0 +1,33 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package stack + +import ( + "sort" + "strings" + + "github.com/elastic/elastic-package/internal/logger" +) + +// Status shows the status for each service +func Status() ([]ServiceStatus, error) { + servicesStatus, err := dockerComposeStatus() + if err != nil { + return nil, err + } + + var services []ServiceStatus + for _, status := range servicesStatus { + if strings.Contains(status.Name, readyServicesSuffix) { + logger.Debugf("Filtering out service: %s", status.Name) + continue + } + services = append(services, status) + } + + sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name }) + + return services, nil +} diff --git a/scripts/test-stack-command.sh b/scripts/test-stack-command.sh index 44bbf16ebf..132593ae33 100755 --- a/scripts/test-stack-command.sh +++ b/scripts/test-stack-command.sh @@ -16,13 +16,31 @@ cleanup() { exit $r } +default_version() { + grep "DefaultStackVersion =" internal/install/stack_version.go | awk '{print $3}' | tr -d '"' +} + +clean_status_output() { + local output_file="$1" + cat ${output_file} | grep "│" | tr -d ' ' +} + trap cleanup EXIT ARG_VERSION="" +EXPECTED_VERSION=$(default_version) if [ "${VERSION}" != "default" ]; then ARG_VERSION="--version ${VERSION}" + EXPECTED_VERSION=${VERSION} fi +OUTPUT_PATH_STATUS="build/elastic-stack-status/${VERSION}" +mkdir -p ${OUTPUT_PATH_STATUS} + +# Initial status empty +elastic-package stack status 2> ${OUTPUT_PATH_STATUS}/initial.txt +grep "\- No service running" ${OUTPUT_PATH_STATUS}/initial.txt + # Update the stack elastic-package stack update -v ${ARG_VERSION} @@ -32,3 +50,25 @@ elastic-package stack up -d -v ${ARG_VERSION} # Verify it's accessible eval "$(elastic-package stack shellinit)" curl --cacert ${ELASTIC_PACKAGE_CA_CERT} -f ${ELASTIC_PACKAGE_KIBANA_HOST}/login | grep kbn-injected-metadata >/dev/null # healthcheck + +# Check status with running services +cat < ${OUTPUT_PATH_STATUS}/expected_running.txt +Status of Elastic stack services: +╭──────────────────┬─────────┬───────────────────╮ +│ SERVICE │ VERSION │ STATUS │ +├──────────────────┼─────────┼───────────────────┤ +│ elastic-agent │ ${EXPECTED_VERSION} │ running (healthy) │ +│ elasticsearch │ ${EXPECTED_VERSION} │ running (healthy) │ +│ fleet-server │ ${EXPECTED_VERSION} │ running (healthy) │ +│ kibana │ ${EXPECTED_VERSION} │ running (healthy) │ +│ package-registry │ latest │ running (healthy) │ +╰──────────────────┴─────────┴───────────────────╯ +EOF + +elastic-package stack status -v 2> ${OUTPUT_PATH_STATUS}/running.txt + +# Remove spaces to avoid issues with spaces between columns +clean_status_output "${OUTPUT_PATH_STATUS}/expected_running.txt" > ${OUTPUT_PATH_STATUS}/expected_no_spaces.txt +clean_status_output "${OUTPUT_PATH_STATUS}/running.txt" > ${OUTPUT_PATH_STATUS}/running_no_spaces.txt + +diff -q ${OUTPUT_PATH_STATUS}/running_no_spaces.txt ${OUTPUT_PATH_STATUS}/expected_no_spaces.txt