Skip to content

Commit 191f05c

Browse files
TerryHoweqweeah
authored andcommitted
fix: add debug logging to oci transport
Signed-off-by: Terry Howe <[email protected]> Co-authored-by: Billy Zha <[email protected]> (cherry picked from commit b52bb41)
1 parent 04cad46 commit 191f05c

File tree

4 files changed

+589
-23
lines changed

4 files changed

+589
-23
lines changed

pkg/registry/client.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,9 @@ func NewClient(options ...ClientOption) (*Client, error) {
106106
client.credentialsFile = helmpath.ConfigPath(CredentialsFileBasename)
107107
}
108108
if client.httpClient == nil {
109-
transport := newTransport()
109+
transport := newTransport(client.debug)
110110
client.httpClient = &http.Client{
111-
Transport: retry.NewTransport(transport),
111+
Transport: transport,
112112
}
113113
}
114114

@@ -357,14 +357,19 @@ func ensureTLSConfig(client *auth.Client) (*tls.Config, error) {
357357
switch t := client.Client.Transport.(type) {
358358
case *http.Transport:
359359
transport = t
360-
case *retry.Transport:
360+
case *fallbackTransport:
361361
switch t := t.Base.(type) {
362362
case *http.Transport:
363363
transport = t
364-
case *fallbackTransport:
364+
case *retry.Transport:
365365
switch t := t.Base.(type) {
366366
case *http.Transport:
367367
transport = t
368+
case *LoggingTransport:
369+
switch t := t.RoundTripper.(type) {
370+
case *http.Transport:
371+
transport = t
372+
}
368373
}
369374
}
370375
}

pkg/registry/fallback.go

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,25 +31,8 @@ type fallbackTransport struct {
3131
forceHTTP atomic.Bool
3232
}
3333

34-
func newTransport() *fallbackTransport {
35-
type cloner[T any] interface {
36-
Clone() T
37-
}
38-
// try to copy (clone) the http.DefaultTransport so any mutations we
39-
// perform on it (e.g. TLS config) are not reflected globally
40-
// follow https://github.com/golang/go/issues/39299 for a more elegant
41-
// solution in the future
42-
baseTransport := http.DefaultTransport
43-
if t, ok := baseTransport.(cloner[*http.Transport]); ok {
44-
baseTransport = t.Clone()
45-
} else if t, ok := baseTransport.(cloner[http.RoundTripper]); ok {
46-
// this branch will not be used with go 1.20, it was added
47-
// optimistically to try to clone if the http.DefaultTransport
48-
// implementation changes, still the Clone method in that case
49-
// might not return http.RoundTripper...
50-
baseTransport = t.Clone()
51-
}
52-
34+
func newTransport(debug bool) *fallbackTransport {
35+
baseTransport := NewTransport(debug)
5336
return &fallbackTransport{
5437
Base: baseTransport,
5538
}

pkg/registry/transport.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
Copyright The Helm Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package registry
18+
19+
import (
20+
"bytes"
21+
"fmt"
22+
"io"
23+
"log/slog"
24+
"mime"
25+
"net/http"
26+
"os"
27+
"strings"
28+
"sync/atomic"
29+
30+
"oras.land/oras-go/v2/registry/remote/retry"
31+
)
32+
33+
var (
34+
// requestCount records the number of logged request-response pairs and will
35+
// be used as the unique id for the next pair.
36+
requestCount uint64
37+
38+
// toScrub is a set of headers that should be scrubbed from the log.
39+
toScrub = []string{
40+
"Authorization",
41+
"Set-Cookie",
42+
}
43+
)
44+
45+
// payloadSizeLimit limits the maximum size of the response body to be printed.
46+
const payloadSizeLimit int64 = 16 * 1024 // 16 KiB
47+
48+
// LoggingTransport is an http.RoundTripper that keeps track of the in-flight
49+
// request and add hooks to report HTTP tracing events.
50+
type LoggingTransport struct {
51+
http.RoundTripper
52+
}
53+
54+
// NewTransport creates and returns a new instance of LoggingTransport
55+
func NewTransport(debug bool) *retry.Transport {
56+
type cloner[T any] interface {
57+
Clone() T
58+
}
59+
60+
// try to copy (clone) the http.DefaultTransport so any mutations we
61+
// perform on it (e.g. TLS config) are not reflected globally
62+
// follow https://github.com/golang/go/issues/39299 for a more elegant
63+
// solution in the future
64+
transport := http.DefaultTransport
65+
if t, ok := transport.(cloner[*http.Transport]); ok {
66+
transport = t.Clone()
67+
} else if t, ok := transport.(cloner[http.RoundTripper]); ok {
68+
// this branch will not be used with go 1.20, it was added
69+
// optimistically to try to clone if the http.DefaultTransport
70+
// implementation changes, still the Clone method in that case
71+
// might not return http.RoundTripper...
72+
transport = t.Clone()
73+
}
74+
if debug {
75+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
76+
Level: slog.LevelDebug}))
77+
slog.SetDefault(logger)
78+
transport = &LoggingTransport{RoundTripper: transport}
79+
}
80+
81+
return retry.NewTransport(transport)
82+
}
83+
84+
// RoundTrip calls base round trip while keeping track of the current request.
85+
func (t *LoggingTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
86+
id := atomic.AddUint64(&requestCount, 1) - 1
87+
88+
slog.Debug("Request", "id", id, "url", req.URL, "method", req.Method, "header", logHeader(req.Header))
89+
resp, err = t.RoundTripper.RoundTrip(req)
90+
if err != nil {
91+
slog.Debug("Response", "id", id, "error", err)
92+
} else if resp != nil {
93+
slog.Debug("Response", "id", id, "status", resp.Status, "header", logHeader(resp.Header), "body", logResponseBody(resp))
94+
} else {
95+
slog.Debug("Response", "id", id, "response", "nil")
96+
}
97+
98+
return resp, err
99+
}
100+
101+
// logHeader prints out the provided header keys and values, with auth header scrubbed.
102+
func logHeader(header http.Header) string {
103+
if len(header) > 0 {
104+
headers := []string{}
105+
for k, v := range header {
106+
for _, h := range toScrub {
107+
if strings.EqualFold(k, h) {
108+
v = []string{"*****"}
109+
}
110+
}
111+
headers = append(headers, fmt.Sprintf(" %q: %q", k, strings.Join(v, ", ")))
112+
}
113+
return strings.Join(headers, "\n")
114+
}
115+
return " Empty header"
116+
}
117+
118+
// logResponseBody prints out the response body if it is printable and within size limit.
119+
func logResponseBody(resp *http.Response) string {
120+
if resp.Body == nil || resp.Body == http.NoBody {
121+
return " No response body to print"
122+
}
123+
124+
// non-applicable body is not printed and remains untouched for subsequent processing
125+
contentType := resp.Header.Get("Content-Type")
126+
if contentType == "" {
127+
return " Response body without a content type is not printed"
128+
}
129+
if !isPrintableContentType(contentType) {
130+
return fmt.Sprintf(" Response body of content type %q is not printed", contentType)
131+
}
132+
133+
buf := bytes.NewBuffer(nil)
134+
body := resp.Body
135+
// restore the body by concatenating the read body with the remaining body
136+
resp.Body = struct {
137+
io.Reader
138+
io.Closer
139+
}{
140+
Reader: io.MultiReader(buf, body),
141+
Closer: body,
142+
}
143+
// read the body up to limit+1 to check if the body exceeds the limit
144+
if _, err := io.CopyN(buf, body, payloadSizeLimit+1); err != nil && err != io.EOF {
145+
return fmt.Sprintf(" Error reading response body: %v", err)
146+
}
147+
148+
readBody := buf.String()
149+
if len(readBody) == 0 {
150+
return " Response body is empty"
151+
}
152+
if containsCredentials(readBody) {
153+
return " Response body redacted due to potential credentials"
154+
}
155+
if len(readBody) > int(payloadSizeLimit) {
156+
return readBody[:payloadSizeLimit] + "\n...(truncated)"
157+
}
158+
return readBody
159+
}
160+
161+
// isPrintableContentType returns true if the contentType is printable.
162+
func isPrintableContentType(contentType string) bool {
163+
mediaType, _, err := mime.ParseMediaType(contentType)
164+
if err != nil {
165+
return false
166+
}
167+
168+
switch mediaType {
169+
case "application/json", // JSON types
170+
"text/plain", "text/html": // text types
171+
return true
172+
}
173+
return strings.HasSuffix(mediaType, "+json")
174+
}
175+
176+
// containsCredentials returns true if the body contains potential credentials.
177+
func containsCredentials(body string) bool {
178+
return strings.Contains(body, `"token"`) || strings.Contains(body, `"access_token"`)
179+
}

0 commit comments

Comments
 (0)