mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-06 15:20:29 +00:00
refactor: drop logrus and other direct dependencies (#1319)
After https://gitea.com/gitea/runner/pulls/1304, logrus stayed only to forward logs from docker/cli and moby/go-archive. The runner used a small part of those: volume parsing, the build context tar, the `ssh://` connection and registry credentials. Own code that matches them replaces them, so logrus leaves the module graph. docker/cli stays for its logrus-free `opts` and `connhelper/ssh` packages. Other dependencies that the standard library or existing ones cover go too: mergo, httprouter (replaced by `http.ServeMux`), retry-go, go-isatty, go-selinux, container-device-interface, gotest.tools and go-cmp (replaced by testify). The binary shrinks by 5%. Behaviour differences: 1. An unreadable file in a build context is skipped with a warning, where go-archive left a truncated tar. 2. Build contexts no longer carry `security.capability` xattrs. 3. The cache and artifact servers answer HEAD on GET routes, and no longer redirect trailing slashes or answer OPTIONS. *Written by Claude.* --------- Co-authored-by: bircni <[email protected]> Reviewed-on: https://gitea.com/gitea/runner/pulls/1319 Reviewed-by: bircni <[email protected]> Reviewed-by: Lunny Xiao <[email protected]> Co-authored-by: silverwind <[email protected]>
This commit is contained in:
1 parent
e356c2ed85
commit
4158e05dae
43 files changed
+1188
-510
No files matched your search
+244
-13
@@ -7,20 +7,33 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/docker/cli/cli/config"
|
||||
"github.com/moby/moby/api/types/registry"
|
||||
)
|
||||
|
||||
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
||||
logger := common.Logger(ctx)
|
||||
// config.LoadDefaultConfigFile panics on nil io.Writer when the config
|
||||
// file is malformed; use config.Load to route errors through the logger.
|
||||
cfg, err := config.Load(config.Dir())
|
||||
cfg, err := loadDockerConfig(ctx)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not load docker config: %v", err)
|
||||
return registry.AuthConfig{}, err
|
||||
@@ -34,32 +47,27 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi
|
||||
}
|
||||
}
|
||||
|
||||
authConfig, err := cfg.GetAuthConfig(registryKey)
|
||||
authConfig, err := cfg.authConfig(ctx, registryKey)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not get auth config from docker config: %v", err)
|
||||
return registry.AuthConfig{}, err
|
||||
}
|
||||
|
||||
return registry.AuthConfig(authConfig), nil
|
||||
return authConfig, nil
|
||||
}
|
||||
|
||||
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||
logger := common.Logger(ctx)
|
||||
cfg, err := config.Load(config.Dir())
|
||||
cfg, err := loadDockerConfig(ctx)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not load docker config: %v", err)
|
||||
return nil
|
||||
}
|
||||
creds, err := cfg.GetAllCredentials()
|
||||
authConfigs, err := cfg.allAuthConfigs(ctx)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not get docker auth configs: %v", err)
|
||||
return nil
|
||||
}
|
||||
authConfigs := make(map[string]registry.AuthConfig, len(creds))
|
||||
for k, v := range creds {
|
||||
authConfigs[k] = registry.AuthConfig(v)
|
||||
}
|
||||
|
||||
return authConfigs
|
||||
}
|
||||
|
||||
@@ -69,3 +77,226 @@ func registryAuthConfigKey(domainName string) string {
|
||||
}
|
||||
return domainName
|
||||
}
|
||||
|
||||
type dockerConfig struct {
|
||||
Auths map[string]registry.AuthConfig `json:"auths"`
|
||||
CredsStore string `json:"credsStore"`
|
||||
CredHelpers map[string]string `json:"credHelpers"`
|
||||
env map[string]registry.AuthConfig // from DOCKER_AUTH_CONFIG, nil when unset or unusable
|
||||
}
|
||||
|
||||
func loadDockerConfig(ctx context.Context) (*dockerConfig, error) {
|
||||
dir := os.Getenv("DOCKER_CONFIG")
|
||||
if dir == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" && runtime.GOOS != "windows" {
|
||||
if current, err := user.Current(); err == nil {
|
||||
home = current.HomeDir
|
||||
}
|
||||
}
|
||||
dir = filepath.Join(home, ".docker")
|
||||
}
|
||||
cfg := &dockerConfig{}
|
||||
filename := filepath.Join(dir, "config.json")
|
||||
file, err := os.Open(filename)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, fmt.Errorf("loading config file: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
if err := json.NewDecoder(file).Decode(cfg); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, fmt.Errorf("parsing config file (%s): %w", filename, err)
|
||||
}
|
||||
}
|
||||
for address, auth := range cfg.Auths {
|
||||
if auth.Auth != "" {
|
||||
if auth.Username, auth.Password, err = decodeAuth(auth.Auth); err != nil {
|
||||
return nil, fmt.Errorf("parsing config file (%s): %w", filename, err)
|
||||
}
|
||||
}
|
||||
auth.Auth, auth.ServerAddress = "", address
|
||||
cfg.Auths[address] = auth
|
||||
}
|
||||
if value := os.Getenv("DOCKER_AUTH_CONFIG"); value != "" {
|
||||
if cfg.env, err = parseDockerAuthConfigEnv(value); err != nil {
|
||||
common.Logger(ctx).Warnf("Failed to create credential store from DOCKER_AUTH_CONFIG: %v", err)
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func decodeAuth(auth string) (string, string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(auth)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
username, password, ok := strings.Cut(string(decoded), ":")
|
||||
if !ok || username == "" {
|
||||
return "", "", errors.New("invalid auth configuration file")
|
||||
}
|
||||
return username, strings.Trim(password, "\x00"), nil
|
||||
}
|
||||
|
||||
func parseDockerAuthConfigEnv(value string) (map[string]registry.AuthConfig, error) {
|
||||
var env struct {
|
||||
Auths map[string]struct {
|
||||
Auth string `json:"auth"`
|
||||
} `json:"auths"`
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(value))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&env); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
if decoder.More() {
|
||||
return nil, errors.New("DOCKER_AUTH_CONFIG does not support more than one JSON object")
|
||||
}
|
||||
auths := make(map[string]registry.AuthConfig, len(env.Auths))
|
||||
for address, entry := range env.Auths {
|
||||
if entry.Auth == "" {
|
||||
return nil, fmt.Errorf("DOCKER_AUTH_CONFIG environment variable is missing key `auth` for %s", address)
|
||||
}
|
||||
username, password, err := decodeAuth(entry.Auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auths[address] = registry.AuthConfig{Username: username, Password: password, ServerAddress: address}
|
||||
}
|
||||
return auths, nil
|
||||
}
|
||||
|
||||
func (c *dockerConfig) authConfig(ctx context.Context, key string) (registry.AuthConfig, error) {
|
||||
if auth, ok := c.env[key]; ok {
|
||||
return auth, nil
|
||||
}
|
||||
helper := c.CredsStore
|
||||
if named, ok := c.CredHelpers[key]; ok {
|
||||
helper = named
|
||||
}
|
||||
if helper == "" {
|
||||
return c.fileAuth(key), nil
|
||||
}
|
||||
auth := c.fileAuth(key)
|
||||
stored, err := helperCredentials(ctx, helper, key)
|
||||
if err != nil {
|
||||
return auth, err
|
||||
}
|
||||
auth.Username, auth.Password, auth.IdentityToken, auth.ServerAddress = stored.Username, stored.Password, stored.IdentityToken, stored.ServerAddress
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func (c *dockerConfig) fileAuth(key string) registry.AuthConfig {
|
||||
if auth, ok := c.Auths[key]; ok {
|
||||
return auth
|
||||
}
|
||||
for address, auth := range c.Auths {
|
||||
if registryHostname(address) == key {
|
||||
return auth
|
||||
}
|
||||
}
|
||||
return registry.AuthConfig{}
|
||||
}
|
||||
|
||||
func (c *dockerConfig) allAuthConfigs(ctx context.Context) (map[string]registry.AuthConfig, error) {
|
||||
var auths map[string]registry.AuthConfig
|
||||
if c.CredsStore == "" {
|
||||
auths = maps.Clone(c.Auths)
|
||||
} else {
|
||||
var err error
|
||||
if auths, err = c.helperAuthConfigs(ctx); err != nil {
|
||||
if c.env == nil {
|
||||
return nil, err
|
||||
}
|
||||
common.Logger(ctx).Warnf("Could not list the credentials of docker-credential-%s: %v", c.CredsStore, err)
|
||||
}
|
||||
}
|
||||
if auths == nil {
|
||||
auths = map[string]registry.AuthConfig{}
|
||||
}
|
||||
maps.Copy(auths, c.env)
|
||||
if c.CredsStore == "" {
|
||||
c.Auths = maps.Clone(auths) // docker's file store holds the DOCKER_AUTH_CONFIG entries after listing, and credHelpers set to "" read it
|
||||
}
|
||||
for address := range c.CredHelpers {
|
||||
auth, err := c.authConfig(ctx, registryAuthConfigKey(address))
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("Failed to get credentials for registry: %s: %v", address, err)
|
||||
continue
|
||||
}
|
||||
auths[address] = auth
|
||||
}
|
||||
return auths, nil
|
||||
}
|
||||
|
||||
func (c *dockerConfig) helperAuthConfigs(ctx context.Context) (map[string]registry.AuthConfig, error) {
|
||||
out, err := runCredentialHelper(ctx, c.CredsStore, "list", "unused")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error listing credentials - err: %w, out: `%s`", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
var addresses map[string]string
|
||||
if err := json.NewDecoder(bytes.NewReader(out)).Decode(&addresses); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auths := make(map[string]registry.AuthConfig, len(addresses))
|
||||
for address := range addresses {
|
||||
stored, err := helperCredentials(ctx, c.CredsStore, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auth := c.Auths[address]
|
||||
auth.Username, auth.Password, auth.IdentityToken = stored.Username, stored.Password, stored.IdentityToken
|
||||
if auth.ServerAddress == "" {
|
||||
auth.ServerAddress = stored.ServerAddress
|
||||
}
|
||||
auths[address] = auth
|
||||
}
|
||||
return auths, nil
|
||||
}
|
||||
|
||||
func helperCredentials(ctx context.Context, helper, address string) (registry.AuthConfig, error) {
|
||||
out, err := runCredentialHelper(ctx, helper, "get", address)
|
||||
if err != nil {
|
||||
if strings.TrimSpace(string(out)) == "credentials not found in native keychain" {
|
||||
return registry.AuthConfig{}, nil
|
||||
}
|
||||
return registry.AuthConfig{}, fmt.Errorf("error getting credentials - err: %w, out: `%s`", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
var stored struct{ Username, Secret string }
|
||||
if err := json.NewDecoder(bytes.NewReader(out)).Decode(&stored); err != nil {
|
||||
return registry.AuthConfig{}, err
|
||||
}
|
||||
if stored.Username == "<token>" {
|
||||
return registry.AuthConfig{IdentityToken: stored.Secret, ServerAddress: address}, nil
|
||||
}
|
||||
return registry.AuthConfig{Username: stored.Username, Password: stored.Secret, ServerAddress: address}, nil
|
||||
}
|
||||
|
||||
func runCredentialHelper(ctx context.Context, helper, action, input string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker-credential-"+helper, action)
|
||||
cmd.Stdin = strings.NewReader(input)
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
func registryHostname(address string) string {
|
||||
if _, rest, ok := strings.Cut(address, "://"); ok {
|
||||
if u, err := url.Parse(address); err == nil && u.Hostname() != "" {
|
||||
if u.Port() == "" {
|
||||
return u.Hostname()
|
||||
}
|
||||
return net.JoinHostPort(u.Hostname(), u.Port())
|
||||
}
|
||||
if host, _, _ := strings.Cut(rest, "/"); host != "" {
|
||||
if colon := strings.LastIndex(host, ":"); strings.Count(host, ":") > 1 && !strings.HasPrefix(host, "[") && isDigits(host[colon+1:]) {
|
||||
return net.JoinHostPort(host[:colon], host[colon+1:])
|
||||
}
|
||||
return host
|
||||
}
|
||||
}
|
||||
host, _, _ := strings.Cut(address, "/")
|
||||
return host
|
||||
}
|
||||
|
||||
func isDigits(s string) bool {
|
||||
return s != "" && strings.Trim(s, "0123456789") == ""
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows && !(WITHOUT_DOCKER || !(linux || darwin || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/moby/moby/api/types/registry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadDockerAuthConfigFromFileHelpersAndEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "docker-credential-fake"), []byte("#!/bin/sh\nread -r server\n[ \"$1\" = get ] && [ \"$server\" = helper.example.com ] && exec echo '{\"Username\":\"helped\",\"Secret\":\"pw\"}'\necho 'credentials not found in native keychain'\nexit 1\n"), 0o755))
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
auth := func(credentials string) string { return base64.StdEncoding.EncodeToString([]byte(credentials)) }
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"auths": {"https://legacy.example.com/v1/": {"auth": "`+auth("legacy:pw")+`"}}, "credHelpers": {"helper.example.com": "fake", "gone.example.com": "fake", "env-legacy.example.com": ""}}`), 0o600))
|
||||
t.Setenv("DOCKER_CONFIG", dir)
|
||||
t.Setenv("DOCKER_AUTH_CONFIG", `{"auths": {"env.example.com": {"auth": "`+auth("env:pw")+`"}, "https://env-legacy.example.com/v1/": {"auth": "`+auth("env:pw")+`"}}}`)
|
||||
|
||||
for image, want := range map[string]registry.AuthConfig{
|
||||
"legacy.example.com/app": {Username: "legacy", Password: "pw", ServerAddress: "https://legacy.example.com/v1/"},
|
||||
"helper.example.com/app": {Username: "helped", Password: "pw", ServerAddress: "helper.example.com"},
|
||||
"env.example.com/app": {Username: "env", Password: "pw", ServerAddress: "env.example.com"},
|
||||
"gone.example.com/app": {},
|
||||
} {
|
||||
got, err := LoadDockerAuthConfig(t.Context(), image)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got, image)
|
||||
}
|
||||
assert.Equal(t, map[string]registry.AuthConfig{
|
||||
"https://legacy.example.com/v1/": {Username: "legacy", Password: "pw", ServerAddress: "https://legacy.example.com/v1/"},
|
||||
"helper.example.com": {Username: "helped", Password: "pw", ServerAddress: "helper.example.com"},
|
||||
"gone.example.com": {},
|
||||
"env.example.com": {Username: "env", Password: "pw", ServerAddress: "env.example.com"},
|
||||
"https://env-legacy.example.com/v1/": {Username: "env", Password: "pw", ServerAddress: "https://env-legacy.example.com/v1/"},
|
||||
"env-legacy.example.com": {Username: "env", Password: "pw", ServerAddress: "https://env-legacy.example.com/v1/"},
|
||||
}, LoadDockerAuthConfigs(t.Context()))
|
||||
}
|
||||
@@ -7,15 +7,18 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/moby/go-archive"
|
||||
"github.com/moby/go-archive/compression"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/moby/patternmatcher"
|
||||
"github.com/moby/patternmatcher/ignorefile"
|
||||
@@ -115,14 +118,140 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
||||
includes = append(includes, ".dockerignore", relDockerfile)
|
||||
}
|
||||
|
||||
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
||||
Compression: compression.None,
|
||||
ExcludePatterns: excludes,
|
||||
IncludeFiles: includes,
|
||||
})
|
||||
pm, err := patternmatcher.New(excludes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildCtx, nil
|
||||
reader, writer := io.Pipe()
|
||||
go func() { writer.CloseWithError(writeBuildContext(ctx, writer, contextDir, includes, pm)) }()
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
func writeBuildContext(ctx context.Context, w io.Writer, contextDir string, includes []string, pm *patternmatcher.PatternMatcher) error {
|
||||
tw := tar.NewWriter(w)
|
||||
seen := map[string]bool{}
|
||||
hardlinks := map[uint64]string{}
|
||||
buffer := make([]byte, 32*1024)
|
||||
for _, include := range includes {
|
||||
include = filepath.FromSlash(include)
|
||||
var parents []parentMatch
|
||||
root := strings.TrimSuffix(contextDir, string(filepath.Separator)) + string(filepath.Separator) + include // unlike filepath.Join, follows a symlinked context dir as docker build does
|
||||
err := filepath.WalkDir(root, func(file string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return leaveOut(ctx, file, err)
|
||||
}
|
||||
rel, err := filepath.Rel(contextDir, file)
|
||||
if err != nil || rel == "." {
|
||||
return nil
|
||||
}
|
||||
if rel != include {
|
||||
for len(parents) > 0 && !strings.HasPrefix(rel, parents[len(parents)-1].dir+string(filepath.Separator)) {
|
||||
parents = parents[:len(parents)-1]
|
||||
}
|
||||
var parentInfo patternmatcher.MatchInfo
|
||||
if len(parents) > 0 {
|
||||
parentInfo = parents[len(parents)-1].info
|
||||
}
|
||||
skip, info, err := pm.MatchesUsingParentResults(rel, parentInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
parents = append(parents, parentMatch{rel, info})
|
||||
}
|
||||
if skip {
|
||||
if entry.IsDir() && !hasExceptionWithin(pm, rel) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if seen[rel] {
|
||||
return nil
|
||||
}
|
||||
seen[rel] = true
|
||||
return addBuildContextEntry(ctx, tw, file, rel, entry, hardlinks, buffer)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Close()
|
||||
}
|
||||
|
||||
type parentMatch struct {
|
||||
dir string
|
||||
info patternmatcher.MatchInfo
|
||||
}
|
||||
|
||||
func hasExceptionWithin(pm *patternmatcher.PatternMatcher, dir string) bool {
|
||||
dirPrefix := dir + string(filepath.Separator)
|
||||
for _, pattern := range pm.Patterns() {
|
||||
if pattern.Exclusion() && strings.HasPrefix(pattern.String()+string(filepath.Separator), dirPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func leaveOut(ctx context.Context, file string, err error) error {
|
||||
common.Logger(ctx).Warnf("Leaving %s out of the build context: %v", file, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
func addBuildContextEntry(ctx context.Context, tw *tar.Writer, file, rel string, entry fs.DirEntry, hardlinks map[uint64]string, buffer []byte) error {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return leaveOut(ctx, file, err)
|
||||
}
|
||||
if info.Mode()&fs.ModeSocket != 0 {
|
||||
return nil // tar has no socket type
|
||||
}
|
||||
var link string
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
if link, err = os.Readlink(file); err != nil {
|
||||
return leaveOut(ctx, file, err)
|
||||
}
|
||||
}
|
||||
header, err := tar.FileInfoHeader(noNameLookups{info}, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.ToSlash(rel)
|
||||
inode, linked := hardlinkInode(info)
|
||||
if target, ok := hardlinks[inode]; linked && ok {
|
||||
header.Typeflag, header.Linkname, header.Size = tar.TypeLink, target, 0
|
||||
}
|
||||
var content *os.File
|
||||
if header.Typeflag == tar.TypeReg && header.Size > 0 {
|
||||
if content, err = os.Open(file); err != nil {
|
||||
return leaveOut(ctx, file, err)
|
||||
}
|
||||
defer content.Close()
|
||||
}
|
||||
if linked && header.Typeflag != tar.TypeLink {
|
||||
hardlinks[inode] = header.Name
|
||||
}
|
||||
if info.IsDir() {
|
||||
header.Name += "/"
|
||||
}
|
||||
header.Format = tar.FormatPAX
|
||||
header.ModTime = header.ModTime.Truncate(time.Second)
|
||||
header.AccessTime, header.ChangeTime = time.Time{}, time.Time{}
|
||||
if runtime.GOOS == "windows" {
|
||||
header.Mode = header.Mode&0o755 | 0o111 // Windows has no exec bit, so mark everything executable like docker build
|
||||
}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if content != nil {
|
||||
_, err = io.CopyBuffer(tw, struct{ io.Reader }{content}, buffer) // hides os.File's WriteTo, which would allocate a buffer per file
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type noNameLookups struct{ fs.FileInfo } // leaves Uname and Gname empty like docker build, so packing never waits on NSS
|
||||
|
||||
func (noNameLookups) Uname() (string, error) { return "", nil }
|
||||
|
||||
func (noNameLookups) Gname() (string, error) { return "", nil }
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, content := range map[string]string{
|
||||
".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a",
|
||||
"a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep",
|
||||
} {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(name)), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644))
|
||||
}
|
||||
require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755))
|
||||
|
||||
contexts := []string{dir}
|
||||
if link := filepath.Join(t.TempDir(), "link"); os.Symlink(dir, link) == nil {
|
||||
contexts = append(contexts, link+string(filepath.Separator))
|
||||
}
|
||||
|
||||
for _, contextDir := range contexts {
|
||||
buildContext, err := createBuildContext(t.Context(), contextDir, "Dockerfile")
|
||||
require.NoError(t, err)
|
||||
var names []string
|
||||
for reader := tar.NewReader(buildContext); ; {
|
||||
header, err := reader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
names = append(names, header.Name)
|
||||
}
|
||||
require.NoError(t, buildContext.Close())
|
||||
require.Equal(t, []string{".dockerignore", "a.txt", "build/keep.txt", "empty/", "Dockerfile"}, names, contextDir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func hardlinkInode(info fs.FileInfo) (uint64, bool) {
|
||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok && !info.IsDir() && stat.Nlink > 1 {
|
||||
return stat.Ino, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd)
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateBuildContextKeepsHardlinkedSpecialFilesLinked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "first"), 0o644))
|
||||
require.NoError(t, os.Link(filepath.Join(dir, "first"), filepath.Join(dir, "second")))
|
||||
|
||||
buildContext, err := createBuildContext(t.Context(), dir, "Dockerfile")
|
||||
require.NoError(t, err)
|
||||
defer buildContext.Close()
|
||||
reader := tar.NewReader(buildContext)
|
||||
first, err := reader.Next()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(tar.TypeFifo), first.Typeflag)
|
||||
second, err := reader.Next()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(tar.TypeLink), second.Typeflag)
|
||||
assert.Equal(t, first.Name, second.Linkname)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !WITHOUT_DOCKER
|
||||
|
||||
package container
|
||||
|
||||
import "io/fs"
|
||||
|
||||
func hardlinkInode(_info fs.FileInfo) (uint64, bool) { return 0, false }
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with:
|
||||
// * appended with license information
|
||||
// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
||||
// * regexp and ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec
|
||||
// * cdiDeviceName in place of the container-device-interface parser's IsQualifiedName
|
||||
// * jsontext in place of json.Compact for the seccomp profile
|
||||
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
||||
//
|
||||
@@ -33,14 +34,12 @@ import (
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/docker/cli/opts"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
"github.com/spf13/pflag"
|
||||
cdi "tags.cncf.io/container-device-interface/pkg/parser"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -388,7 +387,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
||||
volumes := copts.volumes.GetMap()
|
||||
// add any bind targets to the list of container volumes
|
||||
for bind := range volumes {
|
||||
parsed, err := loader.ParseVolume(bind)
|
||||
parsed, err := ParseVolume(bind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -396,7 +395,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
||||
if parsed.Source != "" {
|
||||
toBind := bind
|
||||
|
||||
if parsed.Type == string(mount.TypeBind) {
|
||||
if parsed.Type == mount.TypeBind {
|
||||
if hostPart, targetPath, ok := strings.Cut(bind, ":"); ok {
|
||||
if !filepath.IsAbs(hostPart) && strings.HasPrefix(hostPart, ".") {
|
||||
if absHostPart, err := filepath.Abs(hostPart); err == nil {
|
||||
@@ -504,7 +503,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
||||
deviceMappings := make([]container.DeviceMapping, 0, len(devices))
|
||||
cdiDeviceNames := make([]string, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if cdi.IsQualifiedName(device) {
|
||||
if cdiDeviceName.MatchString(device) {
|
||||
cdiDeviceNames = append(cdiDeviceNames, device)
|
||||
continue
|
||||
}
|
||||
@@ -1197,6 +1196,8 @@ func convertPortSet(ports nat.PortSet) (network.PortSet, error) {
|
||||
return converted, nil
|
||||
}
|
||||
|
||||
var cdiDeviceName = regexp.MustCompile(`^[a-zA-Z]([a-zA-Z0-9_.-]*[a-zA-Z0-9])?/[a-zA-Z]([a-zA-Z0-9_.-]*[a-zA-Z0-9])?=[a-zA-Z0-9]([a-zA-Z0-9_.:-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
func convertPortMap(bindings nat.PortMap) (network.PortMap, error) {
|
||||
converted := make(network.PortMap, len(bindings))
|
||||
for port, portBindings := range bindings {
|
||||
|
||||
@@ -25,13 +25,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
networktypes "github.com/moby/moby/api/types/network"
|
||||
"github.com/spf13/pflag"
|
||||
"gotest.tools/v3/assert"
|
||||
is "gotest.tools/v3/assert/cmp"
|
||||
"gotest.tools/v3/skip"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mustParseMAC(s string) networktypes.HardwareAddr {
|
||||
@@ -90,7 +88,7 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) {
|
||||
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
||||
t.Helper()
|
||||
config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||
assert.NilError(t, err)
|
||||
require.NoError(t, err)
|
||||
return config, hostConfig, nwConfig
|
||||
}
|
||||
|
||||
@@ -156,12 +154,12 @@ func TestParseRunLinks(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, hostConfig, netConfig := mustParse(t, tc.input)
|
||||
assert.Check(t, is.DeepEqual(hostConfig.Links, tc.expHostConfigLinks))
|
||||
assert.Check(t, is.Len(netConfig.EndpointsConfig, len(tc.expNetConfigLinks)))
|
||||
assert.Equal(t, tc.expHostConfigLinks, hostConfig.Links)
|
||||
assert.Len(t, netConfig.EndpointsConfig, len(tc.expNetConfigLinks))
|
||||
for netName, expLinks := range tc.expNetConfigLinks {
|
||||
nc, ok := netConfig.EndpointsConfig[netName]
|
||||
assert.Assert(t, ok)
|
||||
assert.Check(t, is.DeepEqual(nc.Links, expLinks))
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, expLinks, nc.Links)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -169,15 +167,15 @@ func TestParseRunLinks(t *testing.T) {
|
||||
|
||||
func TestParseRunWithoutUmask(t *testing.T) {
|
||||
_, hostConfig, _, err := parseRun([]string{"ubuntu", "bash"})
|
||||
assert.NilError(t, err)
|
||||
assert.Assert(t, hostConfig.Umask == nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, hostConfig.Umask)
|
||||
}
|
||||
|
||||
func TestParseRunUmask(t *testing.T) {
|
||||
_, hostConfig, _, err := parseRun([]string{"--umask", "0022", "ubuntu", "bash"})
|
||||
assert.NilError(t, err)
|
||||
assert.Assert(t, hostConfig.Umask != nil)
|
||||
assert.Equal(t, uint32(0o22), *hostConfig.Umask)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, hostConfig.Umask)
|
||||
require.Equal(t, uint32(0o22), *hostConfig.Umask)
|
||||
}
|
||||
|
||||
func TestParseRunAttach(t *testing.T) {
|
||||
@@ -225,9 +223,9 @@ func TestParseRunAttach(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
config, _, _ := mustParse(t, tc.input)
|
||||
assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin)
|
||||
assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout)
|
||||
assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr)
|
||||
require.Equal(t, tc.expected.AttachStdin, config.AttachStdin)
|
||||
require.Equal(t, tc.expected.AttachStdout, config.AttachStdout)
|
||||
require.Equal(t, tc.expected.AttachStderr, config.AttachStderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -273,7 +271,7 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
|
||||
flags, _ := setupRunFlags()
|
||||
for _, tc := range tests {
|
||||
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
||||
assert.Error(t, flags.Parse(tc.args), tc.error)
|
||||
require.EqualError(t, flags.Parse(tc.args), tc.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -396,23 +394,23 @@ func TestRunFlagsParseWithMemory(t *testing.T) {
|
||||
flags, _ := setupRunFlags()
|
||||
args := []string{"--memory=invalid", "img", "cmd"}
|
||||
err := flags.Parse(args)
|
||||
assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`)
|
||||
require.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`)
|
||||
|
||||
_, hostconfig, _ := mustParse(t, "--memory=1G")
|
||||
assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory))
|
||||
assert.Equal(t, int64(1073741824), hostconfig.Memory)
|
||||
}
|
||||
|
||||
func TestParseWithMemorySwap(t *testing.T) {
|
||||
flags, _ := setupRunFlags()
|
||||
args := []string{"--memory-swap=invalid", "img", "cmd"}
|
||||
err := flags.Parse(args)
|
||||
assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`)
|
||||
require.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`)
|
||||
|
||||
_, hostconfig, _ := mustParse(t, "--memory-swap=1G")
|
||||
assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap))
|
||||
assert.Equal(t, int64(1073741824), hostconfig.MemorySwap)
|
||||
|
||||
_, hostconfig, _ = mustParse(t, "--memory-swap=-1")
|
||||
assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap))
|
||||
assert.Equal(t, int64(-1), hostconfig.MemorySwap)
|
||||
}
|
||||
|
||||
func TestParseHostname(t *testing.T) {
|
||||
@@ -474,7 +472,7 @@ func TestParseWithExpose(t *testing.T) {
|
||||
for expose, expectedError := range tests {
|
||||
t.Run(expose, func(t *testing.T) {
|
||||
_, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||
assert.Error(t, err, expectedError)
|
||||
require.EqualError(t, err, expectedError)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -489,10 +487,10 @@ func TestParseWithExpose(t *testing.T) {
|
||||
for expose, exposedPorts := range tests {
|
||||
t.Run(expose, func(t *testing.T) {
|
||||
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||
assert.NilError(t, err)
|
||||
require.NoError(t, err)
|
||||
for _, port := range exposedPorts {
|
||||
_, ok := config.ExposedPorts[port]
|
||||
assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||
assert.True(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -501,18 +499,20 @@ func TestParseWithExpose(t *testing.T) {
|
||||
t.Run("merge with published", func(t *testing.T) {
|
||||
// Merge with actual published port
|
||||
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
||||
assert.NilError(t, err)
|
||||
assert.Check(t, is.Len(config.ExposedPorts, 2))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, config.ExposedPorts, 2)
|
||||
ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")}
|
||||
for _, port := range ports {
|
||||
_, ok := config.ExposedPorts[port]
|
||||
assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||
assert.True(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseDevice(t *testing.T) {
|
||||
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("Windows and macOS validate server-side")
|
||||
}
|
||||
testCases := []struct {
|
||||
devices []string
|
||||
deviceMapping *container.DeviceMapping
|
||||
@@ -586,17 +586,17 @@ func TestParseDevice(t *testing.T) {
|
||||
|
||||
_, hostconfig, _, err := parseRun(args)
|
||||
|
||||
assert.NilError(t, err)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tc.deviceMapping != nil {
|
||||
if assert.Check(t, is.Len(hostconfig.Devices, 1)) {
|
||||
assert.Check(t, is.DeepEqual(*tc.deviceMapping, hostconfig.Devices[0]))
|
||||
if assert.Len(t, hostconfig.Devices, 1) {
|
||||
assert.Equal(t, *tc.deviceMapping, hostconfig.Devices[0])
|
||||
}
|
||||
} else {
|
||||
assert.Check(t, is.Len(hostconfig.Devices, 0))
|
||||
assert.Empty(t, hostconfig.Devices)
|
||||
}
|
||||
|
||||
assert.Check(t, is.DeepEqual(tc.deviceRequests, hostconfig.DeviceRequests))
|
||||
assert.Equal(t, tc.deviceRequests, hostconfig.DeviceRequests)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -665,11 +665,11 @@ func TestParseDeviceByServerOS(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseDevice(tc.device, tc.serverOS)
|
||||
if tc.wantErr != "" {
|
||||
assert.Error(t, err, tc.wantErr)
|
||||
require.EqualError(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got, tc.want)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -848,13 +848,13 @@ func TestParseNetworkConfig(t *testing.T) {
|
||||
_, hConfig, nwConfig, err := parseRun(tc.flags)
|
||||
|
||||
if tc.expectedErr != "" {
|
||||
assert.Error(t, err, tc.expectedErr)
|
||||
require.EqualError(t, err, tc.expectedErr)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NilError(t, err)
|
||||
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedHostCfg.NetworkMode)
|
||||
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected, cmpopts.EquateComparable(netip.Addr{}))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedHostCfg.NetworkMode, hConfig.NetworkMode)
|
||||
require.Equal(t, tc.expected, nwConfig.EndpointsConfig)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -863,24 +863,24 @@ func TestParseModes(t *testing.T) {
|
||||
// pid ko
|
||||
flags, copts := setupRunFlags()
|
||||
args := []string{"--pid=container:", "img", "cmd"}
|
||||
assert.NilError(t, flags.Parse(args))
|
||||
require.NoError(t, flags.Parse(args))
|
||||
_, err := parse(flags, copts, runtime.GOOS)
|
||||
assert.ErrorContains(t, err, "--pid: invalid PID mode")
|
||||
require.ErrorContains(t, err, "--pid: invalid PID mode")
|
||||
|
||||
// pid ok
|
||||
_, hostconfig, _, err := parseRun([]string{"--pid=host", "img", "cmd"})
|
||||
assert.NilError(t, err)
|
||||
require.NoError(t, err)
|
||||
if !hostconfig.PidMode.Valid() {
|
||||
t.Fatalf("Expected a valid PidMode, got %v", hostconfig.PidMode)
|
||||
}
|
||||
|
||||
// uts ko
|
||||
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||
assert.ErrorContains(t, err, "--uts: invalid UTS mode")
|
||||
require.ErrorContains(t, err, "--uts: invalid UTS mode")
|
||||
|
||||
// uts ok
|
||||
_, hostconfig, _, err = parseRun([]string{"--uts=host", "img", "cmd"})
|
||||
assert.NilError(t, err)
|
||||
require.NoError(t, err)
|
||||
if !hostconfig.UTSMode.Valid() {
|
||||
t.Fatalf("Expected a valid UTSMode, got %v", hostconfig.UTSMode)
|
||||
}
|
||||
@@ -892,11 +892,11 @@ func TestRunFlagsParseShmSize(t *testing.T) {
|
||||
args := []string{"--shm-size=a128m", "img", "cmd"}
|
||||
expectedErr := `invalid argument "a128m" for "--shm-size" flag:`
|
||||
err := flags.Parse(args)
|
||||
assert.ErrorContains(t, err, expectedErr)
|
||||
require.ErrorContains(t, err, expectedErr)
|
||||
|
||||
// shm-size ok
|
||||
_, hostconfig, _, err := parseRun([]string{"--shm-size=128m", "img", "cmd"})
|
||||
assert.NilError(t, err)
|
||||
require.NoError(t, err)
|
||||
if hostconfig.ShmSize != 134217728 {
|
||||
t.Fatalf("Expected a valid ShmSize, got %d", hostconfig.ShmSize)
|
||||
}
|
||||
@@ -967,11 +967,11 @@ func TestParseRestartPolicy(t *testing.T) {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
_, hostConfig, _, err := parseRun([]string{"--restart=" + tc.input, "img", "cmd"})
|
||||
if tc.expectedErr != "" {
|
||||
assert.Check(t, is.Error(err, tc.expectedErr))
|
||||
assert.Check(t, is.Nil(hostConfig))
|
||||
assert.Nil(t, hostConfig)
|
||||
assert.EqualError(t, err, tc.expectedErr)
|
||||
} else {
|
||||
assert.NilError(t, err)
|
||||
assert.Check(t, is.DeepEqual(hostConfig.RestartPolicy, tc.expected))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, hostConfig.RestartPolicy)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -980,7 +980,7 @@ func TestParseRestartPolicy(t *testing.T) {
|
||||
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
||||
const expected = "conflicting options: cannot specify both --restart and --rm"
|
||||
assert.Check(t, is.Error(err, expected))
|
||||
assert.EqualError(t, err, expected)
|
||||
}
|
||||
|
||||
func TestParseHealth(t *testing.T) {
|
||||
@@ -1118,12 +1118,14 @@ func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy
|
||||
|
||||
func TestParseEntryPoint(t *testing.T) {
|
||||
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
||||
assert.NilError(t, err)
|
||||
assert.Check(t, is.DeepEqual(config.Entrypoint, []string{"anything"}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"anything"}, config.Entrypoint)
|
||||
}
|
||||
|
||||
func TestValidateDevice(t *testing.T) {
|
||||
skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("Windows and macOS validate server-side")
|
||||
}
|
||||
valid := []string{
|
||||
"/home",
|
||||
"/home:/home",
|
||||
@@ -1222,22 +1224,22 @@ func TestValidateDeviceByServerOS(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := validateDevice(tc.value, tc.serverOS)
|
||||
if tc.wantError != "" {
|
||||
assert.Error(t, err, tc.wantError)
|
||||
require.EqualError(t, err, tc.wantError)
|
||||
return
|
||||
}
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got, tc.want)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
||||
got, err := validateDeviceCgroupRule("c 1:3 rwm")
|
||||
assert.NilError(t, err)
|
||||
assert.Equal(t, got, "c 1:3 rwm")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "c 1:3 rwm", got)
|
||||
|
||||
_, err = validateDeviceCgroupRule("invalid")
|
||||
assert.Error(t, err, "invalid device cgroup format 'invalid'")
|
||||
require.EqualError(t, err, "invalid device cgroup format 'invalid'")
|
||||
|
||||
if invalidParameter(nil) != nil {
|
||||
t.Fatal("invalidParameter(nil) should be nil")
|
||||
@@ -1245,9 +1247,9 @@ func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
||||
cause := errors.New("bad input")
|
||||
err = invalidParameter(cause)
|
||||
var invalid interface{ InvalidParameter() }
|
||||
assert.Assert(t, errors.As(err, &invalid))
|
||||
assert.Assert(t, errors.Is(err, cause))
|
||||
assert.Equal(t, invalidParameter(err), err) // already invalid, so not wrapped twice
|
||||
require.ErrorAs(t, err, &invalid)
|
||||
require.ErrorIs(t, err, cause)
|
||||
require.Condition(t, func() bool { return invalidParameter(err) == err })
|
||||
}
|
||||
|
||||
func TestParseSystemPaths(t *testing.T) {
|
||||
@@ -1298,9 +1300,9 @@ func TestParseSystemPaths(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
securityOpts, maskedPaths, readonlyPaths := parseSystemPaths(tc.in)
|
||||
assert.DeepEqual(t, securityOpts, tc.out)
|
||||
assert.DeepEqual(t, maskedPaths, tc.masked)
|
||||
assert.DeepEqual(t, readonlyPaths, tc.readonly)
|
||||
require.Equal(t, tc.out, securityOpts)
|
||||
require.Equal(t, tc.masked, maskedPaths)
|
||||
require.Equal(t, tc.readonly, readonlyPaths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1322,9 +1324,9 @@ func TestConvertToStandardNotation(t *testing.T) {
|
||||
for key, ports := range valid {
|
||||
convertedPorts, err := convertToStandardNotation(ports)
|
||||
if err != nil {
|
||||
assert.NilError(t, err)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.DeepEqual(t, strings.Split(key, " "), convertedPorts)
|
||||
require.Equal(t, strings.Split(key, " "), convertedPorts)
|
||||
}
|
||||
|
||||
for _, ports := range invalid {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||
|
||||
"github.com/docker/cli/cli/config"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -44,10 +43,7 @@ func TestCleanImage(t *testing.T) {
|
||||
func TestGetImagePullOptions(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
orig := config.Dir()
|
||||
t.Cleanup(func() { config.SetDir(orig) })
|
||||
|
||||
config.SetDir("/non-existent/docker")
|
||||
t.Setenv("DOCKER_CONFIG", "/non-existent/docker")
|
||||
|
||||
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
@@ -61,7 +57,7 @@ func TestGetImagePullOptions(t *testing.T) {
|
||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided")
|
||||
|
||||
config.SetDir("testdata/docker-pull-options")
|
||||
t.Setenv("DOCKER_CONFIG", "testdata/docker-pull-options")
|
||||
|
||||
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||
Image: "nektos/act",
|
||||
|
||||
+58
-21
@@ -13,6 +13,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -27,11 +28,8 @@ import (
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/docker/cli/cli/connhelper"
|
||||
"github.com/docker/cli/opts"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
@@ -372,15 +370,13 @@ func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) {
|
||||
dockerHost := os.Getenv("DOCKER_HOST")
|
||||
|
||||
if strings.HasPrefix(dockerHost, "ssh://") {
|
||||
var helper *connhelper.ConnectionHelper
|
||||
|
||||
helper, err = connhelper.GetConnectionHelper(dockerHost)
|
||||
if err != nil {
|
||||
var args []string
|
||||
if args, err = sshArgs(dockerHost); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cli, err = client.New(
|
||||
client.WithHost(helper.Host),
|
||||
client.WithDialContext(helper.Dialer),
|
||||
client.WithHost("http://docker.example.com"), // the dialer below ignores the address
|
||||
client.WithDialContext(func(context.Context, string, string) (net.Conn, error) { return dialCommand("ssh", args...) }),
|
||||
)
|
||||
} else {
|
||||
cli, err = client.New(client.FromEnv)
|
||||
@@ -622,10 +618,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
|
||||
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
|
||||
|
||||
err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot merge container.Config options: '%s': '%w'", options, err)
|
||||
}
|
||||
overlay(reflect.ValueOf(config).Elem(), reflect.ValueOf(containerConfig.Config).Elem(), true)
|
||||
logger.Debugf("Merged container.Config ==> %+v", config)
|
||||
|
||||
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
||||
@@ -635,10 +628,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
binds := hostConfig.Binds
|
||||
mounts := hostConfig.Mounts
|
||||
networkMode := hostConfig.NetworkMode
|
||||
err = mergo.Merge(hostConfig, containerConfig.HostConfig, mergo.WithOverride)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot merge container.HostConfig options: '%s': '%w'", options, err)
|
||||
}
|
||||
overlay(reflect.ValueOf(hostConfig).Elem(), reflect.ValueOf(containerConfig.HostConfig).Elem(), false)
|
||||
hostConfig.Binds = binds
|
||||
hostConfig.Mounts = mounts
|
||||
if cf.name != "" {
|
||||
@@ -1117,7 +1107,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
||||
// sanitize binds
|
||||
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
|
||||
for _, bind := range hostConfig.Binds {
|
||||
parsed, err := loader.ParseVolume(bind)
|
||||
parsed, err := ParseVolume(bind)
|
||||
if err != nil {
|
||||
logger.Warnf("parse volume [%s] error: %v", bind, err)
|
||||
continue
|
||||
@@ -1127,8 +1117,8 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
||||
sanitizedBinds = append(sanitizedBinds, bind)
|
||||
continue
|
||||
}
|
||||
if matcher.isValid(parsed.Source, mount.Type(parsed.Type), parsed.ReadOnly) {
|
||||
if matcher.needsNoCopy(parsed.Source, mount.Type(parsed.Type)) && (parsed.Volume == nil || !parsed.Volume.NoCopy) {
|
||||
if matcher.isValid(parsed.Source, parsed.Type, parsed.ReadOnly) {
|
||||
if matcher.needsNoCopy(parsed.Source, parsed.Type) && !parsed.NoCopy {
|
||||
bind += ",nocopy"
|
||||
}
|
||||
sanitizedBinds = append(sanitizedBinds, bind)
|
||||
@@ -1168,9 +1158,56 @@ func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string {
|
||||
return hostConfig.Binds
|
||||
}
|
||||
|
||||
func overlay(dst, src reflect.Value, appendSlices bool) {
|
||||
switch dst.Kind() {
|
||||
case reflect.Struct:
|
||||
opaque := true
|
||||
for i := range dst.NumField() {
|
||||
if dst.Type().Field(i).IsExported() {
|
||||
opaque = false
|
||||
overlay(dst.Field(i), src.Field(i), appendSlices)
|
||||
}
|
||||
}
|
||||
if opaque {
|
||||
dst.Set(src)
|
||||
}
|
||||
case reflect.Map:
|
||||
if src.IsNil() {
|
||||
return
|
||||
}
|
||||
if dst.IsNil() {
|
||||
dst.Set(reflect.MakeMap(dst.Type()))
|
||||
}
|
||||
for key, value := range src.Seq2() {
|
||||
dst.SetMapIndex(key, value)
|
||||
}
|
||||
case reflect.Slice:
|
||||
if appendSlices {
|
||||
dst.Set(reflect.AppendSlice(dst, src))
|
||||
} else if src.Len() > 0 {
|
||||
dst.Set(src)
|
||||
}
|
||||
case reflect.Pointer:
|
||||
if src.IsNil() {
|
||||
return
|
||||
}
|
||||
if dst.IsNil() {
|
||||
dst.Set(src)
|
||||
return
|
||||
}
|
||||
overlay(dst.Elem(), src.Elem(), appendSlices)
|
||||
case reflect.Array:
|
||||
dst.Set(src)
|
||||
default:
|
||||
if !src.IsZero() {
|
||||
dst.Set(src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
||||
func bindTarget(bind string) string {
|
||||
parsed, err := loader.ParseVolume(bind)
|
||||
parsed, err := ParseVolume(bind)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/docker/cli/cli/connhelper/ssh"
|
||||
)
|
||||
|
||||
func sshArgs(daemonURL string) ([]string, error) {
|
||||
spec, err := ssh.ParseURL(daemonURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remote := []string{"docker", "system", "dial-stdio"}
|
||||
if strings.Trim(spec.Path, "/") != "" {
|
||||
remote = []string{"docker", "--host=unix://" + spec.Path, "system", "dial-stdio"}
|
||||
}
|
||||
return spec.Command([]string{"-o", "ConnectTimeout=30", "-T"}, remote...)
|
||||
}
|
||||
|
||||
func dialCommand(name string, args ...string) (net.Conn, error) {
|
||||
conn := &commandConn{cmd: exec.Command(name, args...)}
|
||||
conn.cmd.SysProcAttr = sshSysProcAttr() // a session of its own like docker's, so a ProxyCommand never reads the runner's terminal: https://github.com/docker/cli/issues/1707
|
||||
conn.cmd.Stderr = &conn.stderr
|
||||
conn.cmd.WaitDelay = time.Second // a ProxyCommand child keeping stderr open would otherwise block Wait after ssh exits
|
||||
conn.wait = sync.OnceValue(func() error {
|
||||
if err := conn.cmd.Wait(); err != nil && !errors.Is(err, exec.ErrWaitDelay) {
|
||||
return fmt.Errorf("%s: %w: %s", strings.Join(conn.cmd.Args, " "), err, strings.TrimSpace(conn.stderr.String()))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
var err error
|
||||
if conn.stdin, err = conn.cmd.StdinPipe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if conn.stdout, err = conn.cmd.StdoutPipe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn, conn.cmd.Start()
|
||||
}
|
||||
|
||||
type commandConn struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
stderr cappedBuffer
|
||||
wait func() error
|
||||
}
|
||||
|
||||
func (c *commandConn) waitWithin(timeout time.Duration) error {
|
||||
timer := time.AfterFunc(timeout, func() { _ = c.cmd.Process.Kill() })
|
||||
defer timer.Stop()
|
||||
return c.wait()
|
||||
}
|
||||
|
||||
func (c *commandConn) Read(p []byte) (int, error) {
|
||||
n, err := c.stdout.Read(p)
|
||||
if errors.Is(err, io.EOF) {
|
||||
if waitErr := c.waitWithin(10 * time.Second); waitErr != nil {
|
||||
return n, waitErr
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *commandConn) Write(p []byte) (int, error) { return c.stdin.Write(p) }
|
||||
|
||||
// CloseWrite lets the daemon see the end of a hijacked stream, like a TCP half-close.
|
||||
func (c *commandConn) CloseWrite() error { return c.stdin.Close() }
|
||||
|
||||
func (c *commandConn) Close() error {
|
||||
_ = c.stdin.Close()
|
||||
if c.cmd.Process.Signal(syscall.SIGTERM) != nil { // SIGTERM lets ssh take its ProxyCommand down, Windows only kills
|
||||
_ = c.cmd.Process.Kill()
|
||||
}
|
||||
_ = c.waitWithin(3 * time.Second)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commandConn) LocalAddr() net.Addr { return &net.UnixAddr{Name: "stdio", Net: "unix"} }
|
||||
|
||||
func (c *commandConn) RemoteAddr() net.Addr { return &net.UnixAddr{Name: "stdio", Net: "unix"} }
|
||||
|
||||
func (c *commandConn) SetDeadline(_deadline time.Time) error { return nil }
|
||||
|
||||
func (c *commandConn) SetReadDeadline(_deadline time.Time) error { return nil }
|
||||
|
||||
func (c *commandConn) SetWriteDeadline(_deadline time.Time) error { return nil }
|
||||
|
||||
type cappedBuffer struct{ bytes.Buffer }
|
||||
|
||||
func (b *cappedBuffer) Write(p []byte) (int, error) {
|
||||
if b.Len() > 4096 {
|
||||
b.Reset()
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !WITHOUT_DOCKER
|
||||
|
||||
package container
|
||||
|
||||
import "syscall"
|
||||
|
||||
func sshSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{Setsid: true, Pdeathsig: syscall.SIGKILL}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSSHArgs(t *testing.T) {
|
||||
args, err := sshArgs("ssh://me@host:2222/run/user/1000/my docker.sock")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"-l", "me", "-p", "2222", "-o", "ConnectTimeout=30", "-T", "--", "host", "docker '--host=unix:///run/user/1000/my docker.sock' system dial-stdio"}, args)
|
||||
|
||||
args, err = sshArgs("ssh://[::1]")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"-o", "ConnectTimeout=30", "-T", "--", "::1", "docker system dial-stdio"}, args)
|
||||
|
||||
for _, invalid := range []string{"ssh://me:secret@host", "ssh://host?x=1", "ssh://host#x", "ssh://"} {
|
||||
_, err := sshArgs(invalid)
|
||||
assert.Error(t, err, invalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialCommandStreamsStdioAndReportsExit(t *testing.T) {
|
||||
if _, err := exec.LookPath("sh"); err != nil {
|
||||
t.Skip("needs a POSIX shell")
|
||||
}
|
||||
conn, err := dialCommand("sh", "-c", "cat")
|
||||
require.NoError(t, err)
|
||||
_, err = conn.Write([]byte("ping"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, conn.(interface{ CloseWrite() error }).CloseWrite())
|
||||
echoed, err := io.ReadAll(conn)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ping", string(echoed))
|
||||
require.NoError(t, conn.Close())
|
||||
|
||||
conn, err = dialCommand("sh", "-c", "exec 3<&0; cat <&3 >/dev/null & echo 'no route to host' >&2; exit 255")
|
||||
require.NoError(t, err)
|
||||
_, err = io.ReadAll(conn)
|
||||
require.ErrorContains(t, err, "no route to host")
|
||||
require.NoError(t, conn.Close())
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !WITHOUT_DOCKER && (darwin || netbsd)
|
||||
|
||||
package container
|
||||
|
||||
import "syscall"
|
||||
|
||||
func sshSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !WITHOUT_DOCKER
|
||||
|
||||
package container
|
||||
|
||||
import "syscall"
|
||||
|
||||
func sshSysProcAttr() *syscall.SysProcAttr { return nil }
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
)
|
||||
|
||||
type VolumeSpec struct {
|
||||
Type mount.Type
|
||||
Source string
|
||||
Target string
|
||||
ReadOnly bool
|
||||
NoCopy bool
|
||||
}
|
||||
|
||||
// ParseVolume parses spec like `docker run -v` does, without knowing the target platform.
|
||||
func ParseVolume(spec string) (VolumeSpec, error) {
|
||||
switch len(spec) {
|
||||
case 0:
|
||||
return VolumeSpec{}, errors.New("invalid empty volume spec")
|
||||
case 1, 2:
|
||||
return VolumeSpec{Type: mount.TypeVolume, Target: spec}, nil
|
||||
}
|
||||
|
||||
spec = string([]rune(spec)) // each invalid UTF-8 byte becomes U+FFFD, as in the JSON the daemon receives
|
||||
var parts []string
|
||||
for rest := spec; ; {
|
||||
start := 0
|
||||
if hasDriveLetter(rest) {
|
||||
start = strings.IndexByte(rest, ':') + 1
|
||||
}
|
||||
end := strings.IndexByte(rest[start:], ':')
|
||||
if end < 0 {
|
||||
parts = append(parts, rest)
|
||||
break
|
||||
}
|
||||
parts = append(parts, rest[:start+end])
|
||||
rest = rest[start+end+1:]
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
return VolumeSpec{}, fmt.Errorf("invalid spec: %s: too many colons", spec)
|
||||
}
|
||||
if slices.Contains(parts, "") {
|
||||
return VolumeSpec{}, fmt.Errorf("invalid spec: %s: empty section between colons", spec)
|
||||
}
|
||||
|
||||
volume := VolumeSpec{Type: mount.TypeVolume, Target: parts[0]}
|
||||
if len(parts) > 1 {
|
||||
volume.Source, volume.Target = parts[0], parts[1]
|
||||
if isHostPath(volume.Source) {
|
||||
volume.Type = mount.TypeBind
|
||||
}
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
for option := range strings.SplitSeq(parts[2], ",") {
|
||||
switch option {
|
||||
case "ro":
|
||||
volume.ReadOnly = true
|
||||
case "rw":
|
||||
volume.ReadOnly = false
|
||||
case "nocopy":
|
||||
volume.NoCopy = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return volume, nil
|
||||
}
|
||||
|
||||
func hasDriveLetter(s string) bool {
|
||||
letter, size := utf8.DecodeRuneInString(s)
|
||||
return size < len(s) && s[size] == ':' && unicode.IsLetter(letter)
|
||||
}
|
||||
|
||||
func isHostPath(source string) bool {
|
||||
return strings.ContainsAny(source[:1], "./~") || strings.HasPrefix(source, `\\`) || hasDriveLetter(source)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseVolumeReplacesInvalidUTF8LikeTheDaemonSees(t *testing.T) {
|
||||
parsed, err := ParseVolume("/data/\xff\xfe:/mnt:ro")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, VolumeSpec{Type: mount.TypeBind, Source: "/data/��", Target: "/mnt", ReadOnly: true}, parsed)
|
||||
}
|
||||
Reference in new issue
Block a user