diff --git a/README.md b/README.md index 892d860..82db114 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,7 @@ Aliases: Flags: -c, --cluster string set the cluster + --decode-output decode the last base64-encoded line in the response and ignore logs -e, --endpoint string endpoint of a non registered cluster -f, --file-input string input file for the request -h, --help help for run diff --git a/cmd/service_run.go b/cmd/service_run.go index eb8cd90..cf409ca 100644 --- a/cmd/service_run.go +++ b/cmd/service_run.go @@ -24,6 +24,7 @@ import ( "io" "io/ioutil" "os" + "strings" "github.com/grycap/oscar-cli/v2/pkg/config" "github.com/grycap/oscar-cli/v2/pkg/service" @@ -58,6 +59,7 @@ func serviceRunFunc(cmd *cobra.Command, args []string) error { inputFile, _ := cmd.Flags().GetString("file-input") textInput, _ := cmd.Flags().GetString("text-input") outputFile, _ := cmd.Flags().GetString("output") + decodeOutput, _ := cmd.Flags().GetBool("decode-output") if inputFile == "" && textInput == "" { return errors.New("you must specify \"--file-input\" or \"--text-input\" flag") } @@ -110,28 +112,28 @@ func serviceRunFunc(cmd *cobra.Command, args []string) error { return errors.New("unable to copy the response") } - // Decode the result body - tmpfile.Seek(0, 0) - decoder := base64.NewDecoder(base64.StdEncoding, tmpfile) - - // Parse output (store file if --output is set) - var out *os.File - - if outputFile != "" { - // Create the file if --output is set - out, err = os.Create(outputFile) + if decodeOutput { + tmpfile.Seek(0, 0) + response, err := io.ReadAll(tmpfile) if err != nil { - return fmt.Errorf("unable to create the file \"%s\"", outputFile) + return errors.New("unable to read the response") } - } else { - // Create a temporary file - out, err = ioutil.TempFile("", "") + decoded, err := decodeLastBase64Line(response) if err != nil { - return errors.New("unable to create a temporary file to decode the result") + return err } - defer os.Remove(out.Name()) + return writeServiceRunOutput(outputFile, bytes.NewReader(decoded)) + } + + // Decode the result body + tmpfile.Seek(0, 0) + decoder := base64.NewDecoder(base64.StdEncoding, tmpfile) + + out, err := createServiceRunOutput(outputFile) + if err != nil { + return err } - defer out.Close() + defer closeServiceRunOutput(out, outputFile) // Copy the decoder stream into out _, err = io.Copy(out, decoder) @@ -158,6 +160,68 @@ func serviceRunFunc(cmd *cobra.Command, args []string) error { return nil } +func decodeLastBase64Line(response []byte) ([]byte, error) { + lines := strings.Split(string(response), "\n") + decoder := base64.StdEncoding.Strict() + + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + decoded, err := decoder.DecodeString(line) + if err == nil { + return decoded, nil + } + } + + return nil, errors.New("unable to find base64-encoded output in the response") +} + +func writeServiceRunOutput(outputFile string, input io.Reader) error { + out, err := createServiceRunOutput(outputFile) + if err != nil { + return err + } + defer closeServiceRunOutput(out, outputFile) + + if _, err := io.Copy(out, input); err != nil { + return errors.New("unable to copy the response") + } + + if outputFile == "" { + out.Seek(0, 0) + if _, err := io.Copy(os.Stdout, out); err != nil { + return errors.New("unable to print the result") + } + } + + return nil +} + +func createServiceRunOutput(outputFile string) (*os.File, error) { + if outputFile != "" { + out, err := os.Create(outputFile) + if err != nil { + return nil, fmt.Errorf("unable to create the file \"%s\"", outputFile) + } + return out, nil + } + + out, err := ioutil.TempFile("", "") + if err != nil { + return nil, errors.New("unable to create a temporary file to decode the result") + } + return out, nil +} + +func closeServiceRunOutput(out *os.File, outputFile string) { + if outputFile == "" { + os.Remove(out.Name()) + } + out.Close() +} + func makeServiceRunCmd() *cobra.Command { serviceRunCmd := &cobra.Command{ Use: "run SERVICE_NAME {--file-input | --text-input}", @@ -173,6 +237,7 @@ func makeServiceRunCmd() *cobra.Command { serviceRunCmd.Flags().StringP("file-input", "f", "", "input file for the request") serviceRunCmd.Flags().StringP("text-input", "i", "", "text input string for the request") serviceRunCmd.Flags().StringP("output", "o", "", "file path to store the output") + serviceRunCmd.Flags().Bool("decode-output", false, "decode the last base64-encoded line in the response and ignore logs") return serviceRunCmd } diff --git a/cmd/service_run_test.go b/cmd/service_run_test.go index eaf4326..2a175b6 100644 --- a/cmd/service_run_test.go +++ b/cmd/service_run_test.go @@ -148,6 +148,120 @@ func TestServiceRunCommandFileInput(t *testing.T) { } } +func TestServiceRunCommandDecodeOutputIgnoresLogs(t *testing.T) { + const ( + clusterName = "run-decode-cluster" + serviceName = "decoder" + serviceToken = "decode-token" + payload = "ping" + expected = "decoded result\nwith multiple lines\n" + ) + + response := strings.Join([]string{ + "2026-05-28 06:38:44,746 - supervisor - INFO - Reading storage configuration", + "2026-05-28 06:38:55,157 - supervisor - INFO - Creating response", + base64.StdEncoding.EncodeToString([]byte(expected)), + "", + }, "\n") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/system/services/"+serviceName: + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(&types.Service{ + Name: serviceName, + Token: serviceToken, + }); err != nil { + t.Fatalf("encoding service response: %v", err) + } + case r.Method == http.MethodPost && r.URL.Path == "/run/"+serviceName: + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, response) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + configFile := writeConfigFile(t, clusterName, server.URL) + outputFile := filepath.Join(t.TempDir(), "result.txt") + + stdout, stderr, err := runCommand(t, + "service", "--config", configFile, + "run", serviceName, + "--cluster", clusterName, + "--text-input", payload, + "--output", outputFile, + "--decode-output", + ) + if err != nil { + t.Fatalf("service run command returned error: %v", err) + } + if stdout != "" { + t.Fatalf("expected empty stdout when output file is set, got %q", stdout) + } + if stderr != "" { + t.Fatalf("expected empty stderr, got %q", stderr) + } + + content, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + if string(content) != expected { + t.Fatalf("expected decoded output %q, got %q", expected, content) + } +} + +func TestServiceRunCommandWithoutDecodeOutputKeepsRawResponseWithLogs(t *testing.T) { + const ( + clusterName = "run-raw-cluster" + serviceName = "raw" + serviceToken = "raw-token" + payload = "ping" + expected = "decoded result" + ) + + response := "log line\n" + base64.StdEncoding.EncodeToString([]byte(expected)) + "\n" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/system/services/"+serviceName: + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(&types.Service{ + Name: serviceName, + Token: serviceToken, + }); err != nil { + t.Fatalf("encoding service response: %v", err) + } + case r.Method == http.MethodPost && r.URL.Path == "/run/"+serviceName: + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, response) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + configFile := writeConfigFile(t, clusterName, server.URL) + + stdout, stderr, err := runCommand(t, + "service", "--config", configFile, + "run", serviceName, + "--cluster", clusterName, + "--text-input", payload, + ) + if err != nil { + t.Fatalf("service run command returned error: %v", err) + } + if stderr != "" { + t.Fatalf("expected empty stderr, got %q", stderr) + } + if stdout != response { + t.Fatalf("expected raw response %q, got %q", response, stdout) + } +} + func TestServiceRunCommandInputValidation(t *testing.T) { const clusterName = "run-validate-cluster" diff --git a/pkg/service/service.go b/pkg/service/service.go index 7d9951f..7895ae9 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -30,6 +30,7 @@ import ( "path/filepath" "reflect" "strings" + "time" "github.com/goccy/go-yaml" "github.com/grycap/oscar-cli/v2/pkg/cluster" @@ -39,6 +40,7 @@ import ( const servicesPath = "/system/services" const runPath = "/run" const jobPath = "/job" +const runServiceTimeoutSeconds = 300 // FDL represents a Functions Definition Language file type FDL struct { @@ -257,9 +259,9 @@ func ApplyService(svc *types.Service, c *cluster.Cluster, method string) error { // RunService invokes a service synchronously (a Serverless backend in the cluster is required) func RunService(c *cluster.Cluster, name string, token string, endpoint string, input io.Reader) (responseBody io.ReadCloser, err error) { - client := http.DefaultClient + client := &http.Client{Timeout: time.Second * runServiceTimeoutSeconds} if token == "" { - client, _ = c.GetClientSafe() + client, _ = c.GetClientSafe(runServiceTimeoutSeconds) if reflect.TypeOf(client.Transport).String() == cluster.BASIC_AUTH { svc, err := GetService(c, name) if err != nil {