diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 95f9429..7794f03 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -202,7 +202,7 @@ func (c *Client) ListGpuTypes(ctx context.Context) ([]GpuType, error) { return nil, fmt.Errorf("list GPU types: %w", err) } - c.logResponse(ctx, "/v1/gpu-types", resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -230,7 +230,7 @@ func (c *Client) CreateDeployment(ctx context.Context, body DeploymentCreate) (* return nil, fmt.Errorf("create deployment: %w", err) } - c.logResponse(ctx, "/v1/deployments", resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusCreated: @@ -264,7 +264,7 @@ func (c *Client) ListDeployments(ctx context.Context, params *ListDeploymentsPar return Page[Deployment]{}, fmt.Errorf("list deployments: %w", err) } - c.logResponse(ctx, "/v1/deployments", resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -296,7 +296,7 @@ func (c *Client) GetDeployment(ctx context.Context, deploymentID string) (*Deplo return nil, fmt.Errorf("get deployment: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID, resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -327,7 +327,7 @@ func (c *Client) UpdateDeployment(ctx context.Context, deploymentID string, body return nil, fmt.Errorf("update deployment: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID, resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -363,7 +363,7 @@ func (c *Client) ListEndpoints(ctx context.Context, deploymentID string, params return Page[Endpoint]{}, fmt.Errorf("list endpoints: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/endpoints", resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -393,7 +393,7 @@ func (c *Client) ListVersions(ctx context.Context, deploymentID string, params * return Page[Version]{}, fmt.Errorf("list versions: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/versions", resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -423,7 +423,7 @@ func (c *Client) GetVersion(ctx context.Context, deploymentID string, versionNum return nil, fmt.Errorf("get version: %w", err) } - c.logResponse(ctx, fmt.Sprintf("/v1/deployments/%s/versions/%d", deploymentID, versionNumber), resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -453,7 +453,7 @@ func (c *Client) ListBuilds(ctx context.Context, deploymentID string, params *Li return Page[Build]{}, fmt.Errorf("list builds: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/builds", resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -483,7 +483,7 @@ func (c *Client) GetBuild(ctx context.Context, deploymentID string, buildID uuid return nil, fmt.Errorf("get build: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/builds/"+buildID.String(), resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -513,7 +513,7 @@ func (c *Client) ListWorkers(ctx context.Context, deploymentID string, params *L return Page[Worker]{}, fmt.Errorf("list workers: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/workers", resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: diff --git a/internal/api/serverless/env.go b/internal/api/serverless/env.go index 5b8edf2..f4ace35 100644 --- a/internal/api/serverless/env.go +++ b/internal/api/serverless/env.go @@ -31,7 +31,7 @@ func (c *Client) ListDeploymentEnvironmentVariables(ctx context.Context, deploym return Page[EnvironmentVariable]{}, fmt.Errorf("list environment variables: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/environment-variables", resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -62,7 +62,7 @@ func (c *Client) UpdateDeploymentEnvironmentVariable(ctx context.Context, deploy return nil, fmt.Errorf("update environment variable: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/environment-variables/"+key, resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusOK: @@ -97,7 +97,7 @@ func (c *Client) DeleteDeploymentEnvironmentVariable(ctx context.Context, deploy return fmt.Errorf("delete environment variable: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/environment-variables/"+key, resp.StatusCode(), resp.Body) + c.logResponse(ctx, resp.HTTPResponse, resp.Body) switch resp.StatusCode() { case http.StatusNoContent: diff --git a/internal/api/serverless/log.go b/internal/api/serverless/log.go index 9f8ac45..900f2fe 100644 --- a/internal/api/serverless/log.go +++ b/internal/api/serverless/log.go @@ -9,14 +9,17 @@ import ( 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) { +// logResponse writes a debug line for a control-plane response. Path and +// status come from the generated client's HTTPResponse so they track OpenAPI +// regen (no duplicated path literals). 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, httpResp *http.Response, body []byte) { if c.logger == nil || !c.logger.Enabled(ctx, slog.LevelDebug) { return } + path, status := responsePathStatus(httpResp) if body == nil { c.logger.Debug("serverless response", //nolint:errcheck,gosec "path", path, @@ -32,6 +35,17 @@ func (c *Client) logResponse(ctx context.Context, path string, status int, body ) } +func responsePathStatus(httpResp *http.Response) (path string, status int) { + if httpResp == nil { + return "", 0 + } + status = httpResp.StatusCode + if httpResp.Request != nil && httpResp.Request.URL != nil { + path = httpResp.Request.URL.Path + } + return path, status +} + func debugLogBody(status int, body []byte) string { if status >= http.StatusOK && status < http.StatusBadRequest { return string(redactJSONValues(body)) diff --git a/internal/api/serverless/log_test.go b/internal/api/serverless/log_test.go index 5b3e9d1..c5b5335 100644 --- a/internal/api/serverless/log_test.go +++ b/internal/api/serverless/log_test.go @@ -69,7 +69,9 @@ func TestLogResponse_NilBodyOmitsBody(t *testing.T) { c := &Client{ logger: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), } - c.logResponse(context.Background(), "/v1/secrets", http.StatusOK, nil) + httpResp := testHTTPResponse(t, "/v1/secrets", http.StatusOK) + defer func() { _ = httpResp.Body.Close() }() + c.logResponse(context.Background(), httpResp, nil) out := buf.String() if !strings.Contains(out, `"/v1/secrets"`) { t.Fatalf("path missing: %s", out) @@ -79,6 +81,39 @@ func TestLogResponse_NilBodyOmitsBody(t *testing.T) { } } +func TestResponsePathStatus_FromRequestURL(t *testing.T) { + httpResp := testHTTPResponse(t, "/v1/apps/my-app/builds", http.StatusOK) + defer func() { _ = httpResp.Body.Close() }() + path, status := responsePathStatus(httpResp) + if path != "/v1/apps/my-app/builds" { + t.Fatalf("path = %q", path) + } + if status != http.StatusOK { + t.Fatalf("status = %d", status) + } +} + +func TestResponsePathStatus_NilResponse(t *testing.T) { + path, status := responsePathStatus(nil) + if path != "" || status != 0 { + t.Fatalf("got path=%q status=%d", path, status) + } +} + +func testHTTPResponse(t *testing.T, path string, status int) *http.Response { + t.Helper() + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.example"+path, nil) + if err != nil { + t.Fatal(err) + } + resp := &http.Response{ + StatusCode: status, + Body: http.NoBody, + Request: req, + } + return resp +} + func TestDebugLogBody_CreatedRedacts(t *testing.T) { in := []byte(`{"environmentVariables":[{"key":"K","value":"hidden"}]}`) got := debugLogBody(http.StatusCreated, in) diff --git a/internal/api/serverless/secrets.go b/internal/api/serverless/secrets.go index a79971e..f18a9e2 100644 --- a/internal/api/serverless/secrets.go +++ b/internal/api/serverless/secrets.go @@ -47,7 +47,7 @@ func (c *Client) ListSecrets(ctx context.Context, params *ListSecretsParams) (Pa return Page[Secret]{}, fmt.Errorf("list secrets: %w", err) } - c.logResponse(ctx, "/v1/secrets", resp.StatusCode(), nil) + c.logResponse(ctx, resp.HTTPResponse, nil) switch resp.StatusCode() { case http.StatusOK: @@ -80,7 +80,7 @@ func (c *Client) CreateSecret(ctx context.Context, body SecretCreate) (*Secret, return nil, fmt.Errorf("create secret: %w", err) } - c.logResponse(ctx, "/v1/secrets", resp.StatusCode(), nil) + c.logResponse(ctx, resp.HTTPResponse, nil) switch resp.StatusCode() { case http.StatusCreated: @@ -114,7 +114,7 @@ func (c *Client) UpdateSecret(ctx context.Context, name string, body SecretUpdat return nil, fmt.Errorf("update secret: %w", err) } - c.logResponse(ctx, "/v1/secrets/"+name, resp.StatusCode(), nil) + c.logResponse(ctx, resp.HTTPResponse, nil) switch resp.StatusCode() { case http.StatusOK: @@ -149,7 +149,7 @@ func (c *Client) DeleteSecret(ctx context.Context, name string) error { return fmt.Errorf("delete secret: %w", err) } - c.logResponse(ctx, "/v1/secrets/"+name, resp.StatusCode(), nil) + c.logResponse(ctx, resp.HTTPResponse, nil) switch resp.StatusCode() { case http.StatusNoContent: @@ -180,7 +180,7 @@ func (c *Client) ListDeploymentSecrets(ctx context.Context, deploymentID string, return Page[SecretAttachment]{}, fmt.Errorf("list deployment secrets: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/secrets", resp.StatusCode(), nil) + c.logResponse(ctx, resp.HTTPResponse, nil) switch resp.StatusCode() { case http.StatusOK: @@ -215,7 +215,7 @@ func (c *Client) AttachDeploymentSecret(ctx context.Context, deploymentID string return fmt.Errorf("attach secret: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/secrets", resp.StatusCode(), nil) + c.logResponse(ctx, resp.HTTPResponse, nil) switch resp.StatusCode() { case http.StatusNoContent: @@ -249,7 +249,7 @@ func (c *Client) DetachDeploymentSecret(ctx context.Context, deploymentID, secre return fmt.Errorf("detach secret: %w", err) } - c.logResponse(ctx, "/v1/deployments/"+deploymentID+"/secrets/"+secretName, resp.StatusCode(), nil) + c.logResponse(ctx, resp.HTTPResponse, nil) switch resp.StatusCode() { case http.StatusNoContent: