diff --git a/framework/.changeset/v0.14.4.md b/framework/.changeset/v0.14.4.md new file mode 100644 index 000000000..db718592c --- /dev/null +++ b/framework/.changeset/v0.14.4.md @@ -0,0 +1 @@ +- Compatibility testing CLI \ No newline at end of file diff --git a/framework/cmd/main.go b/framework/cmd/main.go index d3f9b753b..062cea142 100644 --- a/framework/cmd/main.go +++ b/framework/cmd/main.go @@ -258,9 +258,112 @@ Be aware that any TODO requires your attention before your run the final test! }, }, { - Name: "config", + Name: "compat", Aliases: []string{"c"}, - Usage: "Shapes your test config, removes outputs, formatting ,etc", + Usage: "Performs cluster compatibility testing", + Subcommands: []*cli.Command{ + { + Name: "restore", + Aliases: []string{"r"}, + Usage: "Restores back to develop", + Action: func(c *cli.Context) error { + return framework.RestoreToDevelop() + }, + }, + { + Name: "backward", + Aliases: []string{"b"}, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "versions_back", + Aliases: []string{"v"}, + Usage: "How many versions back to test", + Value: 1, + }, + &cli.IntFlag{ + Name: "nodes", + Aliases: []string{"n"}, + Usage: "How many nodes to upgrade", + Value: 3, + }, + &cli.StringFlag{ + Name: "buildcmd", + Aliases: []string{"b"}, + Usage: "Environment build command", + Value: "just cli", + }, + &cli.StringFlag{ + Name: "envcmd", + Aliases: []string{"e"}, + Usage: "Environment bootstrap command", + }, + &cli.StringFlag{ + Name: "testcmd", + Aliases: []string{"t"}, + Usage: "Test verification command", + }, + &cli.StringSliceFlag{ + Name: "include", + Usage: "Patterns to include specific tags (e.g., beta,rc,v0,v1)", + }, + &cli.StringSliceFlag{ + Name: "exclude", + Usage: "Patterns to exclude specific tags (e.g., beta,rc,v0,v1)", + Value: cli.NewStringSlice("beta", "rc", "v0", "v1", "ccip", "cre", "datastreams"), + }, + }, + Usage: "Rollbacks N versions back, runs the test the upgrades CL nodes with new versions", + Action: func(c *cli.Context) error { + versionsBack := c.Int("versions_back") + include := c.StringSlice("include") + exclude := c.StringSlice("exclude") + + buildcmd := c.String("buildcmd") + envcmd := c.String("envcmd") + testcmd := c.String("testcmd") + nodes := c.String("nodes") + // test logic is: + // - rollback to selected tag + // - spin up the env and perform the initial smoke test + // - upgrade some CL nodes + // - perform the test again + tags, err := framework.RollbackToEarliestSemverTag(versionsBack, include, exclude) + if err != nil { + return err + } + if envcmd == "" || testcmd == "" { + framework.L.Info().Msg("No envcmd or testcmd provided, skipping") + return nil + } + if _, err := framework.ExecCmdWithContext(c.Context, framework.L, buildcmd); err != nil { + return err + } + if _, err := framework.ExecCmdWithContext(c.Context, framework.L, envcmd); err != nil { + return err + } + if _, err := framework.ExecCmd(framework.L, testcmd); err != nil { + return err + } + tag := strings.ReplaceAll(tags[0], "v", "") + for i := range nodes { + if err := framework.UpgradeContainer( + c.Context, + fmt.Sprintf("don-node%d", i), + fmt.Sprintf("smartcontract/chainlink:%s", tag)); err != nil { + return err + } + } + if _, err := framework.ExecCmd(framework.L, testcmd); err != nil { + return err + } + return nil + }, + }, + }, + }, + { + Name: "config", + Usage: "Shapes your test config, removes outputs, formatting ,etc", Subcommands: []*cli.Command{ { Name: "fmt", diff --git a/framework/compat.go b/framework/compat.go new file mode 100644 index 000000000..b05213b7c --- /dev/null +++ b/framework/compat.go @@ -0,0 +1,343 @@ +package framework + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/rs/zerolog" + + "github.com/Masterminds/semver/v3" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ecr" +) + +/* + * This file contains functions to verify backward/forward/inter-product compatibility of products + * which are using devenv. + */ + +// UpgradeContainer stops a container, removes it, and creates a new one with the specified image +func UpgradeContainer(ctx context.Context, containerName, newImage string) error { + L = L.With(). + Str("Container", containerName). + Str("Image", newImage). + Logger() + L.Info().Msg("Starting container reboot with new image") + cli, err := client.NewClientWithOpts( + client.FromEnv, + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return fmt.Errorf("failed to create Docker client: %w", err) + } + defer cli.Close() + inspect, err := cli.ContainerInspect(ctx, containerName) + if err != nil { + return fmt.Errorf("failed to inspect container %s: %w", containerName, err) + } + L.Info().Msg("Stopping container") + stopOpts := container.StopOptions{} + if err := cli.ContainerStop(ctx, containerName, stopOpts); err != nil { + return fmt.Errorf("failed to stop container %s: %w", containerName, err) + } + L.Info().Msg("Container stopped successfully") + L.Info().Msg("Removing container") + // keep the volumes + removeOpts := container.RemoveOptions{RemoveVolumes: false} + if err := cli.ContainerRemove(ctx, containerName, removeOpts); err != nil { + return fmt.Errorf("failed to remove container %s: %w", containerName, err) + } + L.Info().Msg("Container removed successfully") + L.Info().Msg("Pulling new image") + pullReader, err := cli.ImagePull(ctx, newImage, image.PullOptions{}) + if err != nil { + return fmt.Errorf("failed to pull image %s: %w", newImage, err) + } + defer pullReader.Close() + + // log pull process for debug + if L.GetLevel() <= zerolog.DebugLevel { + io.Copy(os.Stdout, pullReader) + } else { + io.Copy(io.Discard, pullReader) + } + L.Info().Msg("Image pulled successfully") + + L.Info().Msg("Creating new container with updated image") + inspect.Config.Image = newImage + + networkingConfig := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + "ctf": { + NetworkID: "ctf", + }, + }, + } + createResp, err := cli.ContainerCreate( + ctx, + inspect.Config, + inspect.HostConfig, + networkingConfig, + nil, + containerName, + ) + if err != nil { + return fmt.Errorf("failed to create container with image %s: %w", newImage, err) + } + L.Debug(). + Str("ContainerID", createResp.ID). + Msg("Container created") + L.Info().Msg("Starting new container") + startOpts := container.StartOptions{} + if err := cli.ContainerStart(ctx, createResp.ID, startOpts); err != nil { + return fmt.Errorf("failed to start container %s: %w", containerName, err) + } + L.Info(). + Str("ContainerID", createResp.ID[:12]). + Msg("Container successfully rebooted with new image") + return nil +} + +// RestoreToDevelop restores git back to the develop branch +func RestoreToDevelop() error { + _, err := ExecCmd(L, "git checkout develop") + if err != nil { + return fmt.Errorf("failed to checkout develop branch: %w", err) + } + + L.Info(). + Str("Branch", "develop"). + Msg("Successfully restored to develop branch") + return nil +} + +// RollbackToEarliestSemverTag gets all semver tags, sorts them, and rolls back to the earliest tag +// returns all the tags starting from the oldest one +func RollbackToEarliestSemverTag(tagsBack int, include, exclude []string) ([]string, error) { + output, err := ExecCmd(L, "git tag --list") + if err != nil { + return nil, fmt.Errorf("failed to list git tags: %w", err) + } + + tags := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(tags) == 0 || (len(tags) == 1 && tags[0] == "") { + return nil, fmt.Errorf("no tags found in repository") + } + + sortedDesc := SortSemverTags(tags, include, exclude) + if len(sortedDesc) == 0 { + return nil, fmt.Errorf("no valid semver tags found") + } + + remainingTags := sortedDesc + if len(sortedDesc) > tagsBack { + remainingTags = sortedDesc[:tagsBack] + } + earliestTag := remainingTags[len(remainingTags)-1] + + L.Info(). + Int("TotalValidTags", len(sortedDesc)). + Strs("SelectedTags", remainingTags). + Str("EarliestTag", earliestTag). + Msg("Selected previous tag") + + _, err = ExecCmd(L, "git checkout "+earliestTag) + if err != nil { + L.Error(). + Str("Tag", earliestTag). + Err(err). + Msg("Failed to checkout tag") + return nil, fmt.Errorf("failed to checkout tag %s: %w", earliestTag, err) + } + + L.Info(). + Str("Tag", earliestTag). + Msg("Successfully rolled back to tag") + return remainingTags, nil +} + +type RaneSOTResponseBody struct { + Nodes []struct { + NOP string `json:"nop"` + Version string `json:"version"` + } `json:"nodes"` +} + +// GetTagsFromURL fetches tags from a JSON endpoint and applies filtering +func GetTagsFromURL(url, imageTagSuffix string, nopSuffixes, ignores []string) ([]string, error) { + L.Info(). + Str("URL", url). + Str("ImageTagSuffix", imageTagSuffix). + Strs("NOPs", nopSuffixes). + Strs("IgnoreSuffix", ignores). + Msg("Fetching tags from snapshot") + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch URL: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + var response RaneSOTResponseBody + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + tags := make([]string, 0) + seenTags := make(map[string]bool, 0) + // check all the nodes for uniq tags + for _, node := range response.Nodes { + version := node.Version + nop := node.NOP + // skip if we are not interested in this NOP + for _, ns := range nopSuffixes { + if !strings.Contains(nop, ns) { + continue + } + } + + // skip if version is empty + if version == "" { + continue + } + + // skip if we ignore some images + ignored := false + for _, ignore := range ignores { + if strings.Contains(version, ignore) { + ignored = true + break + } + } + + if strings.Contains(version, imageTagSuffix) && !ignored { + if _, ok := seenTags[version]; ok { + continue + } + tags = append(tags, version) + seenTags[version] = true + } + } + return tags, nil +} + +// GetECRRepositoryTags returns a list of image tags from an ECR repository +func GetECRRepositoryTags(suffix, repoName, registryID, region string, ignores []string) ([]string, error) { + fmt.Printf("Fetching tags for repository: %s in region %s\n", repoName, region) + + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithRegion(region), + ) + if err != nil { + return nil, fmt.Errorf("config load error: %w", err) + } + + client := ecr.NewFromConfig(cfg) + + input := &ecr.DescribeImagesInput{ + RepositoryName: aws.String(repoName), + RegistryId: aws.String(registryID), + MaxResults: aws.Int32(1000), // Max allowed is 1000 + } + + var allTags []string + pageCount := 0 + imageCount := 0 + taggedImageCount := 0 + + paginator := ecr.NewDescribeImagesPaginator(client, input) + for paginator.HasMorePages() { + pageCount++ + page, err := paginator.NextPage(context.TODO()) + if err != nil { + return nil, fmt.Errorf("failed to describe images page %d: %w", pageCount, err) + } + + fmt.Printf("Processing page %d with %d images\n", pageCount, len(page.ImageDetails)) + + for _, image := range page.ImageDetails { + imageCount++ + if len(image.ImageTags) > 0 { + taggedImageCount++ + for _, t := range image.ImageTags { + ignored := false + for _, ignore := range ignores { + if strings.Contains(t, ignore) { + ignored = true + } + } + if strings.Contains(t, suffix) && !ignored { + allTags = append(allTags, t) + } + } + } + } + } + return allTags, nil +} + +// SortSemverTags parses valid versions and returns them sorted from latest to lowest +func SortSemverTags(versions []string, include []string, exclude []string) []string { + parsedVersions := make([]*semver.Version, 0) + for _, v := range versions { + parsed, err := semver.NewVersion(v) + if err != nil { + L.Debug(). + Str("Tag", v). + Msg("Skipping invalid semver tag") + continue + } + parsedVersions = append(parsedVersions, parsed) + } + + sort.Slice(parsedVersions, func(i, j int) bool { + return parsedVersions[i].GreaterThan(parsedVersions[j]) + }) + + result := make([]string, len(parsedVersions)) + for i, v := range parsedVersions { + result[i] = v.Original() + } + + // ignore non GA tags + tags := make([]string, 0) + for _, r := range result { + excluded := false + for _, f := range exclude { + if strings.Contains(r, f) { + excluded = true + } + } + included := false + for _, f := range include { + if strings.Contains(r, f) { + included = true + } + } + if included && !excluded { + tags = append(tags, r) + } + } + L.Info(). + Strs("Include", include). + Strs("Exclude", exclude). + Msg("Applied filters") + return tags +} diff --git a/framework/go.mod b/framework/go.mod index c11d30f2d..5cd75f77e 100644 --- a/framework/go.mod +++ b/framework/go.mod @@ -11,6 +11,9 @@ require ( dario.cat/mergo v1.0.1 github.com/Masterminds/semver/v3 v3.4.0 github.com/avast/retry-go/v4 v4.6.1 + github.com/aws/aws-sdk-go-v2 v1.41.1 + github.com/aws/aws-sdk-go-v2/config v1.28.6 + github.com/aws/aws-sdk-go-v2/service/ecr v1.55.2 github.com/block-vision/sui-go-sdk v1.0.6 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/docker/docker v28.3.3+incompatible @@ -26,6 +29,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/consul/sdk v0.16.2 github.com/minio/minio-go/v7 v7.0.86 + github.com/moby/moby/api v1.53.0 github.com/pelletier/go-toml v1.9.5 github.com/pelletier/go-toml/v2 v2.2.3 github.com/pkg/errors v0.9.1 @@ -64,6 +68,17 @@ require ( github.com/armon/go-metrics v0.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go v1.55.6 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.47 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/framework/go.sum b/framework/go.sum index 19713f318..5de57def2 100644 --- a/framework/go.sum +++ b/framework/go.sum @@ -76,6 +76,34 @@ github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIc github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.28.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo= +github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko= +github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw= +github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/ecr v1.55.2 h1:eEiC82g/AJpNtBB73Par9iO/EbWXcl8vh6tbM8wb+EM= +github.com/aws/aws-sdk-go-v2/service/ecr v1.55.2/go.mod h1:cpYRXx5BkmS3mwWRKPbWSPKmyAUNL7aLWAPiiinwk/U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 h1:8eUsivBQzZHqe/3FE+cqwfH+0p5Jo8PFM/QYQSmeZ+M= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7/go.mod h1:kLPQvGUmxn/fqiCrDeohwG33bq2pQpGeY62yRO6Nrh0= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps= github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= @@ -577,6 +605,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w= +github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= diff --git a/framework/osutil.go b/framework/osutil.go new file mode 100644 index 000000000..71cf45a09 --- /dev/null +++ b/framework/osutil.go @@ -0,0 +1,87 @@ +package framework + +import ( + "bufio" + "context" + "io" + "os/exec" + "strings" + + "github.com/rs/zerolog" +) + +// ExecCmd executes a command and logs the output interactively +func ExecCmd(l zerolog.Logger, command string) ([]byte, error) { + return ExecCmdWithContext(context.Background(), l, command) +} + +// ExecCmdWithContext a command and logs the output interactively +func ExecCmdWithContext(ctx context.Context, l zerolog.Logger, command string) ([]byte, error) { + return ExecCmdWithOpts( + ctx, + l, + command, + func(m string) { + l.Debug().Str("Stream", "stdout").Msg(m) + }, + func(m string) { + l.Debug().Str("Stream", "stderr").Msg(m) + }, + ) +} + +func ExecCmdWithOpts(ctx context.Context, l zerolog.Logger, command string, stdoutFunc func(string), stderrFunc func(string)) ([]byte, error) { + c := strings.Split(command, " ") + l.Info().Interface("Command", command).Msg("Executing command") + cmd := exec.CommandContext(ctx, c[0], c[1:]...) // #nosec: G204 + + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + if err := cmd.Start(); err != nil { + return nil, err + } + + // create a buffer, listen to both pipe outputs, wait them to finish and merge output + // both log it and return merged output + var combinedBuf strings.Builder + stdoutDone := make(chan struct{}) + stderrDone := make(chan struct{}) + + go func() { + readStdPipe(stdout, func(m string) { + stdoutFunc(m) + combinedBuf.WriteString(m + "\n") + }) + close(stdoutDone) + }() + go func() { + readStdPipe(stderr, func(m string) { + stderrFunc(m) + combinedBuf.WriteString(m + "\n") + }) + close(stderrDone) + }() + <-stdoutDone + <-stderrDone + + err = cmd.Wait() + return []byte(combinedBuf.String()), err +} + +func readStdPipe(pipe io.ReadCloser, outputFunction func(string)) { + scanner := bufio.NewScanner(pipe) + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + m := scanner.Text() + if outputFunction != nil { + outputFunction(m) + } + } +}