diff --git a/saas/argocli/src/applatix.io/argo/cmd/app.go b/saas/argocli/src/applatix.io/argo/cmd/app.go new file mode 100644 index 000000000000..eef3b0a8fb6e --- /dev/null +++ b/saas/argocli/src/applatix.io/argo/cmd/app.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "applatix.io/api" + "applatix.io/axamm/application" + "applatix.io/axamm/deployment" + "applatix.io/axerror" + humanize "github.com/dustin/go-humanize" + "github.com/spf13/cobra" +) + +var ( + appListArgs appListFlags + appShowArgs appShowFlags +) + +type appListFlags struct { + showAll bool +} + +type appShowFlags struct { +} + +func init() { + RootCmd.AddCommand(appCmd) + + appCmd.AddCommand(appListCmd) + appListCmd.Flags().BoolVar(&appListArgs.showAll, "show-all", false, "Show all applications, including terminated") + + appCmd.AddCommand(appShowCmd) +} + +var appCmd = &cobra.Command{ + Use: "app", + Short: "application commands", + Run: func(cmd *cobra.Command, args []string) { + cmd.HelpFunc()(cmd, args) + }, +} + +var appListCmd = &cobra.Command{ + Use: "list", + Short: "List applications", + Run: appList, +} + +var appShowCmd = &cobra.Command{ + Use: "show", + Short: "Display details about an application", + Run: appShow, +} + +func appList(cmd *cobra.Command, args []string) { + params := api.ApplicationListParams{} + if !appListArgs.showAll { + params.Statuses = api.RunningApplicationStates + } + initClient() + apps, axErr := apiClient.ApplicationList(params) + checkFatal(axErr) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tSTATUS\tAGE") + for _, app := range apps { + cTime := time.Unix(app.Ctime, 0) + now := time.Now() + hrTimeDiff := humanize.RelTime(cTime, now, "", "later") + fmt.Fprintf(w, "%s\t%s\t%s\n", app.Name, app.Status, hrTimeDiff) + } + w.Flush() +} + +func appShow(cmd *cobra.Command, args []string) { + if len(args) != 1 { + cmd.HelpFunc()(cmd, args) + os.Exit(1) + } + initClient() + app, axErr := apiClient.ApplicationGetByName(args[0]) + checkFatal(axErr) + if app == nil { + axErr = axerror.ERR_AX_ILLEGAL_ARGUMENT.NewWithMessagef("Application with name '%s' not found") + checkFatal(axErr) + } + printApp(app) +} + +// appURL returns the app URL given an app ID +func appURL(id string) string { + return fmt.Sprintf("%s/app/applications/details/%s", apiClient.Config.URL, id) +} + +func formatEndpoint(ep string) string { + return fmt.Sprintf("https://%s", strings.TrimRight(ep, ".")) +} + +func printApp(app *application.Application) { + const fmtStr = "%-13s%v\n" + fmt.Printf(fmtStr, "ID:", app.ID) + fmt.Printf(fmtStr, "URL:", appURL(app.ID)) + fmt.Printf(fmtStr, "Name:", app.Name) + fmt.Printf(fmtStr, "Status:", app.Status) + for key, valIf := range app.StatusDetail { + if valIf == nil { + continue + } + if val, ok := valIf.(string); ok && val != "" && strings.ToLower(val) != strings.ToLower(app.Status) { + fmt.Printf(fmtStr, " "+key+":", val) + } + } + if len(app.Endpoints) > 1 { + fmt.Printf(fmtStr, "Endpoints:", "") + for _, ep := range app.Endpoints { + fmt.Println("- " + formatEndpoint(ep)) + } + } + + fmt.Println() + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintf(w, "%s\n", app.Name) + fmt.Fprintf(w, " |\n") + for i, dep := range app.Deployments { + isLast := i == len(app.Deployments)-1 + var prefix string + if isLast { + prefix = " └" + } else { + prefix = " ├" + } + fmt.Fprintf(w, "%s- %s\t%s\t%s\n", prefix, dep.Name, dep.Status, dep.Id) + printDeployment(w, dep, isLast) + } + w.Flush() +} + +func printDeployment(w *tabwriter.Writer, dep *deployment.Deployment, isLastDep bool) { + var prefix string + if isLastDep { + prefix = " " + } else { + prefix = " | " + } + for i, inst := range dep.Instances { + isLastInst := i == len(dep.Instances)-1 + var prefix2 string + if isLastInst { + prefix2 = "└" + } else { + prefix2 = "├" + } + fmt.Fprintf(w, "%s%s- %s\t%s\t\n", prefix, prefix2, inst.Name, inst.Phase) + } +} diff --git a/saas/argocli/src/applatix.io/argo/cmd/common.go b/saas/argocli/src/applatix.io/argo/cmd/common.go index 48151f26e758..db837d6c284d 100644 --- a/saas/argocli/src/applatix.io/argo/cmd/common.go +++ b/saas/argocli/src/applatix.io/argo/cmd/common.go @@ -32,6 +32,7 @@ type globalFlags struct { config string // --config trace bool // --trace clusterConfig api.ClusterConfig // --cluster, --username, --password + noColor bool // --no-color } func init() { @@ -40,6 +41,7 @@ func init() { RootCmd.PersistentFlags().StringVar(&globalArgs.clusterConfig.Password, "password", "", "Argo password") RootCmd.PersistentFlags().StringVar(&globalArgs.config, "config", "", "Name or path to a Argo cluster config") RootCmd.PersistentFlags().BoolVar(&globalArgs.trace, "trace", false, "Log API requests") + RootCmd.PersistentFlags().BoolVar(&globalArgs.noColor, "no-color", false, "Disable colorized output") } func initConfig() api.ClusterConfig { diff --git a/saas/argocli/src/applatix.io/argo/cmd/job.go b/saas/argocli/src/applatix.io/argo/cmd/job.go index e9c709540298..ebfdd12a0cd1 100644 --- a/saas/argocli/src/applatix.io/argo/cmd/job.go +++ b/saas/argocli/src/applatix.io/argo/cmd/job.go @@ -1,7 +1,11 @@ package cmd import ( + "bufio" + "bytes" + "encoding/json" "fmt" + "io" "log" "math" "os" @@ -76,6 +80,8 @@ func init() { jobCmd.AddCommand(jobKillCmd) + jobCmd.AddCommand(jobLogsCmd) + gitPath, _ = exec.LookPath("git") } @@ -111,8 +117,14 @@ var jobKillCmd = &cobra.Command{ Run: jobKill, } +var jobLogsCmd = &cobra.Command{ + Use: "logs SERVICE_ID", + Short: "Retrieve logs from a container", + Run: jobLogs, +} + var ( - defaultServiceListFields = []string{"id", "name", "username", "launch_time", "status", "status_detail", "failure_path"} + defaultServiceListFields = []string{"id", "name", "username", "launch_time", "status", "status_detail", "failure_path", "policy_id"} sinceRegex = regexp.MustCompile("^(\\d+)([smhd])$") ) @@ -149,6 +161,15 @@ func statusString(svc *service.Service) string { return statusStr } +func submitterString(svc *service.Service) string { + if svc.PolicyId != "" { + // The service object currently does not capture the policy name -- just the ID. + // When we start capturing more policy information, change to display the policy name. + return "Policy" + } + return svc.User +} + func jobList(cmd *cobra.Command, args []string) { params := api.ServiceListParams{ Username: jobListArgs.submitter, @@ -173,7 +194,7 @@ func jobList(cmd *cobra.Command, args []string) { cTime := time.Unix(svc.CreateTime, 0) now := time.Now() hrTimeDiff := humanize.RelTime(cTime, now, "ago", "later") - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", svc.Id, svc.Name, svc.User, hrTimeDiff, statusString(svc)) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", svc.Id, svc.Name, submitterString(svc), hrTimeDiff, statusString(svc)) } w.Flush() } @@ -312,6 +333,50 @@ func jobKill(cmd *cobra.Command, args []string) { printJob(svc) } +// This struct is the format +type JSONLog struct { + Log string `json:"log"` + Stream string `json:"stream"` + Time string `json:"time"` +} + +func jobLogs(cmd *cobra.Command, args []string) { + if len(args) != 1 { + cmd.HelpFunc()(cmd, args) + os.Exit(1) + } + initClient() + res, axErr := apiClient.ServiceLogs(args[0]) + checkFatal(axErr) + defer res.Body.Close() + contentType := res.Header.Get("Content-Type") + switch contentType { + case "text/event-stream": + rd := bufio.NewReader(res.Body) + for { + line, err := rd.ReadBytes('\n') + if err != nil { + if err == io.EOF { + return + } + log.Fatal("Read Error:", err) + } + if !bytes.HasPrefix(line, []byte("data:")) { + continue + } + line = bytes.TrimPrefix(line, []byte("data:")) + var jl JSONLog + err = json.Unmarshal(line, &jl) + if err != nil { + log.Fatalf("Failed to decode line '%s': %v", string(line), err) + } + log.Print(jl.Log) + } + default: + + } +} + func printJob(svc *service.Service) { const svcFmtStr = "%-17s %v\n" fmt.Printf(svcFmtStr, "ID:", svc.Id) @@ -335,7 +400,7 @@ func printJob(svc *service.Service) { fmt.Printf(svcFmtStr, "Failure Path:", strings.Join(failurePath, " -> ")) } } - fmt.Printf(svcFmtStr, "Submitter:", svc.User) + fmt.Printf(svcFmtStr, "Submitter:", submitterString(svc)) fmt.Printf(svcFmtStr, "Submitted:", humanizeTimestamp(svc.CreateTime)) if svc.LaunchTime > 0 { fmt.Printf(svcFmtStr, "Started:", humanizeTimestamp(svc.LaunchTime)) @@ -373,28 +438,20 @@ func printJobTree(svc *service.Service) { for _, child := range svc.Children { statusMap[child.Id] = child.Status } + statusMap[svc.Id] = svc.Status w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) - printJobTreeHelper(w, svc, svc.Name, statusMap, 0, " ", false) + printJobTreeHelper(w, svc, svc.Name, statusMap, 0, " ", " ") w.Flush() } -var statusIconMap = map[int]string{ - utils.ServiceStatusInitiating: "⧖", - utils.ServiceStatusWaiting: "⧖", - utils.ServiceStatusRunning: "●", - utils.ServiceStatusCanceling: "⚠", - utils.ServiceStatusCancelled: "⚠", - utils.ServiceStatusSkipped: "-", - utils.ServiceStatusSuccess: "\033[32m✔\033[0m", - utils.ServiceStatusFailed: "\033[31m✖\033[0m", -} +var jobStatusIconMap map[int]string -func printJobTreeHelper(w *tabwriter.Writer, svc *service.Service, nodeName string, statusMap map[string]int, depth int, prefix string, isLast bool) { +func printJobTreeHelper(w *tabwriter.Writer, svc *service.Service, nodeName string, statusMap map[string]int, depth int, nodePrefix string, childPrefix string) { if svc.Template == nil { return } nodeStatus := statusMap[svc.Id] - nodeName = fmt.Sprintf("%s %s", statusIconMap[nodeStatus], nodeName) + nodeName = fmt.Sprintf("%s %s", jobStatusIconMap[nodeStatus], nodeName) templateType := svc.Template.GetType() var svcID string @@ -402,40 +459,66 @@ func printJobTreeHelper(w *tabwriter.Writer, svc *service.Service, nodeName stri svcID = svc.Id } - if depth == 0 { - fmt.Fprintf(w, "%s\n", nodeName) + fmt.Fprintf(w, "%s%s\t%s\n", nodePrefix, nodeName, svcID) + if len(svc.Children) > 0 { fmt.Fprintf(w, " |\n") - } else { - if isLast { - fmt.Fprintf(w, "%s└- %s\t%s\n", prefix, nodeName, svcID) - } else { - fmt.Fprintf(w, "%s├- %s\t%s\n", prefix, nodeName, svcID) - } } if templateType == template.TemplateTypeWorkflow { wt := svc.Template.(*service.EmbeddedWorkflowTemplate) for i, parallelSteps := range wt.Steps { j := 0 - for stepName, childSvc := range parallelSteps { - j = j + 1 - last := bool(i == len(wt.Steps)-1) && bool(j == len(parallelSteps)) - var childPrefix string - if depth == 0 { - childPrefix = prefix - } else { - if isLast { - childPrefix = prefix + " " + lastStepGroup := bool(i == len(wt.Steps)-1) + var part1, subp1 string + if lastStepGroup { + part1 = "└-" + subp1 = " " + } else { + part1 = "├-" + subp1 = "| " + } + // display parallel steps in alphabetical order (for consistency between CLI invocations) + var keys []string + for k := range parallelSteps { + keys = append(keys, k) + } + sort.Strings(keys) + for _, stepName := range keys { + childSvc := parallelSteps[stepName] + if j > 0 { + if lastStepGroup { + part1 = " " } else { - childPrefix = prefix + "| " + part1 = "| " } } - printJobTreeHelper(w, childSvc, stepName, statusMap, depth+1, childPrefix, last) + firstParallel := bool(j == 0) + lastParallel := bool(j == len(parallelSteps)-1) + var part2, subp2 string + if firstParallel { + part2 = "·-" + if !lastParallel { + subp2 = "| " + } else { + subp2 = " " + } + + } else if lastParallel { + part2 = "└-" + subp2 = " " + } else { + part2 = "├-" + subp2 = "| " + } + childNodePrefix := childPrefix + part1 + part2 + childChldPrefix := childPrefix + subp1 + subp2 + printJobTreeHelper(w, childSvc, stepName, statusMap, depth+1, childNodePrefix, childChldPrefix) + j = j + 1 } } } } -// jobURL returns the formulat for a job URL given a job ID +// jobURL returns the job URL given a job ID func jobURL(id string) string { return fmt.Sprintf("%s/app/timeline/jobs/%s", apiClient.Config.URL, id) } diff --git a/saas/argocli/src/applatix.io/argo/cmd/root.go b/saas/argocli/src/applatix.io/argo/cmd/root.go index 33b9f7145bfa..00d60bfd6331 100644 --- a/saas/argocli/src/applatix.io/argo/cmd/root.go +++ b/saas/argocli/src/applatix.io/argo/cmd/root.go @@ -1,8 +1,12 @@ // Copyright 2015-2017 Applatix, Inc. All rights reserved. package cmd -import "github.com/spf13/cobra" +import ( + "applatix.io/axops/utils" + "github.com/spf13/cobra" +) +// RootCmd is the argo root level command var RootCmd = &cobra.Command{ Use: "argo", Short: "argo is the command line interface to Argo clusters", @@ -10,3 +14,20 @@ var RootCmd = &cobra.Command{ cmd.HelpFunc()(cmd, args) }, } + +func init() { + cobra.OnInitialize(initializeSession) +} + +func initializeSession() { + jobStatusIconMap = map[int]string{ + utils.ServiceStatusInitiating: ansiFormat("⧖", noFormat), + utils.ServiceStatusWaiting: ansiFormat("⧖", noFormat), + utils.ServiceStatusRunning: ansiFormat("●", FgCyan), + utils.ServiceStatusCanceling: ansiFormat("⚠", FgYellow), + utils.ServiceStatusCancelled: ansiFormat("⚠", FgYellow), + utils.ServiceStatusSkipped: ansiFormat("-", noFormat), + utils.ServiceStatusSuccess: ansiFormat("✔", FgGreen), + utils.ServiceStatusFailed: ansiFormat("✖", FgRed), + } +} diff --git a/saas/argocli/src/applatix.io/argo/cmd/util.go b/saas/argocli/src/applatix.io/argo/cmd/util.go index 34a89cb01f7d..dedf55c79562 100644 --- a/saas/argocli/src/applatix.io/argo/cmd/util.go +++ b/saas/argocli/src/applatix.io/argo/cmd/util.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path" + "strconv" "strings" "applatix.io/axerror" @@ -71,3 +72,37 @@ func runCmdTTY(cmdName string, arg ...string) { os.Exit(1) } } + +// ANSI escape codes +const ( + escape = "\x1b" + noFormat = 0 + Bold = 1 + FgBlack = 30 + FgRed = 31 + FgGreen = 32 + FgYellow = 33 + FgBlue = 34 + FgMagenta = 35 + FgCyan = 36 + FgWhite = 37 +) + +// ansiFormat wraps ANSI escape codes to a string to format the string to a desired color. +// NOTE: we still apply formatting even if there is no color formatting desired. +// The purpose of doing this is because when we apply ANSI color escape sequences to our +// output, this confuses the tabwriter library which miscalculates widths of columns and +// misaligns columns. By always applying a ANSI escape sequence (even when we don't want +// color, it provides more consistent string lengths so that tabwriter can calculate +// widths correctly. +func ansiFormat(s string, codes ...int) string { + if globalArgs.noColor || os.Getenv("TERM") == "dumb" || len(codes) == 0 { + return s + } + codeStrs := make([]string, len(codes)) + for i, code := range codes { + codeStrs[i] = strconv.Itoa(code) + } + sequence := strings.Join(codeStrs, ";") + return fmt.Sprintf("%s[%sm%s%s[%dm", escape, sequence, s, escape, noFormat) +} diff --git a/saas/argocli/src/applatix.io/argo/cmd/yaml.go b/saas/argocli/src/applatix.io/argo/cmd/yaml.go index d2e589eca9c5..09347d61406d 100644 --- a/saas/argocli/src/applatix.io/argo/cmd/yaml.go +++ b/saas/argocli/src/applatix.io/argo/cmd/yaml.go @@ -78,12 +78,14 @@ func validateYAML(cmd *cobra.Command, args []string) { ctx, err = buildContextFromFiles(yamlFiles) } exitCode := 0 + errorString := ansiFormat("ERROR", FgRed) + okString := ansiFormat("OK", FgGreen) if ctx != nil { if !ctx.IgnoreErrors { firstErr, filePath := ctx.FirstError() if firstErr != nil { - fmt.Printf("[\033[1;30m%s\033[0m]\n", filePath) - fmt.Printf(" - \033[31mERROR\033[0m %s: %v\n", firstErr.Template.GetName(), firstErr.AXErr) + fmt.Printf("[%s]\n", ansiFormat(filePath, Bold, FgBlack)) + fmt.Printf(" - %s %s: %v\n", errorString, firstErr.Template.GetName(), firstErr.AXErr) os.Exit(1) } } @@ -95,14 +97,14 @@ func validateYAML(cmd *cobra.Command, args []string) { //fmt.Println(result, exists, err) if exists { if !printedFilePath { - fmt.Printf("[\033[1;30m%s\033[0m]\n", filePath) + fmt.Printf("[%s]\n", ansiFormat(filePath, Bold, FgBlack)) printedFilePath = true } if result.AXErr != nil { exitCode = 1 - fmt.Printf(" - \033[31mERROR\033[0m %s: %v\n", templateName, result.AXErr) + fmt.Printf(" - %s %s: %v\n", errorString, templateName, result.AXErr) } else { - fmt.Printf(" - \033[32mOK\033[0m %s\n", templateName) + fmt.Printf(" - %s %s\n", okString, templateName) } } } diff --git a/saas/axamm/src/applatix.io/axamm/axamm_server/application.go b/saas/axamm/src/applatix.io/axamm/axamm_server/application.go index 48c2501981db..d7fa0286d56e 100644 --- a/saas/axamm/src/applatix.io/axamm/axamm_server/application.go +++ b/saas/axamm/src/applatix.io/axamm/axamm_server/application.go @@ -1,17 +1,18 @@ package main import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "applatix.io/axamm/application" "applatix.io/axdb" "applatix.io/axerror" "applatix.io/axops/utils" "applatix.io/common" - "encoding/json" - "fmt" "github.com/gin-gonic/gin" - "net/http" - "strings" - "sync" ) type ApplicationsData struct { diff --git a/saas/common/src/applatix.io/api/application.go b/saas/common/src/applatix.io/api/application.go new file mode 100644 index 000000000000..8bb2ea812496 --- /dev/null +++ b/saas/common/src/applatix.io/api/application.go @@ -0,0 +1,87 @@ +package api + +import ( + "fmt" + "strings" + + "applatix.io/axamm/application" + "applatix.io/axerror" +) + +type ApplicationListParams struct { + Fields []string + Limit int64 + Statuses []string + Name string +} + +// RunningApplicationStates is the list of non-terminated application states +var RunningApplicationStates = []string{ + application.AppStateInit, + application.AppStateWaiting, + application.AppStateError, + application.AppStateActive, + application.AppStateTerminating, + application.AppStateStopping, + application.AppStateStopped, + application.AppStateUpgrading, +} + +// ApplicationList returns a list of applications based on supplied filters +func (c *ArgoClient) ApplicationList(params ApplicationListParams) ([]*application.Application, *axerror.AXError) { + queryArgs := []string{} + if len(params.Statuses) > 0 { + queryArgs = append(queryArgs, fmt.Sprintf("status=%s", strings.Join(params.Statuses, ","))) + } + if len(params.Fields) > 0 { + queryArgs = append(queryArgs, fmt.Sprintf("fields=%s", strings.Join(params.Fields, ","))) + } + if params.Name != "" { + queryArgs = append(queryArgs, fmt.Sprintf("name=%s", params.Name)) + } + if params.Limit != 0 { + queryArgs = append(queryArgs, fmt.Sprintf("limit=%d", params.Limit)) + } + url := fmt.Sprintf("applications") + if len(queryArgs) > 0 { + url += fmt.Sprintf("?%s", strings.Join(queryArgs, "&")) + } + type ApplicationsData struct { + Data []*application.Application `json:"data"` + } + var appsData ApplicationsData + axErr := c.get(url, &appsData) + if axErr != nil { + return nil, axErr + } + return appsData.Data, nil +} + +// ApplicationGet retrieves an application by its ID +func (c *ArgoClient) ApplicationGet(id string) (*application.Application, *axerror.AXError) { + url := fmt.Sprintf("applications/%s", id) + var app application.Application + axErr := c.get(url, &app) + if axErr != nil { + return nil, axErr + } + return &app, nil +} + +// ApplicationGetByName retrieves an application by its name +func (c *ArgoClient) ApplicationGetByName(name string) (*application.Application, *axerror.AXError) { + apps, axErr := c.ApplicationList(ApplicationListParams{ + Fields: []string{application.ApplicationID}, + Name: name, + }) + if axErr != nil { + return nil, axErr + } + if len(apps) == 0 { + return nil, nil + } + if len(apps) > 1 { + return nil, axerror.ERR_AX_INTERNAL.NewWithMessagef("Found %d applications with name '%s'", len(apps), name) + } + return c.ApplicationGet(apps[0].ID) +} diff --git a/saas/common/src/applatix.io/api/client.go b/saas/common/src/applatix.io/api/client.go index 74894998c848..98a401db415a 100644 --- a/saas/common/src/applatix.io/api/client.go +++ b/saas/common/src/applatix.io/api/client.go @@ -20,8 +20,9 @@ type ArgoClient struct { Config ClusterConfig Trace bool - client http.Client - baseURL string + client http.Client + cookieJar http.CookieJar + baseURL string } // NewArgoClient instantiates a new client from a specified config, or the default search order @@ -34,49 +35,45 @@ func NewArgoClient(configs ...ClusterConfig) ArgoClient { } config.URL = strings.TrimRight(config.URL, "/") cookieJar, _ := cookiejar.New(nil) - client := ArgoClient{ - Config: config, - client: http.Client{ - Jar: cookieJar, - Timeout: time.Minute, - }, + argoClient := ArgoClient{ + Config: config, + cookieJar: cookieJar, } - if config.Insecure != nil && *config.Insecure { - tr := &http.Transport{ + argoClient.client = argoClient.newHTTPClient(DefaultHTTPClientTimeout) + return argoClient +} + +func (c *ArgoClient) newHTTPClient(timeout time.Duration) http.Client { + httpClient := http.Client{ + Jar: c.cookieJar, + Timeout: timeout, + } + if c.Config.Insecure != nil && *c.Config.Insecure { + httpClient.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } - client.client.Transport = tr } - return client -} - -func (c *ArgoClient) fromURL(path string) string { - return fmt.Sprintf("%s/v1/%s", c.Config.URL, path) + return httpClient } func (c *ArgoClient) get(path string, target interface{}) *axerror.AXError { - url := c.fromURL(path) - return c.doRequest("GET", url, nil, target) + return c.doRequest("GET", path, nil, target) } func (c *ArgoClient) post(path string, body interface{}, target interface{}) *axerror.AXError { - url := c.fromURL(path) - return c.doRequest("POST", url, body, target) + return c.doRequest("POST", path, body, target) } func (c *ArgoClient) put(path string, body interface{}, target interface{}) *axerror.AXError { - url := c.fromURL(path) - return c.doRequest("PUT", url, body, target) + return c.doRequest("PUT", path, body, target) } func (c *ArgoClient) delete(path string, target interface{}) *axerror.AXError { - url := c.fromURL(path) - return c.doRequest("DELETE", url, nil, target) + return c.doRequest("DELETE", path, nil, target) } -// doRequest is a helper to marshal a JSON body (if supplied) as part of a request, and decode a JSON response into the target interface -// Returns a decoded axErr if API returns back an error -func (c *ArgoClient) doRequest(method, url string, body interface{}, target interface{}) *axerror.AXError { +func (c *ArgoClient) prepareRequest(method, path string, body interface{}) (*http.Request, *axerror.AXError) { + url := fmt.Sprintf("%s/v1/%s", c.Config.URL, path) if c.Trace { log.Printf("%s %s", method, url) } @@ -84,43 +81,62 @@ func (c *ArgoClient) doRequest(method, url string, body interface{}, target inte if body != nil { jsonValue, err := json.Marshal(body) if err != nil { - return axerror.ERR_AX_HTTP_CONNECTION.NewWithMessagef("%s: %s", url, err.Error()) + return nil, axerror.ERR_AX_HTTP_CONNECTION.NewWithMessagef("%s: %s", url, err.Error()) } bodyBuff = bytes.NewBuffer(jsonValue) } req, err := http.NewRequest(method, url, bodyBuff) if err != nil { - return axerror.ERR_AX_HTTP_CONNECTION.NewWithMessagef("%s: %s", url, err.Error()) + return nil, axerror.ERR_AX_HTTP_CONNECTION.NewWithMessage(err.Error()) } req.SetBasicAuth(c.Config.Username, c.Config.Password) req.Header.Set("Content-Type", "application/json") + return req, nil +} + +// doRequest is a helper to marshal a JSON body (if supplied) as part of a request, and decode a JSON response into the target interface +// Returns a decoded axErr if API returns back an error +func (c *ArgoClient) doRequest(method, path string, body interface{}, target interface{}) *axerror.AXError { + req, axErr := c.prepareRequest(method, path, body) + if axErr != nil { + return axErr + } res, err := c.client.Do(req) if err != nil { - return axerror.ERR_AX_HTTP_CONNECTION.NewWithMessagef("%s: %s", url, err.Error()) + return axerror.ERR_AX_HTTP_CONNECTION.NewWithMessage(err.Error()) } return c.handleResponse(res, target) } -// handleResponse JSON decodes the body of an HTTP response into the target interface -func (c *ArgoClient) handleResponse(res *http.Response, target interface{}) *axerror.AXError { +func (c *ArgoClient) handleErrResponse(res *http.Response) *axerror.AXError { + defer res.Body.Close() body, err := ioutil.ReadAll(res.Body) - decodeErr := "Failed to decode response body" - if res.StatusCode >= 400 { - var axErr axerror.AXError - err = json.Unmarshal(body, &axErr) - if err != nil { - decodeErr = fmt.Sprintf("%s: %s", decodeErr, err.Error()) - if c.Trace { - fmt.Println(decodeErr) - fmt.Println(string(body)) - } - return axerror.ERR_AX_INTERNAL.NewWithMessagef(decodeErr) - } + if err != nil { + return axerror.ERR_AX_INTERNAL.NewWithMessagef("Server returned status %d, but failed to read response body: %s", res.StatusCode, err.Error()) + } + var axErr axerror.AXError + err = json.Unmarshal(body, &axErr) + if err != nil { if c.Trace { - fmt.Printf("Server returned %d: %s: %s\n", res.StatusCode, axErr.Code, axErr.Message) + fmt.Println(err) + fmt.Println(string(body)) } - return &axErr + return axerror.ERR_AX_INTERNAL.NewWithMessagef("Server returned status %d, but failed to decode response body: %s", res.StatusCode, err) + } + if c.Trace { + fmt.Printf("Server returned %d: %s: %s\n", res.StatusCode, axErr.Code, axErr.Message) } + return &axErr +} + +// handleResponse JSON decodes the body of an HTTP response into the target interface +func (c *ArgoClient) handleResponse(res *http.Response, target interface{}) *axerror.AXError { + if res.StatusCode >= 400 { + return c.handleErrResponse(res) + } + defer res.Body.Close() + body, err := ioutil.ReadAll(res.Body) + decodeErr := "Failed to decode response body" if target != nil { err = json.Unmarshal(body, target) if err != nil { diff --git a/saas/common/src/applatix.io/api/common.go b/saas/common/src/applatix.io/api/common.go index 713fc42c4fd0..af1d944f0461 100644 --- a/saas/common/src/applatix.io/api/common.go +++ b/saas/common/src/applatix.io/api/common.go @@ -1,6 +1,9 @@ package api +import "time" + const ( - ArgoDir = ".argo" - DefaultConfigName = "default" + ArgoDir = ".argo" + DefaultConfigName = "default" + DefaultHTTPClientTimeout = time.Minute ) diff --git a/saas/common/src/applatix.io/api/service.go b/saas/common/src/applatix.io/api/service.go index dea2bc2b95ba..3ef8f41758e1 100644 --- a/saas/common/src/applatix.io/api/service.go +++ b/saas/common/src/applatix.io/api/service.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "net/http" "strings" "time" @@ -102,3 +103,24 @@ func (c *ArgoClient) ServiceDelete(id string) *axerror.AXError { url := fmt.Sprintf("services/%s", id) return c.delete(url, nil) } + +// ServiceLogs retrieves the HTTP response object to a /logs endpoint containing a streaming response body +func (c *ArgoClient) ServiceLogs(id string) (*http.Response, *axerror.AXError) { + // TODO: a better interface would be to return a io.ReaderCloser containing the RAW log messages, or a channel of log entries + url := fmt.Sprintf("services/%s/logs", id) + req, axErr := c.prepareRequest("GET", url, nil) + if axErr != nil { + return nil, axErr + } + // We use a new HTTP client instead of the default, since we do not want the + // connection to timeout while reading the response body when tailing logs + clnt := c.newHTTPClient(0) + res, err := clnt.Do(req) + if err != nil { + return nil, axerror.ERR_AX_HTTP_CONNECTION.NewWithMessage(err.Error()) + } + if res.StatusCode >= 400 { + return nil, c.handleErrResponse(res) + } + return res, nil +}