Skip to content

Commit

Permalink
Add cmd to download artifacts of ImageRunner (#734)
Browse files Browse the repository at this point in the history
* Add cmd to download artifacts of ImageRunner

* Render results

* Refine description

* rename runnerSvcTimeout

* revert some changes

* Refine artifacts download

* Refine artifacts download method

* fix lint

* refine err msg

* Rebase imagerunner cmd

* Revert some changes

* Update internal/cmd/imagerunner/artifacts.go

Co-authored-by: Alex Plischke <alex.plischke@saucelabs.com>

* Add doc for method

* Update internal/files/files.go

Co-authored-by: Alex Plischke <alex.plischke@saucelabs.com>

---------

Co-authored-by: Alex Plischke <alex.plischke@saucelabs.com>
  • Loading branch information
tianfeng92 and alexplischke authored Apr 4, 2023
1 parent 02dc5a5 commit 9d52e3e
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 59 deletions.
36 changes: 36 additions & 0 deletions internal/archive/zip/zip.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package zip

import (
"archive/zip"
"fmt"
"io"
"os"
"path"
Expand Down Expand Up @@ -100,3 +101,38 @@ func (w *Writer) Close() error {
}
return w.ZipFile.Close()
}

func Extract(targetDir string, file *zip.File) error {
fullPath := path.Join(targetDir, file.Name)

relPath, err := filepath.Rel(targetDir, fullPath)
if err != nil {
return err
}
if strings.Contains(relPath, "..") {
return fmt.Errorf("file %s is relative to an outside folder", file.Name)
}

folder := path.Dir(fullPath)
if err := os.MkdirAll(folder, 0755); err != nil {
return err
}

fd, err := os.Create(fullPath)
if err != nil {
return err
}
defer fd.Close()

rd, err := file.Open()
if err != nil {
return err
}
defer rd.Close()

_, err = io.Copy(fd, rd)
if err != nil {
return err
}
return fd.Close()
}
194 changes: 194 additions & 0 deletions internal/cmd/imagerunner/artifacts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package imagerunner

import (
"archive/zip"
"context"
"encoding/json"
"errors"
"fmt"
"os"

"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/ryanuber/go-glob"
szip "github.com/saucelabs/saucectl/internal/archive/zip"
cmds "github.com/saucelabs/saucectl/internal/cmd"
"github.com/saucelabs/saucectl/internal/files"
"github.com/saucelabs/saucectl/internal/imagerunner"
"github.com/saucelabs/saucectl/internal/segment"
"github.com/saucelabs/saucectl/internal/usage"
"github.com/spf13/cobra"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)

var defaultTableStyle = table.Style{
Name: "saucy",
Box: table.BoxStyle{
BottomLeft: "└",
BottomRight: "┘",
BottomSeparator: "",
EmptySeparator: text.RepeatAndTrim(" ", text.RuneCount("+")),
Left: "│",
LeftSeparator: "",
MiddleHorizontal: "─",
MiddleSeparator: "",
MiddleVertical: "",
PaddingLeft: " ",
PaddingRight: " ",
PageSeparator: "\n",
Right: "│",
RightSeparator: "",
TopLeft: "┌",
TopRight: "┐",
TopSeparator: "",
UnfinishedRow: " ...",
},
Color: table.ColorOptionsDefault,
Format: table.FormatOptions{
Footer: text.FormatDefault,
Header: text.FormatDefault,
Row: text.FormatDefault,
},
HTML: table.DefaultHTMLOptions,
Options: table.Options{
DrawBorder: true,
SeparateColumns: false,
SeparateFooter: true,
SeparateHeader: true,
SeparateRows: false,
},
Title: table.TitleOptionsDefault,
}

func ArtifactsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "artifacts",
Short: "Commands for interacting with artifacts produced by the imagerunner.",
}

cmd.AddCommand(
downloadCommand(),
)

return cmd
}

