diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index f7b89ed..95f9429 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -202,13 +202,7 @@ func (c *Client) ListGpuTypes(ctx context.Context) ([]GpuType, error) { return nil, fmt.Errorf("list GPU types: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/gpu-types", - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/gpu-types", resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -236,13 +230,7 @@ func (c *Client) CreateDeployment(ctx context.Context, body DeploymentCreate) (* return nil, fmt.Errorf("create deployment: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments", - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments", resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusCreated: @@ -276,13 +264,7 @@ func (c *Client) ListDeployments(ctx context.Context, params *ListDeploymentsPar return Page[Deployment]{}, fmt.Errorf("list deployments: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments", - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments", resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -314,13 +296,7 @@ func (c *Client) GetDeployment(ctx context.Context, deploymentID string) (*Deplo return nil, fmt.Errorf("get deployment: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID, - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID, resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -351,13 +327,7 @@ func (c *Client) UpdateDeployment(ctx context.Context, deploymentID string, body return nil, fmt.Errorf("update deployment: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID, - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID, resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -393,13 +363,7 @@ func (c *Client) ListEndpoints(ctx context.Context, deploymentID string, params return Page[Endpoint]{}, fmt.Errorf("list endpoints: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/endpoints", - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/endpoints", resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -429,13 +393,7 @@ func (c *Client) ListVersions(ctx context.Context, deploymentID string, params * return Page[Version]{}, fmt.Errorf("list versions: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/versions", - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/versions", resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -465,13 +423,7 @@ func (c *Client) GetVersion(ctx context.Context, deploymentID string, versionNum return nil, fmt.Errorf("get version: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", fmt.Sprintf("/v1/deployments/%s/versions/%d", deploymentID, versionNumber), - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, fmt.Sprintf("/v1/deployments/%s/versions/%d", deploymentID, versionNumber), resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -501,13 +453,7 @@ func (c *Client) ListBuilds(ctx context.Context, deploymentID string, params *Li return Page[Build]{}, fmt.Errorf("list builds: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/builds", - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/builds", resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -537,13 +483,7 @@ func (c *Client) GetBuild(ctx context.Context, deploymentID string, buildID uuid return nil, fmt.Errorf("get build: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/builds/"+buildID.String(), - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/builds/"+buildID.String(), resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -573,13 +513,7 @@ func (c *Client) ListWorkers(ctx context.Context, deploymentID string, params *L return Page[Worker]{}, fmt.Errorf("list workers: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/workers", - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/workers", resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: diff --git a/internal/api/serverless/env.go b/internal/api/serverless/env.go index f2be009..5b8edf2 100644 --- a/internal/api/serverless/env.go +++ b/internal/api/serverless/env.go @@ -3,7 +3,6 @@ package serverless import ( "context" "fmt" - "log/slog" "net/http" "github.com/runware/runware-cli/internal/api/serverless/gen" @@ -32,13 +31,7 @@ func (c *Client) ListDeploymentEnvironmentVariables(ctx context.Context, deploym return Page[EnvironmentVariable]{}, fmt.Errorf("list environment variables: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/environment-variables", - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/environment-variables", resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -69,13 +62,7 @@ func (c *Client) UpdateDeploymentEnvironmentVariable(ctx context.Context, deploy return nil, fmt.Errorf("update environment variable: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/environment-variables/"+key, - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/environment-variables/"+key, resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -110,13 +97,7 @@ func (c *Client) DeleteDeploymentEnvironmentVariable(ctx context.Context, deploy return fmt.Errorf("delete environment variable: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/environment-variables/"+key, - "status", resp.StatusCode(), - "body", string(resp.Body), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/environment-variables/"+key, resp.StatusCode(), resp.Body) switch resp.StatusCode() { case http.StatusNoContent: diff --git a/internal/api/serverless/log.go b/internal/api/serverless/log.go new file mode 100644 index 0000000..9f8ac45 --- /dev/null +++ b/internal/api/serverless/log.go @@ -0,0 +1,85 @@ +package serverless + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" +) + +const redactedValue = "[redacted]" + +// logResponse writes a debug line for a control-plane response. Success bodies +// have JSON "value" fields redacted so plaintext env vars are not persisted in +// debug logs. Error bodies (problem details) are logged as returned. A nil body +// logs path and status only (used for secrets endpoints whose metadata is opaque). +func (c *Client) logResponse(ctx context.Context, path string, status int, body []byte) { + if c.logger == nil || !c.logger.Enabled(ctx, slog.LevelDebug) { + return + } + if body == nil { + c.logger.Debug("serverless response", //nolint:errcheck,gosec + "path", path, + "status", status, + ) + return + } + c.logger.Debug("serverless response", //nolint:errcheck,gosec + "path", path, + "status", status, + "bodyBytes", len(body), + "body", debugLogBody(status, body), + ) +} + +func debugLogBody(status int, body []byte) string { + if status >= http.StatusOK && status < http.StatusBadRequest { + return string(redactJSONValues(body)) + } + return string(body) +} + +// redactJSONValues replaces every object key "value" with "[redacted]". +// Returns the original bytes if the body is not JSON or has no such key. +func redactJSONValues(body []byte) []byte { + var v any + if err := json.Unmarshal(body, &v); err != nil { + return body + } + if !redactValueKeys(v) { + return body + } + out, err := json.Marshal(v) + if err != nil { + return body + } + return out +} + +func redactValueKeys(v any) bool { + switch t := v.(type) { + case map[string]any: + changed := false + for k, child := range t { + if k == "value" { + t[k] = redactedValue + changed = true + continue + } + if redactValueKeys(child) { + changed = true + } + } + return changed + case []any: + changed := false + for _, child := range t { + if redactValueKeys(child) { + changed = true + } + } + return changed + default: + return false + } +} diff --git a/internal/api/serverless/log_test.go b/internal/api/serverless/log_test.go new file mode 100644 index 0000000..5b3e9d1 --- /dev/null +++ b/internal/api/serverless/log_test.go @@ -0,0 +1,92 @@ +package serverless + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "strings" + "testing" +) + +func TestRedactJSONValues_EnvVarList(t *testing.T) { + in := []byte(`{"data":[{"key":"MY_KEY","value":"super-secret"}],"nextCursor":"page-2"}`) + out := redactJSONValues(in) + if strings.Contains(string(out), "super-secret") { + t.Fatalf("value leaked: %s", out) + } + if !strings.Contains(string(out), `"key":"MY_KEY"`) { + t.Fatalf("key should remain: %s", out) + } + if !strings.Contains(string(out), `"value":"[redacted]"`) { + t.Fatalf("value should be redacted: %s", out) + } +} + +func TestRedactJSONValues_NestedDeploymentEnvVars(t *testing.T) { + in := []byte(`{"deploymentId":"my-app","environmentVariables":[{"key":"K","value":"v"}]}`) + out := redactJSONValues(in) + if strings.Contains(string(out), `"value":"v"`) { + t.Fatalf("nested value leaked: %s", out) + } + if !strings.Contains(string(out), `"deploymentId":"my-app"`) { + t.Fatalf("other fields should remain: %s", out) + } +} + +func TestRedactJSONValues_UnchangedWithoutValueKey(t *testing.T) { + in := []byte(`{"data":[{"id":"h100","name":"H100"}]}`) + out := redactJSONValues(in) + if !bytes.Equal(out, in) { + t.Fatalf("expected original bytes, got %s", out) + } +} + +func TestRedactJSONValues_InvalidJSON(t *testing.T) { + in := []byte("not-json") + if got := string(redactJSONValues(in)); got != "not-json" { + t.Fatalf("got %q", got) + } +} + +func TestDebugLogBody_RedactsSuccessKeepsError(t *testing.T) { + secret := []byte(`{"key":"MY_KEY","value":"super-secret"}`) + got := debugLogBody(http.StatusOK, secret) + if strings.Contains(got, "super-secret") { + t.Fatalf("200 leaked value: %s", got) + } + + problem := []byte(`{"title":"Unprocessable Entity","status":422,"detail":"RUNTIME is a reserved platform name"}`) + got = debugLogBody(http.StatusUnprocessableEntity, problem) + if got != string(problem) { + t.Fatalf("422 body should be unchanged, got %s", got) + } +} + +func TestLogResponse_NilBodyOmitsBody(t *testing.T) { + var buf bytes.Buffer + c := &Client{ + logger: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), + } + c.logResponse(context.Background(), "/v1/secrets", http.StatusOK, nil) + out := buf.String() + if !strings.Contains(out, `"/v1/secrets"`) { + t.Fatalf("path missing: %s", out) + } + if strings.Contains(out, `"body"`) || strings.Contains(out, "bodyBytes") { + t.Fatalf("nil body should omit body attrs: %s", out) + } +} + +func TestDebugLogBody_CreatedRedacts(t *testing.T) { + in := []byte(`{"environmentVariables":[{"key":"K","value":"hidden"}]}`) + got := debugLogBody(http.StatusCreated, in) + if strings.Contains(got, "hidden") { + t.Fatalf("201 leaked value: %s", got) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(got), &parsed); err != nil { + t.Fatalf("redacted body should stay JSON: %v", err) + } +} diff --git a/internal/api/serverless/secrets.go b/internal/api/serverless/secrets.go index 727d9af..a79971e 100644 --- a/internal/api/serverless/secrets.go +++ b/internal/api/serverless/secrets.go @@ -3,7 +3,6 @@ package serverless import ( "context" "fmt" - "log/slog" "net/http" "github.com/runware/runware-cli/internal/api/serverless/gen" @@ -48,12 +47,7 @@ func (c *Client) ListSecrets(ctx context.Context, params *ListSecretsParams) (Pa return Page[Secret]{}, fmt.Errorf("list secrets: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/secrets", - "status", resp.StatusCode(), - ) - } + c.logResponse(ctx, "/v1/secrets", resp.StatusCode(), nil) switch resp.StatusCode() { case http.StatusOK: @@ -86,12 +80,7 @@ func (c *Client) CreateSecret(ctx context.Context, body SecretCreate) (*Secret, return nil, fmt.Errorf("create secret: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/secrets", - "status", resp.StatusCode(), - ) - } + c.logResponse(ctx, "/v1/secrets", resp.StatusCode(), nil) switch resp.StatusCode() { case http.StatusCreated: @@ -125,12 +114,7 @@ func (c *Client) UpdateSecret(ctx context.Context, name string, body SecretUpdat return nil, fmt.Errorf("update secret: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/secrets/"+name, - "status", resp.StatusCode(), - ) - } + c.logResponse(ctx, "/v1/secrets/"+name, resp.StatusCode(), nil) switch resp.StatusCode() { case http.StatusOK: @@ -165,12 +149,7 @@ func (c *Client) DeleteSecret(ctx context.Context, name string) error { return fmt.Errorf("delete secret: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/secrets/"+name, - "status", resp.StatusCode(), - ) - } + c.logResponse(ctx, "/v1/secrets/"+name, resp.StatusCode(), nil) switch resp.StatusCode() { case http.StatusNoContent: @@ -201,12 +180,7 @@ func (c *Client) ListDeploymentSecrets(ctx context.Context, deploymentID string, return Page[SecretAttachment]{}, fmt.Errorf("list deployment secrets: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/secrets", - "status", resp.StatusCode(), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/secrets", resp.StatusCode(), nil) switch resp.StatusCode() { case http.StatusOK: @@ -241,12 +215,7 @@ func (c *Client) AttachDeploymentSecret(ctx context.Context, deploymentID string return fmt.Errorf("attach secret: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/secrets", - "status", resp.StatusCode(), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/secrets", resp.StatusCode(), nil) switch resp.StatusCode() { case http.StatusNoContent: @@ -280,12 +249,7 @@ func (c *Client) DetachDeploymentSecret(ctx context.Context, deploymentID, secre return fmt.Errorf("detach secret: %w", err) } - if c.logger != nil && c.logger.Enabled(ctx, slog.LevelDebug) { - c.logger.Debug("serverless response", //nolint:errcheck,gosec - "path", "/v1/deployments/"+deploymentID+"/secrets/"+secretName, - "status", resp.StatusCode(), - ) - } + c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/secrets/"+secretName, resp.StatusCode(), nil) switch resp.StatusCode() { case http.StatusNoContent: