Files
runner/act/container/docker_auth.go
T
silverwindandbircni 4158e05dae 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]>
2026-10-18 18:57:08 +00:00

303 lines
9.1 KiB
Go

// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2021 The nektos/act Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
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/moby/moby/api/types/registry"
)
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
logger := common.Logger(ctx)
cfg, err := loadDockerConfig(ctx)
if err != nil {
logger.Warnf("Could not load docker config: %v", err)
return registry.AuthConfig{}, err
}
registryKey := registryAuthConfigKey("docker.io")
if image != "" {
if registryRef, refErr := reference.ParseNormalizedNamed(image); refErr != nil {
logger.Warnf("Could not normalize image reference: %v", refErr)
} else {
registryKey = registryAuthConfigKey(reference.Domain(registryRef))
}
}
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 authConfig, nil
}
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
logger := common.Logger(ctx)
cfg, err := loadDockerConfig(ctx)
if err != nil {
logger.Warnf("Could not load docker config: %v", err)
return nil
}
authConfigs, err := cfg.allAuthConfigs(ctx)
if err != nil {
logger.Warnf("Could not get docker auth configs: %v", err)
return nil
}
return authConfigs
}
func registryAuthConfigKey(domainName string) string {
if domainName == "docker.io" || domainName == "index.docker.io" {
return "https://index.docker.io/v1/"
}
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") == ""
}