automation-tests/image/docker/docker_client_test.go

445 lines
15 KiB
Go

package docker
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/containers/image/v5/internal/useragent"
"github.com/containers/image/v5/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDockerCertDir(t *testing.T) {
const nondefaultFullPath = "/this/is/not/the/default/full/path"
const nondefaultPerHostDir = "/this/is/not/the/default/certs.d"
const variableReference = "$HOME"
const rootPrefix = "/root/prefix"
const registryHostPort = "thishostdefinitelydoesnotexist:5000"
systemPerHostResult := filepath.Join(perHostCertDirs[len(perHostCertDirs)-1].path, registryHostPort)
for _, c := range []struct {
sys *types.SystemContext
expected string
}{
// The common case
{nil, systemPerHostResult},
// There is a context, but it does not override the path.
{&types.SystemContext{}, systemPerHostResult},
// Full path overridden
{&types.SystemContext{DockerCertPath: nondefaultFullPath}, nondefaultFullPath},
// Per-host path overridden
{
&types.SystemContext{DockerPerHostCertDirPath: nondefaultPerHostDir},
filepath.Join(nondefaultPerHostDir, registryHostPort),
},
// Both overridden
{
&types.SystemContext{
DockerCertPath: nondefaultFullPath,
DockerPerHostCertDirPath: nondefaultPerHostDir,
},
nondefaultFullPath,
},
// Root overridden
{
&types.SystemContext{RootForImplicitAbsolutePaths: rootPrefix},
filepath.Join(rootPrefix, systemPerHostResult),
},
// Root and path overrides present simultaneously,
{
&types.SystemContext{
DockerCertPath: nondefaultFullPath,
RootForImplicitAbsolutePaths: rootPrefix,
},
nondefaultFullPath,
},
{
&types.SystemContext{
DockerPerHostCertDirPath: nondefaultPerHostDir,
RootForImplicitAbsolutePaths: rootPrefix,
},
filepath.Join(nondefaultPerHostDir, registryHostPort),
},
// … and everything at once
{
&types.SystemContext{
DockerCertPath: nondefaultFullPath,
DockerPerHostCertDirPath: nondefaultPerHostDir,
RootForImplicitAbsolutePaths: rootPrefix,
},
nondefaultFullPath,
},
// No environment expansion happens in the overridden paths
{&types.SystemContext{DockerCertPath: variableReference}, variableReference},
{
&types.SystemContext{DockerPerHostCertDirPath: variableReference},
filepath.Join(variableReference, registryHostPort),
},
} {
path, err := dockerCertDir(c.sys, registryHostPort)
require.Equal(t, nil, err)
assert.Equal(t, c.expected, path)
}
}
// testTokenHTTPResponse creates just enough of a *http.Response to work with newBearerTokenFromHTTPResponseBody.
func testTokenHTTPResponse(t *testing.T, body string) *http.Response {
requestURL, err := url.Parse("https://example.com/token")
require.NoError(t, err)
return &http.Response{
Body: io.NopCloser(bytes.NewReader([]byte(body))),
Request: &http.Request{
Method: "",
URL: requestURL,
},
}
}
func TestNewBearerTokenFromHTTPResponseBody(t *testing.T) {
for _, c := range []struct {
input string
expected *bearerToken // or nil if a failure is expected
}{
{ // Invalid JSON
input: "IAmNotJson",
expected: nil,
},
{ // "token"
input: `{"token":"IAmAToken","expires_in":100,"issued_at":"2018-01-01T10:00:02+00:00"}`,
expected: &bearerToken{token: "IAmAToken", expirationTime: time.Unix(1514800802+100, 0)},
},
{ // "access_token"
input: `{"access_token":"IAmAToken","expires_in":100,"issued_at":"2018-01-01T10:00:02+00:00"}`,
expected: &bearerToken{token: "IAmAToken", expirationTime: time.Unix(1514800802+100, 0)},
},
{ // Small expiry
input: `{"token":"IAmAToken","expires_in":1,"issued_at":"2018-01-01T10:00:02+00:00"}`,
expected: &bearerToken{token: "IAmAToken", expirationTime: time.Unix(1514800802+60, 0)},
},
} {
token, err := newBearerTokenFromHTTPResponseBody(testTokenHTTPResponse(t, c.input))
if c.expected == nil {
assert.Error(t, err, c.input)
} else {
require.NoError(t, err, c.input)
assert.Equal(t, c.expected.token, token.token, c.input)
assert.True(t, c.expected.expirationTime.Equal(token.expirationTime),
"expected [%s] to equal [%s], it did not", token.expirationTime, c.expected.expirationTime)
}
}
}
func TestNewBearerTokenFromHTTPResponseBodyIssuedAtZero(t *testing.T) {
zeroTime := time.Time{}.Format(time.RFC3339)
now := time.Now()
tokenBlob := fmt.Sprintf(`{"token":"IAmAToken","expires_in":100,"issued_at":"%s"}`, zeroTime)
token, err := newBearerTokenFromHTTPResponseBody(testTokenHTTPResponse(t, tokenBlob))
require.NoError(t, err)
expectedExpiration := now.Add(time.Duration(100) * time.Second)
require.False(t, token.expirationTime.Before(expectedExpiration),
"expected [%s] not to be before [%s]", token.expirationTime, expectedExpiration)
}
func TestUserAgent(t *testing.T) {
const sentinelUA = "sentinel/1.0"
var expectedUA string
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := r.Header.Get("User-Agent")
assert.Equal(t, expectedUA, got)
w.WriteHeader(http.StatusOK)
}))
defer s.Close()
for _, tc := range []struct {
sys *types.SystemContext
expected string
}{
// Can't both test nil and set DockerInsecureSkipTLSVerify :(
// {nil, defaultUA},
{&types.SystemContext{}, useragent.DefaultUserAgent},
{&types.SystemContext{DockerRegistryUserAgent: sentinelUA}, sentinelUA},
} {
// For this test against localhost, we don't care.
tc.sys.DockerInsecureSkipTLSVerify = types.OptionalBoolTrue
registry := strings.TrimPrefix(s.URL, "http://")
expectedUA = tc.expected
if err := CheckAuth(context.Background(), tc.sys, "", "", registry); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
}
var registrySuseComResp = http.Response{
Status: "401 Unauthorized",
StatusCode: http.StatusUnauthorized,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: map[string][]string{
"Content-Length": {"145"},
"Content-Type": {"application/json"},
"Date": {"Fri, 26 Aug 2022 08:03:13 GMT"},
"Docker-Distribution-Api-Version": {"registry/2.0"},
// "Www-Authenticate": {`Bearer realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="registry:catalog:*",error="insufficient_scope"`},
"X-Content-Type-Options": {"nosniff"},
},
Request: nil,
}
func TestNeedsRetryOnInsuficientScope(t *testing.T) {
resp := registrySuseComResp
resp.Header["Www-Authenticate"] = []string{
`Bearer realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="registry:catalog:*",error="insufficient_scope"`,
}
expectedScope := authScope{
resourceType: "registry",
remoteName: "catalog",
actions: "*",
}
needsRetry, scope := needsRetryWithUpdatedScope(&resp)
if !needsRetry {
t.Fatal("Expected needing to retry")
}
if expectedScope != *scope {
t.Fatalf("Got an invalid scope, expected '%q' but got '%q'", expectedScope, *scope)
}
}
func TestNeedsRetryNoRetryWhenNoAuthHeader(t *testing.T) {
resp := registrySuseComResp
delete(resp.Header, "Www-Authenticate")
needsRetry, _ := needsRetryWithUpdatedScope(&resp)
if needsRetry {
t.Fatal("Expected no need to retry, as no Authentication headers are present")
}
}
func TestNeedsRetryNoRetryWhenNoBearerAuthHeader(t *testing.T) {
resp := registrySuseComResp
resp.Header["Www-Authenticate"] = []string{
`OAuth2 realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="registry:catalog:*"`,
}
needsRetry, _ := needsRetryWithUpdatedScope(&resp)
if needsRetry {
t.Fatal("Expected no need to retry, as no bearer authentication header is present")
}
}
func TestNeedsRetryNoRetryWhenNoErrorInBearer(t *testing.T) {
resp := registrySuseComResp
resp.Header["Www-Authenticate"] = []string{
`Bearer realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="registry:catalog:*"`,
}
needsRetry, _ := needsRetryWithUpdatedScope(&resp)
if needsRetry {
t.Fatal("Expected no need to retry, as no insufficient error is present in the authentication header")
}
}
func TestNeedsRetryNoRetryWhenInvalidErrorInBearer(t *testing.T) {
resp := registrySuseComResp
resp.Header["Www-Authenticate"] = []string{
`Bearer realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="registry:catalog:*,error="random_error"`,
}
needsRetry, _ := needsRetryWithUpdatedScope(&resp)
if needsRetry {
t.Fatal("Expected no need to retry, as no insufficient_error is present in the authentication header")
}
}
func TestNeedsRetryNoRetryWhenInvalidScope(t *testing.T) {
resp := registrySuseComResp
resp.Header["Www-Authenticate"] = []string{
`Bearer realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="foo:bar",error="insufficient_scope"`,
}
needsRetry, _ := needsRetryWithUpdatedScope(&resp)
if needsRetry {
t.Fatal("Expected no need to retry, as no insufficient_error is present in the authentication header")
}
}
func TestNeedsNoRetry(t *testing.T) {
resp := http.Response{
Status: "200 OK",
StatusCode: http.StatusOK,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: map[string][]string{"Apptime": {"D=49722"},
"Content-Length": {"1683"},
"Content-Type": {"application/json; charset=utf-8"},
"Date": {"Fri, 26 Aug 2022 09:00:21 GMT"},
"Docker-Distribution-Api-Version": {"registry/2.0"},
"Link": {`</v2/_catalog?last=f35%2Fs2i-base&n=100>; rel="next"`},
"Referrer-Policy": {"same-origin"},
"Server": {"Apache"},
"Strict-Transport-Security": {"max-age=31536000; includeSubDomains; preload"},
"Vary": {"Accept"},
"X-Content-Type-Options": {"nosniff"},
"X-Fedora-Proxyserver": {"proxy10.iad2.fedoraproject.org"},
"X-Fedora-Requestid": {"YwiLpHEhLsbSTugJblBF8QAAAEI"},
"X-Frame-Options": {"SAMEORIGIN"},
"X-Xss-Protection": {"1; mode=block"},
},
}
needsRetry, _ := needsRetryWithUpdatedScope(&resp)
if needsRetry {
t.Fatal("Got the need to retry, but none should be required")
}
}
func TestParseRegistryWarningHeader(t *testing.T) {
for _, c := range []struct{ header, expected string }{
{"completely invalid", ""},
{`299 - "trivial"`, "trivial"},
{`100 - "not-299"`, ""},
{`299 localhost "warn-agent set"`, ""},
{`299 - "no-terminating-quote`, ""},
{"299 - \"\x01 control\"", ""},
{"299 - \"\\\x01 escaped control\"", ""},
{"299 - \"e\\scaped\"", "escaped"},
{"299 - \"non-UTF8 \xA1\xA2\"", "non-UTF8 \xA1\xA2"},
{"299 - \"non-UTF8 escaped \\\xA1\\\xA2\"", "non-UTF8 escaped \xA1\xA2"},
{"299 - \"UTF8 žluťoučký\"", "UTF8 žluťoučký"},
{"299 - \"UTF8 \\\xC5\\\xBEluťoučký\"", "UTF8 žluťoučký"},
{`299 - "unterminated`, ""},
{`299 - "warning" "some-date"`, ""},
} {
res := parseRegistryWarningHeader(c.header)
assert.Equal(t, c.expected, res, c.header)
}
}
func TestGetBlobSize(t *testing.T) {
for _, c := range []struct {
headers []string
expected int64 // -1 if error expected
}{
{[]string{}, -1},
{[]string{"0"}, 0},
{[]string{"1"}, 1},
{[]string{"0777"}, 777}, // Not interpreted as octal
{[]string{"x"}, -1}, // Not a number: Go's response reader rejects such responses.
{[]string{"1", "2"}, -1}, // Ambiguous: Go's response reader rejects such responses.
{[]string{""}, -1}, // Empty header: Go's response reader rejects such responses.
{[]string{"-1"}, -1}, // Negative: Go's response reader rejects such responses.
} {
var buf bytes.Buffer
buf.WriteString("HTTP/1.1 200 OK\r\n")
for _, v := range c.headers {
buf.WriteString("Content-Length: " + v + "\r\n")
}
buf.WriteString("\r\n")
resp, err := http.ReadResponse(bufio.NewReader(&buf), nil)
if err != nil {
assert.Equal(t, int64(-1), c.expected)
} else {
res, err := getBlobSize(resp)
if c.expected == -1 {
assert.Error(t, err, c.headers)
} else {
require.NoError(t, err, c.headers)
assert.Equal(t, c.expected, res)
}
}
}
}
func TestIsManifestUnknownError(t *testing.T) {
// Mostly a smoke test; we can add more registries here if they need special handling.
for _, c := range []struct{ name, response string }{
{
name: "docker.io when a tag in an _existing repo_ is not found",
response: "HTTP/1.1 404 Not Found\r\n" +
"Connection: close\r\n" +
"Content-Length: 109\r\n" +
"Content-Type: application/json\r\n" +
"Date: Thu, 12 Aug 2021 20:51:32 GMT\r\n" +
"Docker-Distribution-Api-Version: registry/2.0\r\n" +
"Ratelimit-Limit: 100;w=21600\r\n" +
"Ratelimit-Remaining: 100;w=21600\r\n" +
"Strict-Transport-Security: max-age=31536000\r\n" +
"\r\n" +
"{\"errors\":[{\"code\":\"MANIFEST_UNKNOWN\",\"message\":\"manifest unknown\",\"detail\":{\"Tag\":\"this-does-not-exist\"}}]}\n",
},
{
name: "registry.redhat.io/v2/this-does-not-exist/manifests/latest",
response: "HTTP/1.1 404 Not Found\r\n" +
"Connection: close\r\n" +
"Content-Length: 53\r\n" +
"Cache-Control: max-age=0, no-cache, no-store\r\n" +
"Content-Type: application/json\r\n" +
"Date: Thu, 13 Oct 2022 18:15:15 GMT\r\n" +
"Expires: Thu, 13 Oct 2022 18:15:15 GMT\r\n" +
"Pragma: no-cache\r\n" +
"Server: Apache\r\n" +
"Strict-Transport-Security: max-age=63072000; includeSubdomains; preload\r\n" +
"X-Hostname: crane-tbr06.cran-001.prod.iad2.dc.redhat.com\r\n" +
"\r\n" +
"{\"errors\": [{\"code\": \"404\", \"message\": \"Not Found\"}]}\r\n",
},
{
name: "registry.redhat.io/v2/rhosp15-rhel8/openstack-cron/manifests/sha256-8df5e60c42668706ac108b59c559b9187fa2de7e4e262e2967e3e9da35d5a8d7.sig",
response: "HTTP/1.1 404 Not Found\r\n" +
"Connection: close\r\n" +
"Content-Length: 10\r\n" +
"Accept-Ranges: bytes\r\n" +
"Date: Thu, 13 Oct 2022 18:13:53 GMT\r\n" +
"Server: AkamaiNetStorage\r\n" +
"X-Docker-Size: -1\r\n" +
"\r\n" +
"Not found\r\n",
},
{
name: "Harbor v2.10.2",
response: "HTTP/1.1 404 Not Found\r\n" +
"Content-Length: 153\r\n" +
"Connection: keep-alive\r\n" +
"Content-Type: application/json; charset=utf-8\r\n" +
"Date: Wed, 08 May 2024 08:14:59 GMT\r\n" +
"Server: nginx\r\n" +
"Set-Cookie: sid=f617c257877837614ada2561513d6827; Path=/; HttpOnly\r\n" +
"X-Request-Id: 1b151fb1-c943-4190-a9ce-5156ed5e3200\r\n" +
"\r\n" +
"{\"errors\":[{\"code\":\"NOT_FOUND\",\"message\":\"artifact test/alpine:sha256-443205b0cfcc78444321d56a2fe273f06e27b2c72b5058f8d7e975997d45b015.sig not found\"}]}\n",
},
} {
resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader([]byte(c.response))), nil)
require.NoError(t, err, c.name)
defer resp.Body.Close()
err = fmt.Errorf("wrapped: %w", registryHTTPResponseToError(resp))
res := isManifestUnknownError(err)
assert.True(t, res, "%s: %#v", c.name, err)
}
}