From 2327b76706418a38c06ad2febce2264b78ed3d55 Mon Sep 17 00:00:00 2001 From: Lennart Espe <3391295+lnsp@users.noreply.github.com> Date: Sun, 8 Dec 2024 16:26:08 +0100 Subject: [PATCH] Add command to watch builds until completion. --- README.md | 7 +++- api/client.go | 80 ++++++++++++++++++++++++++++++++++-- cmd/builds.go | 110 ++++++++++++++++++++++++++++++++++++++++++++++++-- go.mod | 11 ++++- go.sum | 16 ++++++++ 5 files changed, 214 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1c876e9..0fe7bb6 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,12 @@ valar builds abort [prefix] #### Show logs from build ```bash -valar builds logs [--follow] [optional buildid] +valar builds logs [--follow] [--raw] [optional buildid] +``` + +#### Watch the build and status until its completed +```bash +valar builds watch [optional buildid] ``` #### Show build status diff --git a/api/client.go b/api/client.go index 0401516..5f4d86b 100644 --- a/api/client.go +++ b/api/client.go @@ -1,6 +1,7 @@ package api import ( + "bufio" "bytes" "encoding/json" "fmt" @@ -194,25 +195,96 @@ func (client *Client) AbortBuild(project, service, id string) error { return nil } +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Source LogEntrySource `json:"source"` + Stage LogEntryStage `json:"stage"` + Content string `json:"content"` +} + +type LogEntrySource string + +const ( + LogEntrySourceUnspecified = "" + LogEntrySourceProcess = "PROCESS" + LogEntrySourceWrapper = "WRAPPER" +) + +type LogEntryStage string + +const ( + LogEntryStageUnspecified = "" + LogEntryStageSetup = "SETUP" + LogEntryStageTurndown = "TURNDOWN" +) + +type logEntryDecoder struct { + consumer func(LogEntry) + writer *io.PipeWriter + done chan struct{} +} + +func newLogEntryDecoder(consumer func(LogEntry)) *logEntryDecoder { + done := make(chan struct{}, 1) + reader, writer := io.Pipe() + go func() { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + logentry := LogEntry{} + logline := scanner.Text() + if err := json.Unmarshal([]byte(logline), &logentry); err != nil { + consumer(LogEntry{Content: logline}) + continue + } + consumer(logentry) + } + close(done) + }() + + return &logEntryDecoder{consumer, writer, done} +} + +func (decoder *logEntryDecoder) Write(b []byte) (int, error) { + return decoder.writer.Write(b) +} + +func (decoder *logEntryDecoder) Close() error { + return decoder.writer.Close() +} + +func (decoder *logEntryDecoder) Wait() { + <-decoder.done +} + // ShowBuildLogs retrieves a specific build task logs. -func (client *Client) ShowBuildLogs(project, service, id string, w io.Writer) error { +func (client *Client) ShowBuildLogs(project, service, id string, consumer func(LogEntry)) error { var ( path = fmt.Sprintf("/projects/%s/services/%s/builds/%s/logs", project, service, id) ) - if err := client.streamRequest(http.MethodGet, path, w); err != nil { + decoder := newLogEntryDecoder(consumer) + if err := client.streamRequest(http.MethodGet, path, decoder); err != nil { + return err + } + if err := decoder.Close(); err != nil { return err } + decoder.Wait() return nil } // StreamBuildLogs streams the active build logs to stdout. -func (client *Client) StreamBuildLogs(project, service, id string, w io.Writer) error { +func (client *Client) StreamBuildLogs(project, service, id string, consumer func(LogEntry)) error { var ( path = fmt.Sprintf("/projects/%s/services/%s/builds/%s/logs?follow=true", project, service, id) ) - if err := client.streamRequest(http.MethodGet, path, w); err != nil { + decoder := newLogEntryDecoder(consumer) + if err := client.streamRequest(http.MethodGet, path, decoder); err != nil { + return err + } + if err := decoder.Close(); err != nil { return err } + decoder.Wait() return nil } diff --git a/cmd/builds.go b/cmd/builds.go index 57f5bad..5ca210a 100644 --- a/cmd/builds.go +++ b/cmd/builds.go @@ -6,13 +6,16 @@ import ( "sort" "strings" "text/tabwriter" + "time" "github.com/dustin/go-humanize" "github.com/fatih/color" "github.com/juju/ansiterm" + "github.com/schollz/progressbar/v3" "github.com/spf13/cobra" "github.com/valar/cli/api" "github.com/valar/cli/config" + "golang.org/x/crypto/ssh/terminal" ) var buildService string @@ -64,6 +67,29 @@ var buildAbortCmd = &cobra.Command{ } var logsFollow = false +var logsRaw = false + +func formatLogEntry(logEntry *api.LogEntry) string { + line := &strings.Builder{} + + fmt.Fprintf(line, "│ %s │ ", color.HiBlackString(logEntry.Timestamp.Format(time.RFC3339))) + + switch logEntry.Source { + case api.LogEntrySourceUnspecified: + case api.LogEntrySourceWrapper: + switch logEntry.Stage { + case api.LogEntryStageUnspecified: + line.WriteString(color.WhiteString("→ %s", logEntry.Content)) + case api.LogEntryStageSetup: + line.WriteString(color.GreenString("setup ↗ %s", logEntry.Content)) + case api.LogEntryStageTurndown: + line.WriteString(color.YellowString("turndown ↘ %s", logEntry.Content)) + } + case api.LogEntrySourceProcess: + line.WriteString(color.WhiteString("→ %s", logEntry.Content)) + } + return line.String() +} var buildLogsCmd = &cobra.Command{ Use: "logs [buildid]", @@ -93,10 +119,87 @@ var buildLogsCmd = &cobra.Command{ // Sort builds by date sort.Slice(builds, func(i, j int) bool { return builds[i].CreatedAt.After(builds[j].CreatedAt) }) latestBuildID := builds[0].ID + consumer := func(le api.LogEntry) { + if logsRaw { + fmt.Println(le.Content) + } else { + fmt.Println(formatLogEntry(&le)) + } + } if logsFollow { - return client.StreamBuildLogs(cfg.Project(), cfg.Service(), latestBuildID, os.Stdout) + return client.StreamBuildLogs(cfg.Project(), cfg.Service(), latestBuildID, consumer) + } + return client.ShowBuildLogs(cfg.Project(), cfg.Service(), latestBuildID, consumer) + }), +} + +var buildWatchCmd = &cobra.Command{ + Use: "watch [prefix]", + Short: "Watch a live build until its completion.", + Args: cobra.MaximumNArgs(1), + Run: runAndHandle(func(cmd *cobra.Command, args []string) error { + cfg, err := config.NewServiceConfigWithFallback(functionConfiguration, &buildService, globalConfiguration) + if err != nil { + return err + } + client, err := globalConfiguration.APIClient() + if err != nil { + return err + } + // Get latest matching build + prefix := "" + if len(args) > 0 { + prefix = args[0] } - return client.ShowBuildLogs(cfg.Project(), cfg.Service(), latestBuildID, os.Stdout) + builds, err := client.ListBuilds(cfg.Project(), cfg.Service(), prefix) + if err != nil { + return err + } + if len(builds) == 0 { + return fmt.Errorf("no builds available") + } + // Sort builds by date + sort.Slice(builds, func(i, j int) bool { return builds[i].CreatedAt.After(builds[j].CreatedAt) }) + latestBuildID := builds[0].ID + + _, rows, _ := terminal.GetSize(0) + bar := progressbar.NewOptions(-1, progressbar.OptionEnableColorCodes(true), progressbar.OptionSpinnerType(3), progressbar.OptionSetElapsedTime(false), progressbar.OptionSetMaxDetailRow(rows-2)) + build, err := client.InspectBuild(cfg.Project(), cfg.Service(), latestBuildID) + if err != nil { + return err + } + + switch build.Status { + case "scheduled": + bar.Describe("Scheduling build onto worker ...") + } + + client.StreamBuildLogs(cfg.Project(), cfg.Service(), latestBuildID, func(le api.LogEntry) { + switch le.Stage { + case api.LogEntryStageUnspecified: + bar.Describe("Processing ...") + case api.LogEntryStageSetup: + bar.Describe("Setting up build environment ...") + case api.LogEntryStageTurndown: + bar.Describe("Turning down build environment ...") + } + bar.AddDetail(formatLogEntry(&le)) + }) + + build, err = client.InspectBuild(cfg.Project(), cfg.Service(), latestBuildID) + if err != nil { + return err + } + switch build.Status { + case "done": + bar.Describe("Build has succeeded.") + case "failed": + bar.Describe("Build has failed.") + } + bar.Finish() + fmt.Println() + + return nil }), } @@ -227,6 +330,7 @@ func colorize(status string) string { func initBuildsCmd() { buildCmd.PersistentFlags().StringVarP(&buildService, "service", "s", "", "The service to inspect for builds") buildLogsCmd.PersistentFlags().BoolVarP(&logsFollow, "follow", "f", false, "Follow the logs") - buildCmd.AddCommand(buildListCmd, buildInspectCmd, buildLogsCmd, buildAbortCmd, buildStatusCmd) + buildLogsCmd.PersistentFlags().BoolVarP(&logsRaw, "raw", "r", false, "Dump the unformatted log content") + buildCmd.AddCommand(buildListCmd, buildInspectCmd, buildLogsCmd, buildAbortCmd, buildStatusCmd, buildWatchCmd) rootCmd.AddCommand(buildCmd) } diff --git a/go.mod b/go.mod index 58c5395..7f8e73a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/valar/cli -go 1.21 +go 1.22 + +toolchain go1.23.3 require ( github.com/dustin/go-humanize v1.0.1 @@ -29,10 +31,15 @@ require ( github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/nwaples/rardecode v1.1.3 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/schollz/progressbar/v3 v3.17.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/ulikunitz/xz v0.5.12 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect - golang.org/x/sys v0.22.0 // indirect + golang.org/x/crypto v0.30.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/term v0.27.0 // indirect ) diff --git a/go.sum b/go.sum index b8d641d..41af9b6 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= @@ -88,9 +90,13 @@ github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0 github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/progressbar/v3 v3.17.1 h1:bI1MTaoQO+v5kzklBjYNRQLoVpe0zbyRZNK6DFkVC5U= +github.com/schollz/progressbar/v3 v3.17.1/go.mod h1:RzqpnsPQNjUyIgdglUjRLgD7sVnxN1wpmBMV+UiEbL4= github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= @@ -109,6 +115,8 @@ github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= @@ -127,6 +135,14 @@ golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= +golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=