executors/docker/docker.go (1,254 lines of code) (raw):
package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"os"
"sort"
"strconv"
"strings"
"sync"
"github.com/bmatcuk/doublestar/v4"
"github.com/docker/cli/opts"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
"github.com/docker/docker/pkg/stdcopy"
"github.com/hashicorp/go-version"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sirupsen/logrus"
"gitlab.com/gitlab-org/gitlab-runner/common"
"gitlab.com/gitlab-org/gitlab-runner/common/buildlogger"
"gitlab.com/gitlab-org/gitlab-runner/executors"
"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/exec"
"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/labels"
"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/networks"
"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/prebuilt"
"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/pull"
"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/volumes"
"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/volumes/parser"
"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/volumes/permission"
"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/wait"
"gitlab.com/gitlab-org/gitlab-runner/helpers"
"gitlab.com/gitlab-org/gitlab-runner/helpers/container/helperimage"
"gitlab.com/gitlab-org/gitlab-runner/helpers/docker"
"gitlab.com/gitlab-org/gitlab-runner/helpers/featureflags"
"gitlab.com/gitlab-org/gitlab-runner/helpers/limitwriter"
"gitlab.com/gitlab-org/gitlab-runner/shells"
"gitlab.com/gitlab-org/gitlab-runner/steps"
)
const (
ExecutorStagePrepare common.ExecutorStage = "docker_prepare"
ExecutorStageRun common.ExecutorStage = "docker_run"
ExecutorStageCleanup common.ExecutorStage = "docker_cleanup"
ExecutorStageCreatingBuildVolumes common.ExecutorStage = "docker_creating_build_volumes"
ExecutorStageCreatingStepRunnerVolume common.ExecutorStage = "docker_creating_step_runner_volume"
ExecutorStageCreatingServices common.ExecutorStage = "docker_creating_services"
ExecutorStageCreatingUserVolumes common.ExecutorStage = "docker_creating_user_volumes"
ExecutorStagePullingImage common.ExecutorStage = "docker_pulling_image"
)
const ServiceLogOutputLimit = 64 * 1024
const (
labelServiceType = "service"
labelWaitType = "wait"
)
// internalFakeTunnelHostname is an internal hostname we provide the Docker client
// when we provide a tunnelled dialer implementation. Because we're overriding
// the dialer, this domain should never be used by the client, but we use the
// reserved TLD ".invalid" for safety.
const internalFakeTunnelHostname = "http://internal.tunnel.invalid"
var neverRestartPolicy = container.RestartPolicy{Name: "no"}
var (
errVolumesManagerUndefined = errors.New("volumesManager is undefined")
errNetworksManagerUndefined = errors.New("networksManager is undefined")
)
type executor struct {
executors.AbstractExecutor
client docker.Client
volumeParser parser.Parser
newVolumePermissionSetter func() (permission.Setter, error)
info system.Info
serverAPIVersion *version.Version
waiter wait.KillWaiter
temporary []string // IDs of containers that should be removed
buildContainerID string
services []*types.Container
links []string
devices []container.DeviceMapping
deviceRequests []container.DeviceRequest
helperImageInfo helperimage.Info
volumesManager volumes.Manager
networksManager networks.Manager
labeler labels.Labeler
pullManager pull.Manager
networkMode container.NetworkMode
projectUniqRandomizedName string
tunnelClient executors.Client
}
var version1_44 = version.Must(version.NewVersion("1.44"))
func (e *executor) getServiceVariables(serviceDefinition common.Image) []string {
variables := e.Build.GetAllVariables().PublicOrInternal()
variables = append(variables, serviceDefinition.Variables...)
return variables.Expand().StringList()
}
func (e *executor) expandAndGetDockerImage(
imageName string,
allowedImages []string,
dockerOptions common.ImageDockerOptions,
imagePullPolicies []common.DockerPullPolicy,
) (*types.ImageInspect, error) {
imageName, err := e.expandImageName(imageName, allowedImages)
if err != nil {
return nil, err
}
dockerOptions.Platform = e.ExpandValue(dockerOptions.Platform)
image, err := e.pullManager.GetDockerImage(imageName, dockerOptions, imagePullPolicies)
if err != nil {
return nil, err
}
return image, nil
}
func (e *executor) getHelperImage() (*types.ImageInspect, error) {
if imageNameFromConfig := e.ExpandValue(e.Config.Docker.HelperImage); imageNameFromConfig != "" {
e.BuildLogger.Debugln(
"Pull configured helper_image for predefined container instead of import bundled image",
imageNameFromConfig,
"...",
)
e.BuildLogger.Println("Using helper image: ", imageNameFromConfig, " (overridden, default would be ", e.helperImageInfo, ")")
return e.pullManager.GetDockerImage(imageNameFromConfig, common.ImageDockerOptions{}, nil)
}
e.BuildLogger.Debugln(fmt.Sprintf("Looking for prebuilt image %s...", e.helperImageInfo))
image, _, err := e.client.ImageInspectWithRaw(e.Context, e.helperImageInfo.String())
if err == nil {
return &image, nil
}
// Try to load prebuilt image from local filesystem
loadedImage := e.getLocalHelperImage()
if loadedImage != nil {
return loadedImage, nil
}
e.BuildLogger.Println("Using helper image: ", e.helperImageInfo.String())
// Fall back to getting image from registry
e.BuildLogger.Debugln(fmt.Sprintf("Loading image form registry: %s", e.helperImageInfo))
return e.pullManager.GetDockerImage(e.helperImageInfo.String(), common.ImageDockerOptions{}, nil)
}
func (e *executor) getLocalHelperImage() *types.ImageInspect {
if e.helperImageInfo.Prebuilt == "" {
return nil
}
image, err := prebuilt.Get(e.Context, e.client, e.helperImageInfo)
if err != nil {
e.BuildLogger.Debugln("Failed to load prebuilt:", err)
}
return image
}
func (e *executor) getBuildImage() (*types.ImageInspect, error) {
imageName, err := e.expandImageName(e.Build.Image.Name, []string{})
if err != nil {
return nil, err
}
imagePullPolicies := e.Build.Image.PullPolicies
// Fetch image
image, err := e.pullManager.GetDockerImage(imageName, e.Build.Image.ExecutorOptions.Docker, imagePullPolicies)
if err != nil {
return nil, err
}
return image, nil
}
func fakeContainer(id string, names ...string) *types.Container {
return &types.Container{ID: id, Names: names}
}
func (e *executor) parseDeviceString(deviceString string) (device container.DeviceMapping, err error) {
// Split the device string PathOnHost[:PathInContainer[:CgroupPermissions]]
parts := strings.Split(deviceString, ":")
if len(parts) > 3 {
err = fmt.Errorf("too many colons")
return
}
device.PathOnHost = parts[0]
// Optional container path
if len(parts) >= 2 {
device.PathInContainer = parts[1]
} else {
// default: device at same path in container
device.PathInContainer = device.PathOnHost
}
// Optional permissions
if len(parts) >= 3 {
device.CgroupPermissions = parts[2]
} else {
// default: rwm, just like 'docker run'
device.CgroupPermissions = "rwm"
}
return
}
func (e *executor) bindDevices() (err error) {
e.devices, err = e.bindContainerDevices(e.Config.Docker.Devices)
return err
}
func (e *executor) bindContainerDevices(devices []string) ([]container.DeviceMapping, error) {
mapping := []container.DeviceMapping{}
for _, deviceString := range devices {
device, err := e.parseDeviceString(deviceString)
if err != nil {
return nil, fmt.Errorf("failed to parse device string %q: %w", deviceString, err)
}
mapping = append(mapping, device)
}
return mapping, nil
}
func (e *executor) bindDeviceRequests() (err error) {
e.deviceRequests, err = e.bindContainerDeviceRequests(e.Config.Docker.Gpus)
return err
}
func (e *executor) bindContainerDeviceRequests(gpus string) ([]container.DeviceRequest, error) {
if strings.TrimSpace(gpus) == "" {
return nil, nil
}
var gpuOpts opts.GpuOpts
err := gpuOpts.Set(gpus)
if err != nil {
return nil, fmt.Errorf("parsing gpus string %q: %w", gpus, err)
}
return gpuOpts.Value(), nil
}
func isInAllowedPrivilegedImages(image string, allowedPrivilegedImages []string) bool {
if len(allowedPrivilegedImages) == 0 {
return true
}
for _, allowedImage := range allowedPrivilegedImages {
ok, _ := doublestar.Match(allowedImage, image)
if ok {
return true
}
}
return false
}
func (e *executor) isInPrivilegedServiceList(serviceDefinition common.Image) bool {
return isInAllowedPrivilegedImages(serviceDefinition.Name, e.Config.Docker.AllowedPrivilegedServices)
}
func (e *executor) createService(
serviceIndex int,
service, version, image string,
definition common.Image,
linkNames []string,
) (*types.Container, error) {
if service == "" {
return nil, common.MakeBuildError("invalid service image name: %s", definition.Name).WithFailureReason(common.ConfigurationError)
}
if e.volumesManager == nil {
return nil, errVolumesManagerUndefined
}
var serviceName string
if strings.HasPrefix(version, "@sha256") {
serviceName = fmt.Sprintf("%s%s...", service, version) // service@digest
} else {
serviceName = fmt.Sprintf("%s:%s...", service, version) // service:version
}
e.BuildLogger.Println("Starting service", serviceName)
serviceImage, err := e.pullManager.GetDockerImage(image, definition.ExecutorOptions.Docker, definition.PullPolicies)
if err != nil {
return nil, err
}
serviceSlug := strings.ReplaceAll(service, "/", "__")
containerName := e.makeContainerName(fmt.Sprintf("%s-%d", serviceSlug, serviceIndex))
// this will fail potentially some builds if there's name collision
_ = e.removeContainer(e.Context, containerName)
config := e.createServiceContainerConfig(service, version, serviceImage.ID, definition)
devices, err := e.getServicesDevices(image)
if err != nil {
return nil, err
}
deviceRequests, err := e.getServicesDeviceRequests()
if err != nil {
return nil, err
}
hostConfig, err := e.createHostConfigForService(e.isInPrivilegedServiceList(definition), devices, deviceRequests)
if err != nil {
return nil, err
}
platform := platformForImage(serviceImage, definition.ExecutorOptions)
networkConfig := e.networkConfig(linkNames)
e.BuildLogger.Debugln("Creating service container", containerName, "...")
resp, err := e.client.ContainerCreate(e.Context, config, hostConfig, networkConfig, platform, containerName)
if err != nil {
return nil, err
}
e.BuildLogger.Debugln(fmt.Sprintf("Starting service container %s (%s)...", containerName, resp.ID))
err = e.client.ContainerStart(e.Context, resp.ID, container.StartOptions{})
if err != nil {
e.temporary = append(e.temporary, resp.ID)
return nil, err
}
return fakeContainer(resp.ID, containerName), nil
}
func platformForImage(image *types.ImageInspect, opts common.ImageExecutorOptions) *v1.Platform {
if image == nil || opts.Docker.Platform == "" {
return nil
}
return &v1.Platform{
Architecture: image.Architecture,
OS: image.Os,
OSVersion: image.OsVersion,
Variant: image.Variant,
}
}
func (e *executor) createHostConfigForService(imageIsPrivileged bool, devices []container.DeviceMapping, deviceRequests []container.DeviceRequest) (*container.HostConfig, error) {
nanoCPUs, err := e.Config.Docker.GetServiceNanoCPUs()
if err != nil {
return nil, fmt.Errorf("service nano cpus: %w", err)
}
privileged := e.Config.Docker.Privileged
if e.Config.Docker.ServicesPrivileged != nil {
privileged = *e.Config.Docker.ServicesPrivileged
}
privileged = privileged && imageIsPrivileged
var useInit *bool
if e.Build.IsFeatureFlagOn(featureflags.UseInitWithDockerExecutor) {
yes := true
useInit = &yes
}
return &container.HostConfig{
Resources: container.Resources{
Memory: e.Config.Docker.GetServiceMemory(),
MemorySwap: e.Config.Docker.GetServiceMemorySwap(),
MemoryReservation: e.Config.Docker.GetServiceMemoryReservation(),
CgroupParent: e.Config.Docker.ServiceCgroupParent,
CpusetCpus: e.Config.Docker.ServiceCPUSetCPUs,
CPUShares: e.Config.Docker.ServiceCPUShares,
NanoCPUs: nanoCPUs,
Devices: devices,
DeviceRequests: deviceRequests,
},
DNS: e.Config.Docker.DNS,
DNSSearch: e.Config.Docker.DNSSearch,
RestartPolicy: neverRestartPolicy,
ExtraHosts: e.Config.Docker.ExtraHosts,
Privileged: privileged,
SecurityOpt: e.Config.Docker.ServicesSecurityOpt,
Runtime: e.Config.Docker.Runtime,
UsernsMode: container.UsernsMode(e.Config.Docker.UsernsMode),
NetworkMode: e.networkMode,
Binds: e.volumesManager.Binds(),
ShmSize: e.Config.Docker.ShmSize,
Tmpfs: e.Config.Docker.ServicesTmpfs,
LogConfig: container.LogConfig{
Type: "json-file",
},
Init: useInit,
}, nil
}
func (e *executor) createServiceContainerConfig(
service, version, serviceImageID string,
definition common.Image,
) *container.Config {
labels := e.prepareContainerLabels(map[string]string{
"type": labelServiceType,
"service": service,
"service.version": version,
})
config := &container.Config{
Image: serviceImageID,
Labels: e.labeler.Labels(labels),
Env: e.getServiceVariables(definition),
}
if len(definition.Command) > 0 {
config.Cmd = definition.Command
}
config.Entrypoint = e.overwriteEntrypoint(&definition)
config.User = definition.ExecutorOptions.Docker.User
return config
}
func (e *executor) getServicesDevices(image string) ([]container.DeviceMapping, error) {
var devices []container.DeviceMapping
for imageGlob, deviceStrings := range e.Config.Docker.ServicesDevices {
ok, err := doublestar.Match(imageGlob, image)
if err != nil {
return nil, fmt.Errorf("invalid service device image pattern: %s: %w", imageGlob, err)
}
if !ok {
continue
}
dvs, err := e.bindContainerDevices(deviceStrings)
if err != nil {
return nil, err
}
devices = append(devices, dvs...)
}
return devices, nil
}
func (e *executor) getServicesDeviceRequests() ([]container.DeviceRequest, error) {
return e.bindContainerDeviceRequests(e.Config.Docker.ServiceGpus)
}
func (e *executor) networkConfig(aliases []string) *network.NetworkingConfig {
// setting a container's mac-address changed in API version 1.44
if e.serverAPIVersion.LessThan(version1_44) {
return e.networkConfigLegacy(aliases)
}
nm := string(e.networkMode)
nc := network.NetworkingConfig{}
if nm == "" {
// docker defaults to using "bridge" network driver if none was specified.
nc.EndpointsConfig = map[string]*network.EndpointSettings{
network.NetworkDefault: {MacAddress: e.Config.Docker.MacAddress},
}
return &nc
}
nc.EndpointsConfig = map[string]*network.EndpointSettings{
nm: {MacAddress: e.Config.Docker.MacAddress},
}
if e.networkMode.IsUserDefined() {
nc.EndpointsConfig[nm].Aliases = aliases
}
return &nc
}
// Setting a container's mac-address changed in API version 1.44. This is the original/legacy/pre-1.44 way to set
// mac-address.
func (e *executor) networkConfigLegacy(aliases []string) *network.NetworkingConfig {
if e.networkMode.UserDefined() == "" {
return &network.NetworkingConfig{}
}
return &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{
e.networkMode.UserDefined(): {Aliases: aliases},
},
}
}
func (e *executor) getProjectUniqRandomizedName() string {
if e.projectUniqRandomizedName == "" {
uuid, _ := helpers.GenerateRandomUUID(8)
e.projectUniqRandomizedName = fmt.Sprintf("%s-%s", e.Build.ProjectUniqueName(), uuid)
}
return e.projectUniqRandomizedName
}
// Build and predefined container names are comprised of:
// - A runner project scoped ID (runner-<description>-project-<project_id>-concurrent-<concurrent>)
// - A unique randomized ID for each execution
// - The container's type (build, predefined, step-runner)
//
// For example: runner-linux-project-123-concurrent-2-0a1b2c3d-predefined
//
// A container of the same type is created _once_ per execution and re-used.
func (e *executor) makeContainerName(suffix string) string {
return e.getProjectUniqRandomizedName() + "-" + suffix
}
func (e *executor) createBuildNetwork() error {
if e.networksManager == nil {
return errNetworksManagerUndefined
}
networkMode, err := e.networksManager.Create(e.Context, e.Config.Docker.NetworkMode, e.Config.Docker.EnableIPv6)
if err != nil {
return err
}
e.networkMode = networkMode
return nil
}
func (e *executor) cleanupNetwork(ctx context.Context) error {
if e.networksManager == nil {
return errNetworksManagerUndefined
}
if e.networkMode.UserDefined() == "" {
return nil
}
inspectResponse, err := e.networksManager.Inspect(ctx)
if err != nil {
e.BuildLogger.Errorln("network inspect returned error ", err)
return nil
}
for id := range inspectResponse.Containers {
e.BuildLogger.Debugln("Removing Container", id, "...")
err = e.removeContainer(ctx, id)
if err != nil {
e.BuildLogger.Errorln("remove container returned error ", err)
}
}
return e.networksManager.Cleanup(ctx)
}
func (e *executor) isInPrivilegedImageList(imageDefinition common.Image) bool {
return isInAllowedPrivilegedImages(imageDefinition.Name, e.Config.Docker.AllowedPrivilegedImages)
}
type containerConfigurator interface {
ContainerConfig(image *types.ImageInspect) (*container.Config, error)
HostConfig() (*container.HostConfig, error)
NetworkConfig(aliases []string) *network.NetworkingConfig
}
type defaultContainerConfigurator struct {
e *executor
containerType string
imageDefinition common.Image
cmd []string
allowedInternalImages []string
}
var _ containerConfigurator = &defaultContainerConfigurator{}
func newDefaultContainerConfigurator(
e *executor,
containerType string,
imageDefinition common.Image,
cmd,
allowedInternalImages []string,
) *defaultContainerConfigurator {
return &defaultContainerConfigurator{
e: e,
containerType: containerType,
imageDefinition: imageDefinition,
cmd: cmd,
allowedInternalImages: allowedInternalImages,
}
}
func (c *defaultContainerConfigurator) ContainerConfig(image *types.ImageInspect) (*container.Config, error) {
hostname := c.e.Config.Docker.Hostname
if hostname == "" {
hostname = c.e.Build.ProjectUniqueName()
}
return c.e.createContainerConfig(
c.containerType,
c.imageDefinition,
image,
hostname,
c.cmd,
)
}
func (c *defaultContainerConfigurator) HostConfig() (*container.HostConfig, error) {
return c.e.createHostConfig(
c.containerType == buildContainerType,
c.e.isInPrivilegedImageList(c.imageDefinition),
)
}
func (c *defaultContainerConfigurator) NetworkConfig(aliases []string) *network.NetworkingConfig {
return c.e.networkConfig(aliases)
}
func (e *executor) createContainer(
containerType string,
imageDefinition common.Image,
allowedInternalImages []string,
cfgTor containerConfigurator,
) (*types.ContainerJSON, error) {
if e.volumesManager == nil {
return nil, errVolumesManagerUndefined
}
image, err := e.expandAndGetDockerImage(
imageDefinition.Name,
allowedInternalImages,
imageDefinition.ExecutorOptions.Docker,
imageDefinition.PullPolicies,
)
if err != nil {
return nil, err
}
containerName := e.makeContainerName(containerType)
config, err := cfgTor.ContainerConfig(image)
if err != nil {
return nil, fmt.Errorf("failed to create container configuration: %w", err)
}
hostConfig, err := cfgTor.HostConfig()
if err != nil {
return nil, err
}
networkConfig := cfgTor.NetworkConfig([]string{"build", containerName})
var platform *v1.Platform
// predefined/helper container always uses native platform
if containerType == buildContainerType {
platform = platformForImage(image, imageDefinition.ExecutorOptions)
}
// this will fail potentially some builds if there's name collision
_ = e.removeContainer(e.Context, containerName)
e.BuildLogger.Debugln("Creating container", containerName, "...")
resp, err := e.client.ContainerCreate(e.Context, config, hostConfig, networkConfig, platform, containerName)
if resp.ID != "" {
e.temporary = append(e.temporary, resp.ID)
if containerType == buildContainerType {
e.buildContainerID = resp.ID
}
}
if err != nil {
return nil, err
}
inspect, err := e.client.ContainerInspect(e.Context, resp.ID)
return &inspect, err
}
// addStepRunnerToPath adds the step-runner's path (stepRunnerBinaryPath) to the specific environment's PATH variable.
func addStepRunnerToPath(env []string) []string {
res := []string{}
for _, e := range env {
if strings.HasPrefix(e, "PATH=") {
res = append(res, e+":"+stepRunnerBinaryPath)
} else {
res = append(res, e)
}
}
return res
}
func (e *executor) createContainerConfig(
containerType string,
imageDefinition common.Image,
image *types.ImageInspect,
hostname string,
cmd []string,
) (*container.Config, error) {
labels := e.prepareContainerLabels(map[string]string{"type": containerType})
config := &container.Config{
Image: image.ID,
Hostname: hostname,
Cmd: cmd,
Labels: labels,
Tty: false,
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
OpenStdin: true,
StdinOnce: true,
Env: e.Build.GetAllVariables().StringList(),
}
// user config should only be set in build containers
if containerType == buildContainerType {
if user, err := e.getBuildContainerUser(imageDefinition); err != nil {
return nil, err
} else {
config.User = user
}
}
// only allow entrypoint overwriting if steps is not enabled OR this is the helper container.
if containerType == predefinedContainerType || !e.Build.UseNativeSteps() {
config.Entrypoint = e.overwriteEntrypoint(&imageDefinition)
}
if containerType == buildContainerType && e.Build.UseNativeSteps() {
// Set the build container's environment to the build IMAGE's environment with the step-runner binary path
// appended to PATH.
config.Env = addStepRunnerToPath(image.Config.Env)
}
// setting a container's mac-address changed in API version 1.44
if e.serverAPIVersion.LessThan(version1_44) {
//nolint:staticcheck
config.MacAddress = e.Config.Docker.MacAddress
}
return config, nil
}
func (e *executor) getBuildContainerUser(imageDefinition common.Image) (string, error) {
// runner config takes precedence
user := e.Config.Docker.User
if user == "" {
user = imageDefinition.ExecutorOptions.Docker.User
}
if !e.Config.Docker.IsUserAllowed(user) {
return "", fmt.Errorf("user %q is not an allowed user: %v",
user, e.Config.Docker.AllowedUsers)
}
return user, nil
}
func (e *executor) createHostConfig(isBuildContainer, imageIsPrivileged bool) (*container.HostConfig, error) {
nanoCPUs, err := e.Config.Docker.GetNanoCPUs()
if err != nil {
return nil, err
}
isolation := container.Isolation(e.Config.Docker.Isolation)
if !isolation.IsValid() {
return nil, fmt.Errorf("the isolation value %q is not valid. "+
"the valid values are: 'process', 'hyperv', 'default' and an empty string", isolation)
}
ulimits, err := e.Config.Docker.GetUlimits()
if err != nil {
return nil, err
}
var useInit *bool
if isBuildContainer && e.Build.IsFeatureFlagOn(featureflags.UseInitWithDockerExecutor) {
yes := true
useInit = &yes
}
return &container.HostConfig{
Resources: container.Resources{
Memory: e.Config.Docker.GetMemory(),
MemorySwap: e.Config.Docker.GetMemorySwap(),
MemoryReservation: e.Config.Docker.GetMemoryReservation(),
CgroupParent: e.Config.Docker.CgroupParent,
CpusetCpus: e.Config.Docker.CPUSetCPUs,
CpusetMems: e.Config.Docker.CPUSetMems,
CPUShares: e.Config.Docker.CPUShares,
NanoCPUs: nanoCPUs,
Devices: e.devices,
DeviceRequests: e.deviceRequests,
OomKillDisable: e.Config.Docker.GetOomKillDisable(),
DeviceCgroupRules: e.Config.Docker.DeviceCgroupRules,
Ulimits: ulimits,
},
DNS: e.Config.Docker.DNS,
DNSSearch: e.Config.Docker.DNSSearch,
Runtime: e.Config.Docker.Runtime,
Privileged: e.Config.Docker.Privileged && imageIsPrivileged,
GroupAdd: e.Config.Docker.GroupAdd,
UsernsMode: container.UsernsMode(e.Config.Docker.UsernsMode),
CapAdd: e.Config.Docker.CapAdd,
CapDrop: e.Config.Docker.CapDrop,
SecurityOpt: e.Config.Docker.SecurityOpt,
RestartPolicy: neverRestartPolicy,
ExtraHosts: e.Config.Docker.ExtraHosts,
NetworkMode: e.networkMode,
IpcMode: container.IpcMode(e.Config.Docker.IpcMode),
Links: append(e.Config.Docker.Links, e.links...),
Binds: e.volumesManager.Binds(),
OomScoreAdj: e.Config.Docker.OomScoreAdjust,
ShmSize: e.Config.Docker.ShmSize,
Isolation: isolation,
VolumeDriver: e.Config.Docker.VolumeDriver,
VolumesFrom: e.Config.Docker.VolumesFrom,
LogConfig: container.LogConfig{
Type: "json-file",
},
Tmpfs: e.Config.Docker.Tmpfs,
Sysctls: e.Config.Docker.SysCtls,
Init: useInit,
}, nil
}
func (e *executor) startAndWatchContainer(ctx context.Context, id string, input io.Reader) error {
dockerExec := exec.NewDocker(e.Context, e.client, e.waiter, e.Build.Log())
// Use stepsDocker exec implementation if steps is enabled and this is the build container.
if id == e.buildContainerID && e.Build.UseNativeSteps() {
request, err := steps.NewRequest(e.Build)
if err != nil {
return common.MakeBuildError("creating steps request: %w", err).WithFailureReason(common.ConfigurationError)
}
dockerExec = exec.NewStepsDocker(e.Context, e.client, e.waiter, e.Build.Log(), request)
}
stdout := e.BuildLogger.Stream(buildlogger.StreamWorkLevel, buildlogger.Stdout)
defer stdout.Close()
stderr := e.BuildLogger.Stream(buildlogger.StreamWorkLevel, buildlogger.Stderr)
defer stderr.Close()
streams := exec.IOStreams{
Stdin: input,
Stdout: stdout,
Stderr: stderr,
}
var gracefulExitFunc wait.GracefulExitFunc
if id == e.buildContainerID && e.helperImageInfo.OSType != helperimage.OSTypeWindows {
// send SIGTERM to all processes in the build container.
gracefulExitFunc = e.sendSIGTERMToContainerProcs
}
err := dockerExec.Exec(ctx, id, streams, gracefulExitFunc)
// if the context is canceled we attempt to remove the container,
// as Exec making calls such as ContainerAttach that are canceled
// can leave the container in a state that cannot easily be recovered
// from.
if ctx.Err() != nil {
_ = e.removeContainer(e.Context, id)
}
return err
}
func (e *executor) removeContainer(ctx context.Context, id string) error {
e.BuildLogger.Debugln("Removing container", id)
e.disconnectNetwork(ctx, id)
options := container.RemoveOptions{
RemoveVolumes: true,
Force: true,
}
err := e.client.ContainerRemove(ctx, id, options)
if docker.IsErrNotFound(err) {
return nil
}
if err != nil {
e.BuildLogger.Debugln("Removing container", id, "finished with error", err)
return fmt.Errorf("removing container: %w", err)
}
e.BuildLogger.Debugln("Removed container", id)
return nil
}
func (e *executor) disconnectNetwork(ctx context.Context, id string) {
e.BuildLogger.Debugln("Disconnecting container", id, "from networks")
netList, err := e.client.NetworkList(ctx, network.ListOptions{})
if err != nil {
e.BuildLogger.Debugln("Can't get network list. ListNetworks exited with", err)
return
}
for _, network := range netList {
for _, pluggedContainer := range network.Containers {
if id == pluggedContainer.Name {
err = e.client.NetworkDisconnect(ctx, network.ID, id, true)
if err != nil {
e.BuildLogger.Warningln(
"Can't disconnect possibly zombie container",
pluggedContainer.Name,
"from network",
network.Name,
"->",
err,
)
} else {
e.BuildLogger.Warningln(
"Possibly zombie container",
pluggedContainer.Name,
"is disconnected from network",
network.Name,
)
}
break
}
}
}
}
func (e *executor) verifyAllowedImage(image, optionName string, allowedImages, internalImages []string) error {
options := common.VerifyAllowedImageOptions{
Image: image,
OptionName: optionName,
AllowedImages: allowedImages,
InternalImages: internalImages,
}
return common.VerifyAllowedImage(options, e.BuildLogger)
}
func (e *executor) expandImageName(imageName string, allowedInternalImages []string) (string, error) {
defaultDockerImage := e.ExpandValue(e.Config.Docker.Image)
if imageName != "" {
image := e.ExpandValue(imageName)
allowedInternalImages = append(allowedInternalImages, defaultDockerImage)
err := e.verifyAllowedImage(image, "images", e.Config.Docker.AllowedImages, allowedInternalImages)
if err != nil {
return "", err
}
return image, nil
}
if defaultDockerImage == "" {
return "", errors.New("no Docker image specified to run the build in")
}
return defaultDockerImage, nil
}
func (e *executor) overwriteEntrypoint(image *common.Image) []string {
if len(image.Entrypoint) > 0 {
if !e.Config.Docker.DisableEntrypointOverwrite {
return image.Entrypoint
}
e.BuildLogger.Warningln("Entrypoint override disabled")
}
return nil
}
//nolint:nestif
func (e *executor) connectDocker(options common.ExecutorPrepareOptions) error {
var opts []client.Opt
creds := e.Config.Docker.Credentials
environment, ok := e.Build.ExecutorData.(executors.Environment)
if ok {
c, err := environment.Prepare(e.Context, e.BuildLogger, options)
if err != nil {
return fmt.Errorf("preparing environment: %w", err)
}
if e.tunnelClient != nil {
e.tunnelClient.Close()
}
e.tunnelClient = c
// We tunnel the docker connection for remote environments.
//
// To do this, we create a new dial context for Docker's client, whilst
// also overridding the daemon hostname it would typically use (if it were to use
// its own dialer).
host := creds.Host
scheme, dialer, err := e.environmentDialContext(c, host)
if err != nil {
return fmt.Errorf("creating env dialer: %w", err)
}
// If the scheme (docker uses it to define the protocol used) is "npipe" or "unix", we
// need to use a "fake" host, otherwise when dialing from Linux to Windows or vice-versa
// docker will complain because it doesn't think Linux can support "npipe" and doesn't
// think Windows can support "unix".
switch scheme {
case "unix", "npipe", "dial-stdio":
creds.Host = internalFakeTunnelHostname
}
opts = append(opts, client.WithDialContext(dialer))
}
dockerClient, err := docker.New(creds, opts...)
if err != nil {
return err
}
e.client = dockerClient
e.info, err = e.client.Info(e.Context)
if err != nil {
return err
}
serverVersion, err := e.client.ServerVersion(e.Context)
if err != nil {
return fmt.Errorf("getting server version info: %w", err)
}
e.serverAPIVersion, err = version.NewVersion(serverVersion.APIVersion)
if err != nil {
return fmt.Errorf("parsing server API version %q: %w", serverVersion.APIVersion, err)
}
e.BuildLogger.Debugln(fmt.Sprintf(
"Connected to docker daemon (client version: %s, server version: %s, api version: %s, kernel: %s, os: %s/%s)",
e.client.ClientVersion(),
e.info.ServerVersion,
serverVersion.APIVersion,
e.info.KernelVersion,
e.info.OSType,
e.info.Architecture,
))
err = e.validateOSType()
if err != nil {
return err
}
e.waiter = wait.NewDockerKillWaiter(e.client)
return err
}
func (e *executor) environmentDialContext(
executorClient executors.Client,
host string,
) (string, func(ctx context.Context, network, addr string) (net.Conn, error), error) {
systemHost := host == ""
if host == "" {
host = os.Getenv("DOCKER_HOST")
}
if host == "" {
host = client.DefaultDockerHost
}
u, err := client.ParseHostURL(host)
if err != nil {
return "", nil, fmt.Errorf("parsing docker host: %w", err)
}
if !e.Build.IsFeatureFlagOn(featureflags.UseDockerAutoscalerDialStdio) {
return u.Scheme, func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := executorClient.Dial(u.Scheme, u.Host)
if err != nil {
return nil, fmt.Errorf("dialing environment connection: %w", err)
}
return conn, nil
}, nil
}
return "dial-stdio", func(_ context.Context, network, addr string) (net.Conn, error) {
// DialRun doesn't want just a context for dialing, but one for a long-lived connection,
// so here we're ensuring that we use the executor's context, so that it is only cancelled
// when the job is cancelled.
// if the host was explicit, we try to use this even with dial-stdio
cmd := fmt.Sprintf("docker -H %s system dial-stdio", host)
// rather than use this system's host, we use the remote system's default
if systemHost {
cmd = "docker system dial-stdio"
}
return executorClient.DialRun(e.Context, cmd)
}, nil
}
// validateOSType checks if the ExecutorOptions metadata matches with the docker
// info response.
func (e *executor) validateOSType() error {
switch e.info.OSType {
case osTypeLinux, osTypeWindows, osTypeFreeBSD:
return nil
}
return fmt.Errorf("unsupported os type: %s", e.info.OSType)
}
func (e *executor) createDependencies() error {
createDependenciesStrategy := []func() error{
e.createLabeler,
e.createNetworksManager,
e.createBuildNetwork,
e.createPullManager,
e.bindDevices,
e.bindDeviceRequests,
e.createVolumesManager,
e.createVolumes,
e.createBuildVolume,
e.createStepRunnerVolume,
e.createServices,
}
for _, setup := range createDependenciesStrategy {
err := setup()
if err != nil {
return err
}
}
return nil
}
func (e *executor) createVolumes() error {
e.SetCurrentStage(ExecutorStageCreatingUserVolumes)
e.BuildLogger.Debugln("Creating user-defined volumes...")
if e.volumesManager == nil {
return errVolumesManagerUndefined
}
for _, volume := range e.Config.Docker.Volumes {
err := e.volumesManager.Create(e.Context, volume)
if errors.Is(err, volumes.ErrCacheVolumesDisabled) {
e.BuildLogger.Warningln(fmt.Sprintf(
"Container based cache volumes creation is disabled. Will not create volume for %q",
volume,
))
continue
}
if err != nil {
return err
}
}
return nil
}
func (e *executor) createBuildVolume() error {
e.SetCurrentStage(ExecutorStageCreatingBuildVolumes)
e.BuildLogger.Debugln("Creating build volume...")
if e.volumesManager == nil {
return errVolumesManagerUndefined
}
jobsDir := e.Build.RootDir
var err error
if e.Build.GetGitStrategy() == common.GitFetch {
err = e.volumesManager.Create(e.Context, jobsDir)
if err == nil {
return nil
}
if errors.Is(err, volumes.ErrCacheVolumesDisabled) {
err = e.volumesManager.CreateTemporary(e.Context, jobsDir)
}
} else {
err = e.volumesManager.CreateTemporary(e.Context, jobsDir)
}
if err != nil {
var volDefinedErr *volumes.ErrVolumeAlreadyDefined
if !errors.As(err, &volDefinedErr) {
return err
}
}
return nil
}
func (e *executor) createStepRunnerVolume() error {
if !e.Build.UseNativeSteps() {
return nil
}
e.SetCurrentStage(ExecutorStageCreatingStepRunnerVolume)
e.BuildLogger.Debugln("Creating step-runner volume...")
if e.volumesManager == nil {
return errVolumesManagerUndefined
}
return e.volumesManager.CreateTemporary(e.Context, stepRunnerBinaryPath)
}
func (e *executor) Prepare(options common.ExecutorPrepareOptions) error {
e.SetCurrentStage(ExecutorStagePrepare)
if options.Config.Docker == nil {
return errors.New("missing docker configuration")
}
e.AbstractExecutor.PrepareConfiguration(options)
err := e.connectDocker(options)
if err != nil {
return err
}
e.helperImageInfo, err = e.prepareHelperImage()
if err != nil {
return err
}
// setup default executor options based on OS type
e.setupDefaultExecutorOptions(e.helperImageInfo.OSType)
err = e.prepareBuildsDir(options)
if err != nil {
return err
}
err = e.AbstractExecutor.PrepareBuildAndShell()
if err != nil {
return err
}
if e.BuildShell.PassFile {
return errors.New("docker doesn't support shells that require script file")
}
imageName, err := e.expandImageName(e.Build.Image.Name, []string{})
if err != nil {
return err
}
e.BuildLogger.Println("Using Docker executor with image", imageName, "...")
err = e.createDependencies()
if err != nil {
return err
}
return nil
}
func (e *executor) setupDefaultExecutorOptions(os string) {
switch os {
case helperimage.OSTypeWindows:
e.DefaultBuildsDir = `C:\builds`
e.DefaultCacheDir = `C:\cache`
e.ExecutorOptions.Shell.Shell = shells.SNPowershell
e.ExecutorOptions.Shell.RunnerCommand = "gitlab-runner-helper"
if e.volumeParser == nil {
e.volumeParser = parser.NewWindowsParser(e.ExpandValue)
}
if e.newVolumePermissionSetter == nil {
e.newVolumePermissionSetter = func() (permission.Setter, error) {
return permission.NewDockerWindowsSetter(), nil
}
}
default:
e.DefaultBuildsDir = `/builds`
e.DefaultCacheDir = `/cache`
e.ExecutorOptions.Shell.Shell = "bash"
e.ExecutorOptions.Shell.RunnerCommand = "/usr/bin/gitlab-runner-helper"
if e.volumeParser == nil {
e.volumeParser = parser.NewLinuxParser(e.ExpandValue)
}
if e.newVolumePermissionSetter == nil {
e.newVolumePermissionSetter = func() (permission.Setter, error) {
helperImage, err := e.getHelperImage()
if err != nil {
return nil, err
}
return permission.NewDockerLinuxSetter(e.client, e.Build.Log(), helperImage), nil
}
}
}
}
func (e *executor) prepareHelperImage() (helperimage.Info, error) {
return helperimage.Get(common.AppVersion.Version, helperimage.Config{
OSType: e.info.OSType,
Architecture: e.info.Architecture,
KernelVersion: e.info.KernelVersion,
Shell: e.Config.Shell,
Flavor: e.ExpandValue(e.Config.Docker.HelperImageFlavor),
ProxyExec: e.Config.IsProxyExec(),
})
}
func (e *executor) prepareBuildsDir(options common.ExecutorPrepareOptions) error {
if e.volumeParser == nil {
return common.MakeBuildError("missing volume parser").WithFailureReason(common.RunnerSystemFailure)
}
isHostMounted, err := volumes.IsHostMountedVolume(e.volumeParser, e.RootDir(), options.Config.Docker.Volumes...)
if err != nil {
return &common.BuildError{Inner: err}
}
// We need to set proper value for e.SharedBuildsDir because
// it's required to properly start the job, what is done inside of
// e.AbstractExecutor.Prepare()
// And a started job is required for Volumes Manager to work, so it's
// done before the manager is even created.
if isHostMounted {
e.SharedBuildsDir = true
}
return nil
}
func (e *executor) Cleanup() {
if e.Config.Docker == nil {
// if there's no Docker config, we got here because Prepare() failed
// and there's nothing to cleanup.
return
}
e.SetCurrentStage(ExecutorStageCleanup)
var wg sync.WaitGroup
ctx, cancel := context.WithTimeout(context.Background(), dockerCleanupTimeout)
defer cancel()
remove := func(id string) {
wg.Add(1)
go func() {
_ = e.removeContainer(ctx, id)
wg.Done()
}()
}
for _, temporaryID := range e.temporary {
remove(temporaryID)
}
wg.Wait()
err := e.cleanupVolume(ctx)
if err != nil {
volumeLogger := e.BuildLogger.WithFields(logrus.Fields{
"error": err,
})
volumeLogger.Errorln("Failed to cleanup volumes")
}
err = e.cleanupNetwork(ctx)
if err != nil {
networkLogger := e.BuildLogger.WithFields(logrus.Fields{
"network": e.networkMode.NetworkName(),
"error": err,
})
networkLogger.Errorln("Failed to remove network for build")
}
if e.client != nil {
err = e.client.Close()
if err != nil {
clientCloseLogger := e.BuildLogger.WithFields(logrus.Fields{
"error": err,
})
clientCloseLogger.Debugln("Failed to close the client")
}
}
if e.tunnelClient != nil {
e.tunnelClient.Close()
}
e.AbstractExecutor.Cleanup()
}
// sendSIGTERMToContainerProcs exec's into the specified container and executes the script
// shells.sendSIGTERMToContainerProcs, which (unsurprisingly) sends SIGTERM to all processes in the container. This
// Effectively gives the processes in a the container a chance to exit gracefully (if they listen for SIGTERM).
func (e *executor) sendSIGTERMToContainerProcs(ctx context.Context, containerID string) error {
e.BuildLogger.Debugln("Emitting SIGTERM to processes in container", containerID)
return e.execScriptOnContainer(ctx, containerID, shells.ContainerSigTermScriptForLinux)
}
// Because docker error types are in fact interfaces with a unique identifying method, it's not possible to use
// errors.Is or errors.As on them. And because we wrap those errors as they are returned up the chain, we can't use
// errdefs directly. Do this instead.
func shouldIgnoreDockerError(err error, isFuncs ...func(error) bool) bool {
if err == nil {
return true
}
for e := err; e != nil; e = errors.Unwrap(e) {
for _, is := range isFuncs {
if is(e) {
return true
}
}
}
return false
}
func (e *executor) execScriptOnContainer(ctx context.Context, containerID string, script ...string) (err error) {
action := ""
execConfig := container.ExecOptions{
Tty: false,
AttachStderr: true,
AttachStdout: true,
Cmd: append([]string{"sh", "-c"}, script...),
}
defer func() {
if !shouldIgnoreDockerError(err, errdefs.IsConflict, errdefs.IsNotFound) {
e.Config.Log().WithFields(logrus.Fields{"error": err}).Warningln(action, err)
}
}()
exec, err := e.client.ContainerExecCreate(ctx, containerID, execConfig)
if err != nil {
action = "Failed to exec create to container:"
return err
}
resp, err := e.client.ContainerExecAttach(ctx, exec.ID, container.ExecStartOptions{})
if err != nil {
action = "Failed to exec attach to container:"
return err
}
defer resp.Close()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
<-ctx.Done()
resp.Close()
}()
// Copy any output generated by running the script (typically there will be none) to runner's stdout/stderr...
_, err = stdcopy.StdCopy(os.Stdout, os.Stderr, resp.Reader)
if err != nil {
action = "Failed to read from attached container:"
return err
}
return nil
}
func (e *executor) cleanupVolume(ctx context.Context) error {
if e.volumesManager == nil {
e.BuildLogger.Debugln("Volumes manager is empty, skipping volumes cleanup")
return nil
}
err := e.volumesManager.RemoveTemporary(ctx)
if err != nil {
return fmt.Errorf("remove temporary volumes: %w", err)
}
return nil
}
func (e *executor) createHostConfigForServiceHealthCheck(service *types.Container) *container.HostConfig {
var legacyLinks []string
if e.networkMode.UserDefined() == "" {
legacyLinks = append(legacyLinks, service.Names[0]+":service")
}
return &container.HostConfig{
RestartPolicy: neverRestartPolicy,
Links: legacyLinks,
NetworkMode: e.networkMode,
LogConfig: container.LogConfig{
Type: "json-file",
},
}
}
// addServiceHealthCheckEnvironment returns environment variables mimicing
// the legacy container links networking feature of Docker, where environment
// variables are provided with the hostname and port of the linked service our
// health check is performed against.
//
// The hostname we provide is the container's short ID (the first 12 characters
// of a full container ID). The short ID, as opposed to the full ID, is
// internally resolved to the container's IP address by Docker's built-in DNS
// service.
//
// The legacy container links (https://docs.docker.com/network/links/) network
// feature is deprecated. When we remove support for links, the healthcheck
// system can be updated to no longer rely on environment variables
func (e *executor) addServiceHealthCheckEnvironment(service *types.Container) ([]string, error) {
environment := []string{}
if e.networkMode.UserDefined() != "" {
environment = append(environment, "WAIT_FOR_SERVICE_TCP_ADDR="+service.ID[:12])
ports, err := e.getContainerExposedPorts(service)
if err != nil {
return nil, fmt.Errorf("get container exposed ports: %v", err)
}
if len(ports) == 0 {
return nil, fmt.Errorf("service %q has no exposed ports", service.Names[0])
}
for _, port := range ports {
environment = append(environment, fmt.Sprintf("WAIT_FOR_SERVICE_%d_TCP_PORT=%d", port, port))
}
}
return environment, nil
}
//nolint:gocognit
func (e *executor) getContainerExposedPorts(container *types.Container) ([]int, error) {
inspect, err := e.client.ContainerInspect(e.Context, container.ID)
if err != nil {
return nil, err
}
for _, env := range inspect.Config.Env {
key, val, ok := strings.Cut(env, "=")
if !ok {
continue
}
if strings.EqualFold(key, "HEALTHCHECK_TCP_PORT") {
port, err := strconv.ParseInt(val, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid health check tcp port: %v", val)
}
return []int{int(port)}, nil
}
}
// maxPortsCheck is the maximum number of ports that we'll check to see
// if a service is running
const maxPortsCheck = 20
var ports []int
for port := range inspect.Config.ExposedPorts {
start, end, err := port.Range()
if err == nil && port.Proto() == "tcp" {
for i := start; i <= end && len(ports) < maxPortsCheck; i++ {
ports = append(ports, i)
}
}
}
sort.Ints(ports)
return ports, nil
}
func (e *executor) readContainerLogs(containerID string) string {
var buf bytes.Buffer
options := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Timestamps: true,
}
hijacked, err := e.client.ContainerLogs(e.Context, containerID, options)
if err != nil {
return strings.TrimSpace(err.Error())
}
defer func() { _ = hijacked.Close() }()
// limit how much data we read from the container log to
// avoid memory exhaustion
w := limitwriter.New(&buf, ServiceLogOutputLimit)
_, _ = stdcopy.StdCopy(w, w, hijacked)
return strings.TrimSpace(buf.String())
}
func (e *executor) prepareContainerLabels(otherLabels map[string]string) map[string]string {
l := e.labeler.Labels(otherLabels)
for k, v := range e.Config.Docker.ContainerLabels {
l[k] = e.Build.Variables.ExpandValue(v)
}
return l
}