Skip to content

Commit 53f9612

Browse files
cgwaltersmtrmac
authored andcommitted
main: Add support for overriding HTTP User-Agent
I want this for bootc-dev/bootc#1686 so we can distinguish pulls there. But more generally it's can be a good idea for people writing scripts using skopeo to set custom user agents so that registries can more easily trace which actors are performing tasks. Assisted-by: Claude Code Signed-off-by: Colin Walters <[email protected]>
1 parent 6b2c20c commit 53f9612

File tree

3 files changed

+125
-1
lines changed

3 files changed

+125
-1
lines changed

cmd/skopeo/main.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type globalOptions struct {
3030
commandTimeout time.Duration // Timeout for the command execution
3131
registriesConfPath string // Path to the "registries.conf" file
3232
tmpDir string // Path to use for big temporary files
33+
userAgentPrefix string // Prefix to add to the user agent string
3334
}
3435

3536
// requireSubcommand returns an error if no sub command is provided
@@ -90,6 +91,7 @@ func createApp() (*cobra.Command, *globalOptions) {
9091
logrus.Fatal("unable to mark registries-conf flag as hidden")
9192
}
9293
rootCommand.PersistentFlags().StringVar(&opts.tmpDir, "tmpdir", "", "directory used to store temporary files")
94+
rootCommand.PersistentFlags().StringVar(&opts.userAgentPrefix, "user-agent-prefix", "", "prefix to add to the user agent string")
9395
flag := commonFlag.OptionalBoolFlag(rootCommand.Flags(), &opts.tlsVerify, "tls-verify", "Require HTTPS and verify certificates when accessing the registry")
9496
flag.Hidden = true
9597
rootCommand.AddCommand(
@@ -181,14 +183,18 @@ func (opts *globalOptions) commandTimeoutContext() (context.Context, context.Can
181183
// newSystemContext returns a *types.SystemContext corresponding to opts.
182184
// It is guaranteed to return a fresh instance, so it is safe to make additional updates to it.
183185
func (opts *globalOptions) newSystemContext() *types.SystemContext {
186+
userAgent := defaultUserAgent
187+
if opts.userAgentPrefix != "" {
188+
userAgent = opts.userAgentPrefix + " " + defaultUserAgent
189+
}
184190
ctx := &types.SystemContext{
185191
RegistriesDirPath: opts.registriesDirPath,
186192
ArchitectureChoice: opts.overrideArch,
187193
OSChoice: opts.overrideOS,
188194
VariantChoice: opts.overrideVariant,
189195
SystemRegistriesConfPath: opts.registriesConfPath,
190196
BigFilesTemporaryDir: opts.tmpDir,
191-
DockerRegistryUserAgent: defaultUserAgent,
197+
DockerRegistryUserAgent: userAgent,
192198
}
193199
// DEPRECATED: We support this for backward compatibility, but override it if a per-image flag is provided.
194200
if opts.tlsVerify.Present() {

docs/skopeo.1.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ Use registry configuration files in _dir_ (e.g. for container signature storage)
9696

9797
Directory used to store temporary files. Defaults to /var/tmp.
9898

99+
**--user-agent-prefix** _prefix_
100+
101+
Prefix to add to the user agent string. The resulting user agent will be in the format "_prefix_ skopeo/_version_".
102+
99103
**--version**, **-v**
100104

101105
Print the version number

integration/user_agent_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"slices"
7+
"strings"
8+
"sync"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// mockRegistryHandler implements a minimal Docker Registry V2 API that captures User-Agent headers
15+
type mockRegistryHandler struct {
16+
mu sync.Mutex
17+
userAgents []string
18+
}
19+
20+
func (h *mockRegistryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
21+
// Capture the User-Agent header
22+
h.mu.Lock()
23+
h.userAgents = append(h.userAgents, r.Header.Get("User-Agent"))
24+
h.mu.Unlock()
25+
26+
// Implement minimal Docker Registry V2 API endpoints for inspect --raw
27+
switch {
28+
case r.URL.Path == "/v2/":
29+
// Registry version check endpoint
30+
w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
31+
w.WriteHeader(http.StatusOK)
32+
33+
case strings.HasSuffix(r.URL.Path, "/manifests/latest"):
34+
// Return a minimal OCI manifest as raw string
35+
// The digest matches this exact content
36+
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0}]}`
37+
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
38+
w.WriteHeader(http.StatusOK)
39+
if _, err := w.Write([]byte(manifest)); err != nil {
40+
panic(err)
41+
}
42+
43+
default:
44+
w.WriteHeader(http.StatusNotFound)
45+
}
46+
}
47+
48+
func (h *mockRegistryHandler) getUserAgents() []string {
49+
h.mu.Lock()
50+
defer h.mu.Unlock()
51+
return slices.Clone(h.userAgents)
52+
}
53+
54+
func TestUserAgent(t *testing.T) {
55+
testCases := []struct {
56+
name string
57+
extraArgs []string
58+
userAgentValidator func(string) bool
59+
description string
60+
}{
61+
{
62+
name: "default user agent",
63+
extraArgs: []string{},
64+
userAgentValidator: func(ua string) bool {
65+
return strings.HasPrefix(ua, "skopeo/")
66+
},
67+
description: "Default user agent should start with 'skopeo/'",
68+
},
69+
{
70+
name: "custom user agent prefix",
71+
extraArgs: []string{"--user-agent-prefix", "bootc/1.0"},
72+
userAgentValidator: func(ua string) bool {
73+
return strings.HasPrefix(ua, "bootc/1.0 skopeo/")
74+
},
75+
description: "Custom user agent should be in format 'prefix skopeo/version'",
76+
},
77+
{
78+
name: "prefix with spaces",
79+
extraArgs: []string{"--user-agent-prefix", "my cool app"},
80+
userAgentValidator: func(ua string) bool {
81+
return strings.HasPrefix(ua, "my cool app skopeo/")
82+
},
83+
description: "User agent with spaces should work correctly",
84+
},
85+
}
86+
87+
for _, tc := range testCases {
88+
t.Run(tc.name, func(t *testing.T) {
89+
handler := &mockRegistryHandler{}
90+
server := httptest.NewServer(handler)
91+
defer server.Close()
92+
93+
// Extract host:port from the test server URL
94+
registryAddr := strings.TrimPrefix(server.URL, "http://")
95+
imageRef := "docker://" + registryAddr + "/test/image:latest"
96+
97+
// Build arguments: base args + test-specific args + image ref
98+
args := append([]string{"--tls-verify=false"}, tc.extraArgs...)
99+
args = append(args, "inspect", "--raw", imageRef)
100+
101+
// Run skopeo inspect --raw
102+
assertSkopeoSucceeds(t, "", args...)
103+
104+
// Verify that at least one request was made with the expected User-Agent
105+
userAgents := handler.getUserAgents()
106+
require.NotEmpty(t, userAgents, "Expected at least one request to be made")
107+
108+
// Check that at least one User-Agent matches the validator
109+
require.True(t,
110+
slices.ContainsFunc(userAgents, tc.userAgentValidator),
111+
"%s, got: %v", tc.description, userAgents)
112+
})
113+
}
114+
}

0 commit comments

Comments
 (0)