func downloadCommand() *cobra.Command {
var targetDir string
var out string

cmd := &cobra.Command{
Use: "download",
Short: "Downloads the specified artifacts from the given run. Supports glob pattern.",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 || args[0] == "" {
return errors.New("no run ID specified")
}
if len(args) == 1 || args[1] == "" {
return errors.New("no file pattern specified")
}

return nil
},
PreRun: func(cmd *cobra.Command, args []string) {
tracker := segment.DefaultTracker

go func() {
tracker.Collect(
cases.Title(language.English).String(cmds.FullName(cmd)),
usage.Properties{}.SetFlags(cmd.Flags()),
)
_ = tracker.Close()
}()
},
RunE: func(cmd *cobra.Command, args []string) error {
ID := args[0]
filePattern := args[1]

return download(ID, filePattern, targetDir, out)
},
}

flags := cmd.Flags()
flags.StringVar(&targetDir, "target-dir", "", "Save files to target directory. Defaults to current working directory.")
flags.StringVarP(&out, "out", "o", "text", "Output format to the console. Options: text, json.")

return cmd
}

func download(ID, filePattern, targetDir, outputFormat string) error {
reader, err := imagerunnerClient.DownloadArtifacts(context.Background(), ID)
if err != nil {
return fmt.Errorf("failed to fetch artifacts: %w", err)
}

fileName, err := files.SaveToTempFile(reader)
if err != nil {
return fmt.Errorf("failed to download artifacts content: %w", err)
}
defer os.Remove(fileName)

zf, err := zip.OpenReader(fileName)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer zf.Close()

files := []string{}
for _, f := range zf.File {
if glob.Glob(filePattern, f.Name) {
files = append(files, f.Name)
if err = szip.Extract(targetDir, f); err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
}
}

lst := imagerunner.ArtifactList{
ID: ID,
Items: files,
}

switch outputFormat {
case "json":
if err := renderJSON(lst); err != nil {
return fmt.Errorf("failed to render output: %w", err)
}
case "text":
renderTable(lst)
default:
return errors.New("unknown output format")
}
return nil
}

func renderTable(lst imagerunner.ArtifactList) {
if len(lst.Items) == 0 {
println("No artifacts for this job.")
return
}

t := table.NewWriter()
t.SetStyle(defaultTableStyle)
t.SuppressEmptyColumns()

t.AppendHeader(table.Row{"Items"})
t.SetColumnConfigs([]table.ColumnConfig{
{
Name: "Items",
},
})

for _, item := range lst.Items {
// the order of values must match the order of the header
t.AppendRow(table.Row{item})
}
t.SuppressEmptyColumns()

println(t.Render())
}

func renderJSON(val any) error {
return json.NewEncoder(os.Stdout).Encode(val)
}
1 change: 1 addition & 0 deletions internal/cmd/imagerunner/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func Command(preRun func(cmd *cobra.Command, args []string)) *cobra.Command {

cmd.AddCommand(
LogsCommand(),
ArtifactsCommand(),
)
return cmd
}
17 changes: 17 additions & 0 deletions internal/files/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,20 @@ func NewSHA256(filename string) (string, error) {
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}

// SaveToTempFile writes out the contents of the reader to a temp file.
// It's the caller's responsibility to clean up the temp file.
func SaveToTempFile(closer io.ReadCloser) (string, error) {
defer closer.Close()
fd, err := os.CreateTemp("", "")
if err != nil {
return "", err
}
defer fd.Close()

_, err = io.Copy(fd, closer)
if err != nil {
return "", err
}
return fd.Name(), fd.Close()
}
1 change: 1 addition & 0 deletions internal/http/imagerunner.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func NewImageRunner(url string, creds iam.Credentials, timeout time.Duration) Im
Creds: creds,
}
}

func (c *ImageRunner) TriggerRun(ctx context.Context, spec imagerunner.RunnerSpec) (imagerunner.Runner, error) {
var runner imagerunner.Runner
url := fmt.Sprintf("%s/v1alpha1/hosted/image/runners", c.URL)
Expand Down
5 changes: 5 additions & 0 deletions internal/imagerunner/imagerunner.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,8 @@ type Runner struct {
TerminationTime int64 `json:"termination_time,omitempty"`
TerminationReason string `json:"termination_reason,omitempty"`
}

type ArtifactList struct {
ID string `json:"id"`
Items []string `json:"items"`
}
68 changes: 9 additions & 59 deletions internal/saucecloud/imagerunner.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,20 @@ import (
"encoding/base64"
"errors"
"fmt"
"github.com/ryanuber/go-glob"
"github.com/saucelabs/saucectl/internal/config"
"github.com/saucelabs/saucectl/internal/msg"
"github.com/saucelabs/saucectl/internal/report"
"io"
"os"
"os/signal"
"path"
"path/filepath"
"reflect"
"strings"
"time"

"github.com/rs/zerolog/log"
"github.com/ryanuber/go-glob"
szip "github.com/saucelabs/saucectl/internal/archive/zip"
"github.com/saucelabs/saucectl/internal/config"
"github.com/saucelabs/saucectl/internal/files"
"github.com/saucelabs/saucectl/internal/imagerunner"
"github.com/saucelabs/saucectl/internal/msg"
"github.com/saucelabs/saucectl/internal/report"
)

type ImageRunner interface {
Expand Down Expand Up @@ -317,56 +316,6 @@ func (r *ImgRunner) PollRun(ctx context.Context, id string, lastStatus string) (
}
}

func extractFile(artifactFolder string, file *zip.File) error {
fullPath := path.Join(artifactFolder, file.Name)

relPath, err := filepath.Rel(artifactFolder, fullPath)
if err != nil {
return err
}
if strings.Contains(relPath, "..") {
return fmt.Errorf("file %s is relative to an outside folder", file.Name)
}

folder := path.Dir(fullPath)
if err := os.MkdirAll(folder, 0755); err != nil {
return err
}

fd, err := os.Create(fullPath)
if err != nil {
return err
}
defer fd.Close()

rd, err := file.Open()
if err != nil {
return err
}
defer rd.Close()

_, err = io.Copy(fd, rd)
if err != nil {
return err
}
return fd.Close()
}

func saveToTempFile(closer io.ReadCloser) (string, error) {
defer closer.Close()
fd, err := os.CreateTemp("", "")
if err != nil {
return "", err
}
defer fd.Close()

_, err = io.Copy(fd, closer)
if err != nil {
return "", err
}
return fd.Name(), fd.Close()
}

func (r *ImgRunner) DownloadArtifacts(runnerID, suiteName, status string, passed bool) {
if runnerID == "" || status == imagerunner.StateCancelled || !r.Project.Artifacts.Download.When.IsNow(passed) {
return
Expand All @@ -384,7 +333,7 @@ func (r *ImgRunner) DownloadArtifacts(runnerID, suiteName, status string, passed
log.Err(err).Str("suite", suiteName).Msg("Failed to fetch artifacts.")
return
}
fileName, err := saveToTempFile(reader)
fileName, err := files.SaveToTempFile(reader)
if err != nil {
log.Err(err).Str("suite", suiteName).Msg("Failed to download artifacts content.")
return
Expand All @@ -393,13 +342,14 @@ func (r *ImgRunner) DownloadArtifacts(runnerID, suiteName, status string, passed

zf, err := zip.OpenReader(fileName)
if err != nil {
log.Error().Msgf("Unable to open zip file %s: %s", fileName, err)
return
}
defer zf.Close()
for _, f := range zf.File {
for _, pattern := range r.Project.Artifacts.Download.Match {
if glob.Glob(pattern, f.Name) {
if err = extractFile(dir, f); err != nil {
if err = szip.Extract(dir, f); err != nil {
log.Error().Msgf("Unable to extract file '%s': %s", f.Name, err)
}
break
Expand Down

0 comments on commit 9d52e3e

Please sign in to comment.