mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-13 22:41:54 +02:00
refactor: move act under internal
The runner is an application, not a library. `act/model` and `act/exprparser` were the last packages anything outside this repository consumed and they now live in actionslib, so nothing needs the rest of `act` to be importable, and keeping it importable invites the coupling that was just removed. Import paths only, the files are unchanged. Assisted-by: Codet:GPT-5.1-Codex
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2013-2017 Docker, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
)
|
||||
|
||||
// ExitCodeError reports a non-zero process exit code from a container command.
|
||||
type ExitCodeError int
|
||||
|
||||
func (e ExitCodeError) Error() string {
|
||||
return fmt.Sprintf("Process completed with exit code %d.", int(e))
|
||||
}
|
||||
|
||||
// NewContainerInput the input for the New function
|
||||
type NewContainerInput struct {
|
||||
Image string
|
||||
Username string
|
||||
Password string
|
||||
Entrypoint []string
|
||||
Cmd []string
|
||||
WorkingDir string
|
||||
Env []string
|
||||
Binds []string
|
||||
Mounts map[string]string
|
||||
Name string
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
NetworkMode string
|
||||
Privileged bool
|
||||
UsernsMode string
|
||||
Platform string
|
||||
Options string
|
||||
NetworkAliases []string
|
||||
ExposedPorts nat.PortSet
|
||||
PortBindings nat.PortMap
|
||||
|
||||
// Gitea specific
|
||||
AutoRemove bool
|
||||
ValidVolumes []string
|
||||
AllocatePTY bool // allocate a pseudo-TTY for the container's exec processes
|
||||
}
|
||||
|
||||
// FileEntry is a file to copy to a container
|
||||
type FileEntry struct {
|
||||
Name string
|
||||
Mode int64
|
||||
Body string
|
||||
}
|
||||
|
||||
// Container and healthcheck states, as plain strings so a caller of Info needs no docker
|
||||
// SDK of its own.
|
||||
const (
|
||||
StateRunning = string(container.StateRunning)
|
||||
|
||||
HealthNone = string(container.NoHealthcheck)
|
||||
HealthStarting = string(container.Starting)
|
||||
HealthHealthy = string(container.Healthy)
|
||||
HealthUnhealthy = string(container.Unhealthy)
|
||||
)
|
||||
|
||||
// ErrContainerNotFound reports a container the daemon no longer knows. Its text is a
|
||||
// fragment, missingContainerError composes it into the message every operation shares.
|
||||
var ErrContainerNotFound = errors.New("does not exist")
|
||||
|
||||
// Info is a snapshot of a container, as of one inspect.
|
||||
type Info struct {
|
||||
ID string
|
||||
State string // the docker container state: "created", "running", "exited", ...
|
||||
ExitCode int
|
||||
Health string // one of the Health* constants
|
||||
// HealthOutput is the last healthcheck probe's output.
|
||||
HealthOutput string
|
||||
Ports map[string]string // container port ("5432") to the host port it is published on
|
||||
}
|
||||
|
||||
// Container for managing docker run containers
|
||||
type Container interface {
|
||||
Create(capAdd, capDrop []string) common.Executor
|
||||
ConnectToNetwork(name string) common.Executor
|
||||
Copy(destPath string, files ...*FileEntry) common.Executor
|
||||
CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error
|
||||
CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor
|
||||
GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error)
|
||||
Inspect(ctx context.Context) (*Info, error)
|
||||
DumpLogs(ctx context.Context) error
|
||||
Pull(forcePull bool) common.Executor
|
||||
Start(attach bool) common.Executor
|
||||
Exec(command []string, env map[string]string, user, workdir string) common.Executor
|
||||
UpdateFromEnv(srcPath string, env *map[string]string) common.Executor
|
||||
UpdateFromImageEnv(env *map[string]string) common.Executor
|
||||
Remove() common.Executor
|
||||
Close() common.Executor
|
||||
ReplaceLogWriter(io.Writer, io.Writer) (io.Writer, io.Writer)
|
||||
}
|
||||
|
||||
// NewDockerBuildExecutorInput the input for the NewDockerBuildExecutor function
|
||||
type NewDockerBuildExecutorInput struct {
|
||||
ContextDir string
|
||||
Dockerfile string
|
||||
BuildContext io.Reader
|
||||
ImageTag string
|
||||
Platform string
|
||||
BuildArgs map[string]*string
|
||||
}
|
||||
|
||||
// NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function
|
||||
type NewDockerNetworkCreateExecutorInput struct {
|
||||
EnableIPv4 *bool
|
||||
EnableIPv6 *bool
|
||||
RunnerUUID string
|
||||
}
|
||||
|
||||
// NewDockerPullExecutorInput the input for the NewDockerPullExecutor function
|
||||
type NewDockerPullExecutorInput struct {
|
||||
Image string
|
||||
ForcePull bool
|
||||
Platform string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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 (
|
||||
"context"
|
||||
|
||||
"gitea.com/gitea/runner/internal/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())
|
||||
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.GetAuthConfig(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
|
||||
}
|
||||
|
||||
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||
logger := common.Logger(ctx)
|
||||
cfg, err := config.Load(config.Dir())
|
||||
if err != nil {
|
||||
logger.Warnf("Could not load docker config: %v", err)
|
||||
return nil
|
||||
}
|
||||
creds, err := cfg.GetAllCredentials()
|
||||
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
|
||||
}
|
||||
|
||||
func registryAuthConfigKey(domainName string) string {
|
||||
if domainName == "docker.io" || domainName == "index.docker.io" {
|
||||
return "https://index.docker.io/v1/"
|
||||
}
|
||||
return domainName
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.com/gitea/runner/internal/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"
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// NewDockerBuildExecutor function to create a run executor for the container
|
||||
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
if input.Platform != "" {
|
||||
logger.Infof("docker build -t %s --platform %s %s", input.ImageTag, input.Platform, input.ContextDir)
|
||||
} else {
|
||||
logger.Infof("docker build -t %s %s", input.ImageTag, input.ContextDir)
|
||||
}
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
logger.Debugf("Building image from '%v'", input.ContextDir)
|
||||
|
||||
tags := []string{input.ImageTag}
|
||||
options := client.ImageBuildOptions{
|
||||
Tags: tags,
|
||||
Remove: true,
|
||||
AuthConfigs: LoadDockerAuthConfigs(ctx),
|
||||
Dockerfile: input.Dockerfile,
|
||||
BuildArgs: input.BuildArgs,
|
||||
}
|
||||
platform, err := parsePlatform(input.Platform)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if platform != nil {
|
||||
options.Platforms = []specs.Platform{*platform}
|
||||
}
|
||||
var buildContext io.ReadCloser
|
||||
if input.BuildContext != nil {
|
||||
buildContext = io.NopCloser(input.BuildContext)
|
||||
} else {
|
||||
buildContext, err = createBuildContext(ctx, input.ContextDir, input.Dockerfile)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer buildContext.Close()
|
||||
|
||||
logger.Debugf("Creating image from context dir '%s' with tag '%s' and platform '%s'", input.ContextDir, input.ImageTag, input.Platform)
|
||||
resp, err := cli.ImageBuild(ctx, buildContext, options)
|
||||
|
||||
err = logDockerResponse(logger, resp.Body, err != nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (io.ReadCloser, error) {
|
||||
common.Logger(ctx).Debugf("Creating archive for build context dir '%s' with relative dockerfile '%s'", contextDir, relDockerfile)
|
||||
|
||||
// And canonicalize dockerfile name to a platform-independent one
|
||||
relDockerfile = filepath.ToSlash(relDockerfile)
|
||||
|
||||
f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var excludes []string
|
||||
if err == nil {
|
||||
excludes, err = ignorefile.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If .dockerignore mentions .dockerignore or the Dockerfile
|
||||
// then make sure we send both files over to the daemon
|
||||
// because Dockerfile is, obviously, needed no matter what, and
|
||||
// .dockerignore is needed to know if either one needs to be
|
||||
// removed. The daemon will remove them for us, if needed, after it
|
||||
// parses the Dockerfile. Ignore errors here, as they will have been
|
||||
// caught by validateContextDirectory above.
|
||||
includes := []string{"."}
|
||||
keepThem1, _ := patternmatcher.Matches(".dockerignore", excludes)
|
||||
keepThem2, _ := patternmatcher.Matches(relDockerfile, excludes)
|
||||
if keepThem1 || keepThem2 {
|
||||
includes = append(includes, ".dockerignore", relDockerfile)
|
||||
}
|
||||
|
||||
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
||||
Compression: compression.None,
|
||||
ExcludePatterns: excludes,
|
||||
IncludeFiles: includes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildCtx, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const (
|
||||
pullPolicyAlways = "always"
|
||||
pullPolicyMissing = "missing"
|
||||
pullPolicyNever = "never"
|
||||
)
|
||||
|
||||
var pullPolicies = []string{pullPolicyAlways, pullPolicyMissing, pullPolicyNever}
|
||||
|
||||
// createFlags are the flags docker/cli registers on the `create` and `run` commands
|
||||
// instead of in addFlags, so they are not part of containerOptions.
|
||||
type createFlags struct {
|
||||
platform string
|
||||
pull string
|
||||
name string
|
||||
useAPISocket bool
|
||||
}
|
||||
|
||||
func registerCreateFlags(flags *pflag.FlagSet) *createFlags {
|
||||
cf := new(createFlags)
|
||||
flags.StringVar(&cf.platform, "platform", "", "Set platform if server is multi-platform capable")
|
||||
flags.StringVar(&cf.pull, "pull", pullPolicyMissing, `Pull image before creating ("always", "missing", "never")`)
|
||||
flags.StringVar(&cf.name, "name", "", "Assign a name to the container")
|
||||
flags.BoolVar(&cf.useAPISocket, "use-api-socket", false, "Bind mount Docker API socket and required auth")
|
||||
// Accepted without effect: pull progress is only logged at debug level, and docker
|
||||
// no longer implements content trust.
|
||||
flags.BoolP("quiet", "q", false, "Suppress the pull output")
|
||||
flags.Bool("disable-content-trust", true, "Skip image verification (deprecated)")
|
||||
return cf
|
||||
}
|
||||
|
||||
// parseContainerOptions parses a container options string. The flags are returned even
|
||||
// on error, holding whatever was read before the failure.
|
||||
func parseContainerOptions(options string) (*pflag.FlagSet, *containerOptions, *createFlags, error) {
|
||||
flags := pflag.NewFlagSet("container_flags", pflag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
copts := addFlags(flags)
|
||||
cf := registerCreateFlags(flags)
|
||||
|
||||
args, err := shellquote.Split(options)
|
||||
if err != nil {
|
||||
return flags, copts, cf, fmt.Errorf("Cannot split container options: '%s': '%w'", options, err)
|
||||
}
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return flags, copts, cf, fmt.Errorf("Cannot parse container options: '%s': '%w'", options, err)
|
||||
}
|
||||
|
||||
return flags, copts, cf, nil
|
||||
}
|
||||
|
||||
// createFlagsFromOptions reads the create-level flags that have to be known before the
|
||||
// container is created. Malformed options keep the defaults here and are reported by
|
||||
// mergeContainerConfigs at create time.
|
||||
func createFlagsFromOptions(options string) *createFlags {
|
||||
_, _, cf, _ := parseContainerOptions(options)
|
||||
return cf
|
||||
}
|
||||
|
||||
func (cf *createFlags) validate() error {
|
||||
if !slices.Contains(pullPolicies, cf.pull) {
|
||||
return fmt.Errorf("invalid --pull option %q: must be one of %q", cf.pull, pullPolicies)
|
||||
}
|
||||
|
||||
if cf.useAPISocket {
|
||||
return errors.New("--use-api-socket is not supported, use the runner's container.docker_host setting to expose a docker socket")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateFlagsFromOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
options string
|
||||
platform string
|
||||
pull string
|
||||
}{
|
||||
{"", "", pullPolicyMissing},
|
||||
{"-v /a:/b --platform=linux/arm64 --pull always", "linux/arm64", pullPolicyAlways},
|
||||
{"--platform linux/arm/v7 --pull never", "linux/arm/v7", pullPolicyNever},
|
||||
{`--platform "linux/amd64`, "", pullPolicyMissing}, // malformed, defaults kept
|
||||
} {
|
||||
t.Run(tc.options, func(t *testing.T) {
|
||||
cf := createFlagsFromOptions(tc.options)
|
||||
assert.Equal(t, tc.platform, cf.platform)
|
||||
assert.Equal(t, tc.pull, cf.pull)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFlagsValidate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
options string
|
||||
wantErr string
|
||||
}{
|
||||
{"--quiet --disable-content-trust --name mine", ""},
|
||||
{"--pull sometimes", `invalid --pull option "sometimes"`},
|
||||
{"--use-api-socket", "--use-api-socket is not supported"},
|
||||
} {
|
||||
t.Run(tc.options, func(t *testing.T) {
|
||||
err := createFlagsFromOptions(tc.options).validate()
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContainerAppliesCreateFlags(t *testing.T) {
|
||||
input := &NewContainerInput{Platform: "linux/amd64", Options: "--platform linux/arm64 --pull never"}
|
||||
cr, ok := NewContainer(input).(*containerReference)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "linux/arm64", input.Platform)
|
||||
assert.Equal(t, pullPolicyNever, cr.pullPolicy)
|
||||
|
||||
kept := &NewContainerInput{Platform: "linux/amd64", Options: "--privileged"}
|
||||
NewContainer(kept)
|
||||
assert.Equal(t, "linux/amd64", kept.Platform)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||
// requested name, tag and architecture exists in the local docker image store
|
||||
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if platform == "" || platform == "any" || fmt.Sprintf("%s/%s", inspectImage.Os, inspectImage.Architecture) == platform {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// RemoveImage removes image from local store, the function is used to run different
|
||||
// container image architectures
|
||||
func RemoveImage(ctx context.Context, imageName string, force, pruneChildren bool) (bool, error) {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
|
||||
Force: force,
|
||||
PruneChildren: pruneChildren,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
|
||||
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
|
||||
// network or emulation since there is nothing to run) and returns its tag, removing it after
|
||||
// the test.
|
||||
func buildScratchImage(t *testing.T, platform string) string {
|
||||
t.Helper()
|
||||
tag := fmt.Sprintf("act-test-exists-%s:latest", strings.TrimPrefix(platform, "linux/"))
|
||||
cmd := exec.Command("docker", "build", "--platform", platform, "-t", tag, "-")
|
||||
cmd.Stdin = strings.NewReader("FROM scratch\nLABEL act-test=1\n")
|
||||
// Force BuildKit: it records the requested architecture in the image config for a
|
||||
// FROM-scratch build, whereas the classic builder ignores --platform and tags it with the
|
||||
// host arch, which would break the per-platform existence assertions below.
|
||||
cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
|
||||
return tag
|
||||
}
|
||||
|
||||
func TestImageExistsLocally(t *testing.T) {
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// a non-existent image is reported absent
|
||||
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.False(t, missing)
|
||||
|
||||
// Build tiny images for two architectures locally so per-platform existence can be checked
|
||||
// offline (formerly pulled node:24-bookworm-slim for amd64 and arm64 over the network).
|
||||
amd64Ref := buildScratchImage(t, "linux/amd64")
|
||||
arm64Ref := buildScratchImage(t, "linux/arm64")
|
||||
|
||||
amd64Exists, err := ImageExistsLocally(ctx, amd64Ref, "linux/amd64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, amd64Exists)
|
||||
|
||||
// a non-host architecture image is detected for its own architecture
|
||||
arm64Exists, err := ImageExistsLocally(ctx, arm64Ref, "linux/arm64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, arm64Exists)
|
||||
|
||||
// a present image is reported absent for a different platform
|
||||
wrongPlatform, err := ImageExistsLocally(ctx, amd64Ref, "linux/arm64")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.False(t, wrongPlatform)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type dockerMessage struct {
|
||||
ID string `json:"id"`
|
||||
Stream string `json:"stream"`
|
||||
Error string `json:"error"`
|
||||
ErrorDetail struct {
|
||||
Message string
|
||||
}
|
||||
Status string `json:"status"`
|
||||
Progress string `json:"progress"`
|
||||
}
|
||||
|
||||
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
|
||||
if dockerResponse == nil {
|
||||
return nil
|
||||
}
|
||||
defer dockerResponse.Close()
|
||||
|
||||
scanner := bufio.NewScanner(dockerResponse)
|
||||
msg := dockerMessage{}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
msg.ID = ""
|
||||
msg.Stream = ""
|
||||
msg.Error = ""
|
||||
msg.ErrorDetail.Message = ""
|
||||
msg.Status = ""
|
||||
msg.Progress = ""
|
||||
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
writeLog(logger, false, "Unable to unmarshal line [%s] ==> %v", string(line), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Error != "" {
|
||||
writeLog(logger, isError, "%s", msg.Error)
|
||||
return errors.New(msg.Error)
|
||||
}
|
||||
|
||||
if msg.ErrorDetail.Message != "" {
|
||||
writeLog(logger, isError, "%s", msg.ErrorDetail.Message)
|
||||
return errors.New(msg.ErrorDetail.Message)
|
||||
}
|
||||
|
||||
if msg.Status != "" {
|
||||
if msg.Progress != "" {
|
||||
writeLog(logger, isError, "%s :: %s :: %s\n", msg.Status, msg.ID, msg.Progress)
|
||||
} else {
|
||||
writeLog(logger, isError, "%s :: %s\n", msg.Status, msg.ID)
|
||||
}
|
||||
} else if msg.Stream != "" {
|
||||
writeLog(logger, isError, "%s", msg.Stream)
|
||||
} else {
|
||||
writeLog(logger, false, "Unable to handle line: %s", string(line))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeLog(logger logrus.FieldLogger, isError bool, format string, args ...any) {
|
||||
if isError {
|
||||
logger.Errorf(format, args...)
|
||||
} else {
|
||||
logger.Debugf(format, args...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
const (
|
||||
networkCreateAttempts = 3
|
||||
networkCreateRetryDelay = time.Second
|
||||
|
||||
// marks the networks a runner creates for its jobs, so it can tell its own leftovers from
|
||||
// those of another runner sharing the daemon
|
||||
runnerUUIDLabel = "com.gitea.runner.uuid"
|
||||
)
|
||||
|
||||
// RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did
|
||||
// not get to them: the runner died with the job, the teardown timed out, or the network still
|
||||
// had an endpoint on it at the time. Each one holds a subnet of the daemon's address pool
|
||||
// until it is removed. Networks created after createdBefore are left alone, so a job starting
|
||||
// while this runs cannot lose the network it has created but not yet attached a container to.
|
||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to the docker daemon: %w", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
return removeOrphanNetworks(ctx, cli, runnerUUID, createdBefore)
|
||||
}
|
||||
|
||||
func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID string, createdBefore time.Time) error {
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{
|
||||
Filters: make(client.Filters).Add("label", runnerUUIDLabel+"="+runnerUUID),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, n := range networks.Items {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to inspect network %s: %w", n.Name, err))
|
||||
continue
|
||||
}
|
||||
// the emptiness check, not the label, is what keeps a live job of another process
|
||||
// sharing this registration safe
|
||||
if len(result.Network.Containers) != 0 || result.Network.Created.After(createdBefore) {
|
||||
continue
|
||||
}
|
||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||
continue
|
||||
}
|
||||
common.Logger(ctx).Infof("removed docker network %s left behind by an earlier job", n.Name)
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
// Only create the network if it doesn't exist
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for i := range networkCreateAttempts {
|
||||
if i > 0 {
|
||||
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||
}
|
||||
}
|
||||
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
EnableIPv4: opts.EnableIPv4,
|
||||
EnableIPv6: opts.EnableIPv6,
|
||||
Labels: runnerLabels(opts.RunnerUUID),
|
||||
}); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isAddressPoolExhausted(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runnerLabels(runnerUUID string) map[string]string {
|
||||
if runnerUUID == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{runnerUUIDLabel: runnerUUID}
|
||||
}
|
||||
|
||||
// The daemon reports this as a plain invalid-parameter error, the same kind it uses for every
|
||||
// malformed request, so the message is the only discriminator.
|
||||
func isAddressPoolExhausted(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "all predefined address pools have been fully subnetted") ||
|
||||
strings.Contains(msg, "could not find an available, non-overlapping IPv4 address pool among the defaults") // docker 24 and older
|
||||
}
|
||||
|
||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
// Make sure that all network of the specified name are removed
|
||||
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
var errs []error
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// it holds a subnet out of the daemon's pool until something reclaims it
|
||||
if len(result.Network.Containers) != 0 {
|
||||
common.Logger(ctx).Warnf("Refusing to remove network %s because it still has active endpoints, the idle cleanup reclaims it once they are gone", name)
|
||||
continue
|
||||
}
|
||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", name, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsAddressPoolExhausted(t *testing.T) {
|
||||
assert.True(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("Error response from daemon: all predefined address pools have been fully subnetted")))
|
||||
assert.True(t, isAddressPoolExhausted(errors.New("could not find an available, non-overlapping IPv4 address pool among the defaults to assign to the network")))
|
||||
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||
}
|
||||
|
||||
// Of this runner's networks, only the ones nothing is attached to and old enough to predate
|
||||
// any job now starting are the runner's to reclaim. An unexpected NetworkRemove fails the
|
||||
// test on its own, since testify has no expectation to match it against.
|
||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||
client := &mockDockerClient{}
|
||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||
Filters: make(mobyclient.Filters).Add("label", runnerUUIDLabel+"=runner-1"),
|
||||
}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||
{Network: network.Network{ID: "orphan"}},
|
||||
{Network: network.Network{ID: "busy"}},
|
||||
{Network: network.Network{ID: "starting"}},
|
||||
}}, nil)
|
||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
||||
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Network: network.Network{Created: cutoff.Add(time.Second)}}}, nil)
|
||||
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||
Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||
|
||||
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", cutoff))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2025 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// parsePlatform parses an "os/arch[/variant]" string into a Platform. An empty input
|
||||
// returns (nil, nil), meaning "no platform constraint". A non-empty but malformed
|
||||
// string is rejected explicitly so it cannot silently fall through to the daemon's
|
||||
// default architecture.
|
||||
func parsePlatform(platform string) (*specs.Platform, error) {
|
||||
if platform == "" {
|
||||
return nil, nil //nolint:nilnil // no platform constraint requested
|
||||
}
|
||||
|
||||
parts := strings.Split(platform, "/")
|
||||
if len(parts) < 2 || len(parts) > 3 || parts[0] == "" || parts[1] == "" || (len(parts) == 3 && parts[2] == "") {
|
||||
return nil, fmt.Errorf("invalid platform %q: expected os/arch[/variant]", platform)
|
||||
}
|
||||
|
||||
spec := &specs.Platform{
|
||||
OS: strings.ToLower(parts[0]),
|
||||
Architecture: strings.ToLower(parts[1]),
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
spec.Variant = strings.ToLower(parts[2])
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParsePlatform(t *testing.T) {
|
||||
t.Run("empty input returns nil platform without error", func(t *testing.T) {
|
||||
got, err := parsePlatform("")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
|
||||
t.Run("os/arch", func(t *testing.T) {
|
||||
got, err := parsePlatform("linux/amd64")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "linux", got.OS)
|
||||
assert.Equal(t, "amd64", got.Architecture)
|
||||
assert.Empty(t, got.Variant)
|
||||
})
|
||||
|
||||
t.Run("os/arch/variant", func(t *testing.T) {
|
||||
got, err := parsePlatform("linux/arm/v7")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "linux", got.OS)
|
||||
assert.Equal(t, "arm", got.Architecture)
|
||||
assert.Equal(t, "v7", got.Variant)
|
||||
})
|
||||
|
||||
t.Run("input is lowercased", func(t *testing.T) {
|
||||
got, err := parsePlatform("Linux/AMD64/V8")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "linux", got.OS)
|
||||
assert.Equal(t, "amd64", got.Architecture)
|
||||
assert.Equal(t, "v8", got.Variant)
|
||||
})
|
||||
|
||||
for _, bad := range []string{
|
||||
"amd64",
|
||||
"linux",
|
||||
"linux/",
|
||||
"/amd64",
|
||||
"/",
|
||||
"//",
|
||||
"linux/arm/",
|
||||
"linux/arm/v7/extra",
|
||||
} {
|
||||
t.Run("rejects "+bad, func(t *testing.T) {
|
||||
got, err := parsePlatform(bad)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
"github.com/moby/moby/api/pkg/authconfig"
|
||||
"github.com/moby/moby/api/types/registry"
|
||||
"github.com/moby/moby/client"
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// NewDockerPullExecutor function to create a run executor for the container
|
||||
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Debugf("docker pull %v", input.Image)
|
||||
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// skip the pull when the image is already here: either none was forced, or a digest
|
||||
// pins the content so a forced pull could only fetch the same bytes again
|
||||
if !input.ForcePull || isPinnedImage(input.Image) {
|
||||
imageExists, err := ImageExistsLocally(ctx, input.Image, input.Platform)
|
||||
logger.Debugf("Image exists? %v", imageExists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to determine if image already exists for image '%s' (%s): %w", input.Image, input.Platform, err)
|
||||
}
|
||||
if imageExists {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
imageRef := cleanImage(ctx, input.Image)
|
||||
logger.Debugf("pulling image '%v' (%s)", imageRef, input.Platform)
|
||||
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
imagePullOptions, err := getImagePullOptions(ctx, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// the daemon reports a failure that happens after the first progress line in the
|
||||
// stream rather than on the call itself, so both have to be checked
|
||||
pullOnce := func(opts client.ImagePullOptions) error {
|
||||
reader, err := cli.ImagePull(ctx, imageRef, opts)
|
||||
streamErr := logDockerResponse(logger, reader, err != nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return streamErr
|
||||
}
|
||||
|
||||
err = pullOnce(imagePullOptions)
|
||||
if err != nil && imagePullOptions.RegistryAuth != "" && strings.Contains(err.Error(), "unauthorized") {
|
||||
logger.Errorf("pulling image '%v' (%s) failed with credentials %s retrying without them, please check for stale docker config files", imageRef, input.Platform, err.Error())
|
||||
imagePullOptions.RegistryAuth = ""
|
||||
err = pullOnce(imagePullOptions)
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// a registry that is down should not fail a job whose image is already here
|
||||
if exists, existsErr := ImageExistsLocally(ctx, input.Image, input.Platform); existsErr == nil && exists {
|
||||
logger.Warnf("could not update image '%s' (%s), continuing with the local copy: %v", imageRef, input.Platform, err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to pull image '%s' (%s): %w", imageRef, input.Platform, err)
|
||||
}
|
||||
}
|
||||
|
||||
func getImagePullOptions(ctx context.Context, input NewDockerPullExecutorInput) (client.ImagePullOptions, error) {
|
||||
imagePullOptions := client.ImagePullOptions{}
|
||||
platform, err := parsePlatform(input.Platform)
|
||||
if err != nil {
|
||||
return imagePullOptions, err
|
||||
}
|
||||
if platform != nil {
|
||||
imagePullOptions.Platforms = []specs.Platform{*platform}
|
||||
}
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
if input.Username != "" && input.Password != "" {
|
||||
logger.Debugf("using authentication for docker pull")
|
||||
|
||||
encodedAuth, err := authconfig.Encode(registry.AuthConfig{
|
||||
Username: input.Username,
|
||||
Password: input.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return imagePullOptions, err
|
||||
}
|
||||
|
||||
imagePullOptions.RegistryAuth = encodedAuth
|
||||
} else {
|
||||
authConfig, err := LoadDockerAuthConfig(ctx, input.Image)
|
||||
if err != nil {
|
||||
return imagePullOptions, err
|
||||
}
|
||||
if authConfig.Username == "" && authConfig.Password == "" {
|
||||
return imagePullOptions, nil
|
||||
}
|
||||
logger.Info("using DockerAuthConfig authentication for docker pull")
|
||||
|
||||
imagePullOptions.RegistryAuth, err = authconfig.Encode(authConfig)
|
||||
if err != nil {
|
||||
return imagePullOptions, err
|
||||
}
|
||||
}
|
||||
|
||||
return imagePullOptions, nil
|
||||
}
|
||||
|
||||
func isPinnedImage(image string) bool {
|
||||
ref, err := reference.ParseAnyReference(image)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, pinned := ref.(reference.Canonical)
|
||||
return pinned
|
||||
}
|
||||
|
||||
func cleanImage(ctx context.Context, imageName string) string {
|
||||
ref, err := reference.ParseAnyReference(imageName)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Error(err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return ref.String()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
|
||||
func TestCleanImage(t *testing.T) {
|
||||
tables := []struct {
|
||||
imageIn string
|
||||
imageOut string
|
||||
}{
|
||||
{"myhost.com/foo/bar", "myhost.com/foo/bar"},
|
||||
{"localhost:8000/canonical/ubuntu", "localhost:8000/canonical/ubuntu"},
|
||||
{"localhost/canonical/ubuntu:latest", "localhost/canonical/ubuntu:latest"},
|
||||
{"localhost:8000/canonical/ubuntu:latest", "localhost:8000/canonical/ubuntu:latest"},
|
||||
{"ubuntu", "docker.io/library/ubuntu"},
|
||||
{"ubuntu:18.04", "docker.io/library/ubuntu:18.04"},
|
||||
{"cibuilds/hugo:0.53", "docker.io/cibuilds/hugo:0.53"},
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
imageOut := cleanImage(context.Background(), table.imageIn)
|
||||
assert.Equal(t, table.imageOut, imageOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetImagePullOptions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
orig := config.Dir()
|
||||
t.Cleanup(func() { config.SetDir(orig) })
|
||||
|
||||
config.SetDir("/non-existent/docker")
|
||||
|
||||
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, "", options.RegistryAuth, "RegistryAuth should be empty if no username or password is set") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||
Image: "",
|
||||
Username: "username",
|
||||
Password: "password",
|
||||
})
|
||||
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")
|
||||
|
||||
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||
Image: "nektos/act",
|
||||
})
|
||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZFxuIiwic2VydmVyYWRkcmVzcyI6Imh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxLyJ9", options.RegistryAuth, "RegistryAuth should be taken from local docker config")
|
||||
}
|
||||
|
||||
// A digest-pinned image is immutable, so its local copy is always current.
|
||||
func TestIsPinnedImage(t *testing.T) {
|
||||
assert.True(t, isPinnedImage("alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"))
|
||||
assert.False(t, isPinnedImage("alpine:latest"))
|
||||
}
|
||||
|
||||
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
||||
// whichever of the two shapes the daemon used.
|
||||
func TestLogDockerResponseError(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
streamErr := func(line string) error {
|
||||
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
||||
}
|
||||
require.EqualError(t, streamErr(`{"error":"toomanyrequests: rate limit exceeded"}`), "toomanyrequests: rate limit exceeded")
|
||||
require.EqualError(t, streamErr(`{"errorDetail":{"message":"unexpected EOF"}}`), "unexpected EOF")
|
||||
require.NoError(t, streamErr(`{"status":"Downloading"}`))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,932 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDocker(t *testing.T) {
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
client, err := GetDockerClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
||||
ContextDir: "testdata",
|
||||
ImageTag: "envmergetest",
|
||||
})
|
||||
|
||||
err = dockerBuild(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
cr := &containerReference{
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "envmergetest",
|
||||
},
|
||||
}
|
||||
env := map[string]string{
|
||||
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin",
|
||||
"RANDOM_VAR": "WITH_VALUE",
|
||||
"ANOTHER_VAR": "",
|
||||
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
||||
}
|
||||
|
||||
envExecutor := cr.extractFromImageEnv(&env)
|
||||
err = envExecutor(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, map[string]string{
|
||||
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin:/this/path/does/not/exists/anywhere:/this/either",
|
||||
"RANDOM_VAR": "WITH_VALUE",
|
||||
"ANOTHER_VAR": "",
|
||||
"SOME_RANDOM_VAR": "",
|
||||
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
||||
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
||||
}, env)
|
||||
}
|
||||
|
||||
type mockDockerClient struct {
|
||||
mobyclient.APIClient
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ExecCreate(ctx context.Context, id string, opts mobyclient.ExecCreateOptions) (mobyclient.ExecCreateResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ExecCreateResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ExecAttach(ctx context.Context, id string, opts mobyclient.ExecAttachOptions) (mobyclient.ExecAttachResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ExecAttachResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ExecInspect(ctx context.Context, execID string, opts mobyclient.ExecInspectOptions) (mobyclient.ExecInspectResult, error) {
|
||||
args := m.Called(ctx, execID, opts)
|
||||
return args.Get(0).(mobyclient.ExecInspectResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerAttach(ctx context.Context, containerID string, opts mobyclient.ContainerAttachOptions) (mobyclient.ContainerAttachResult, error) {
|
||||
args := m.Called(ctx, containerID, opts)
|
||||
return args.Get(0).(mobyclient.ContainerAttachResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerWait(ctx context.Context, containerID string, opts mobyclient.ContainerWaitOptions) mobyclient.ContainerWaitResult {
|
||||
args := m.Called(ctx, containerID, opts)
|
||||
return args.Get(0).(mobyclient.ContainerWaitResult)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, options mobyclient.CopyToContainerOptions) (mobyclient.CopyToContainerResult, error) {
|
||||
args := m.Called(ctx, id, options)
|
||||
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerList(ctx context.Context, opts mobyclient.ContainerListOptions) (mobyclient.ContainerListResult, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
return args.Get(0).(mobyclient.ContainerListResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerRemove(ctx context.Context, id string, opts mobyclient.ContainerRemoveOptions) (mobyclient.ContainerRemoveResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ContainerRemoveResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) ContainerKill(ctx context.Context, id string, opts mobyclient.ContainerKillOptions) (mobyclient.ContainerKillResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.ContainerKillResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.NetworkListOptions) (mobyclient.NetworkListResult, error) {
|
||||
args := m.Called(ctx, opts)
|
||||
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mobyclient.NetworkRemoveOptions) (mobyclient.NetworkRemoveResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1)
|
||||
}
|
||||
|
||||
type endlessReader struct {
|
||||
io.Reader
|
||||
}
|
||||
|
||||
func (r endlessReader) Read(_ []byte) (n int, err error) {
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
type mockConn struct {
|
||||
net.Conn
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockConn) Write(b []byte) (n int, err error) {
|
||||
args := m.Called(b)
|
||||
return args.Int(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockConn) Close() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDockerExecAbort(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
conn := &mockConn{}
|
||||
conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil)
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
||||
HijackedResponse: mobyclient.HijackedResponse{
|
||||
Conn: conn,
|
||||
Reader: bufio.NewReader(endlessReader{}),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
channel := make(chan error)
|
||||
|
||||
go func() {
|
||||
channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
||||
}()
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
cancel()
|
||||
|
||||
err := <-channel
|
||||
assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
conn.AssertExpectations(t)
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerExecFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
conn := &mockConn{}
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||
client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{
|
||||
HijackedResponse: mobyclient.HijackedResponse{
|
||||
Conn: conn,
|
||||
Reader: bufio.NewReader(strings.NewReader("output")),
|
||||
},
|
||||
}, nil)
|
||||
client.On("ExecInspect", ctx, "id", mobyclient.ExecInspectOptions{}).Return(mobyclient.ExecInspectResult{
|
||||
ExitCode: 1,
|
||||
}, nil)
|
||||
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
err := cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx)
|
||||
var exitErr ExitCodeError
|
||||
require.ErrorAs(t, err, &exitErr)
|
||||
assert.Equal(t, ExitCodeError(1), exitErr)
|
||||
assert.Equal(t, "Process completed with exit code 1.", err.Error())
|
||||
|
||||
conn.AssertExpectations(t)
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// stdcopyFrame wraps payload in a single Docker multiplexed-stream frame, the
|
||||
// format StdCopy expects: an 8-byte header (stream type + 4-byte big-endian
|
||||
// length) followed by the payload.
|
||||
func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
||||
b := make([]byte, 8+len(payload))
|
||||
b[0] = byte(stream)
|
||||
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
|
||||
copy(b[8:], payload)
|
||||
return b
|
||||
}
|
||||
|
||||
// TestDockerAttachFlushesTrailingLine verifies that wait() blocks until the
|
||||
// attach() streaming goroutine has drained and flushed the container's output,
|
||||
// so a final line without a trailing newline is not lost.
|
||||
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
||||
|
||||
var lines []string
|
||||
logWriter := common.NewLineWriter(func(s string) bool {
|
||||
lines = append(lines, s)
|
||||
return true
|
||||
})
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerAttach", ctx, "123", mock.AnythingOfType("client.ContainerAttachOptions")).
|
||||
Return(mobyclient.ContainerAttachResult{
|
||||
HijackedResponse: mobyclient.HijackedResponse{
|
||||
Conn: &mockConn{},
|
||||
Reader: bufio.NewReader(framed),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
statusCh := make(chan container.WaitResponse, 1)
|
||||
statusCh <- container.WaitResponse{StatusCode: 0}
|
||||
errCh := make(chan error, 1)
|
||||
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
||||
Return(mobyclient.ContainerWaitResult{
|
||||
Result: (<-chan container.WaitResponse)(statusCh),
|
||||
Error: (<-chan error)(errCh),
|
||||
})
|
||||
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
Stdout: logWriter,
|
||||
Stderr: logWriter,
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cr.attach()(ctx))
|
||||
require.NoError(t, cr.wait()(ctx))
|
||||
|
||||
// wait() must have blocked until the goroutine drained AND flushed; the
|
||||
// trailing, non-newline-terminated line must therefore be present. Reading
|
||||
// lines here is race-free because wait() synchronizes on attachDone, which
|
||||
// the goroutine closes after the final append.
|
||||
assert.Equal(t, []string{"line one\n", "last line without newline"}, lines)
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerWaitFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
statusCh := make(chan container.WaitResponse, 1)
|
||||
statusCh <- container.WaitResponse{StatusCode: 2}
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerWait", ctx, "123", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}).
|
||||
Return(mobyclient.ContainerWaitResult{
|
||||
Result: (<-chan container.WaitResponse)(statusCh),
|
||||
Error: (<-chan error)(errCh),
|
||||
})
|
||||
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
err := cr.wait()(ctx)
|
||||
var exitErr ExitCodeError
|
||||
require.ErrorAs(t, err, &exitErr)
|
||||
assert.Equal(t, ExitCodeError(2), exitErr)
|
||||
assert.Equal(t, "Process completed with exit code 2.", err.Error())
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStream(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
_ = cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// Docker 29.5+ rejects absolute names in the mkdir tarball with
|
||||
// "path escapes from parent", since it is extracted relative to "/".
|
||||
func TestDockerCopyTarStreamMkdirEntryIsRelative(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var mkdirNames []string
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
if opts.DestinationPath != "/" || opts.Content == nil {
|
||||
return false
|
||||
}
|
||||
tr := tar.NewReader(opts.Content)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
mkdirNames = append(mkdirNames, hdr.Name)
|
||||
}
|
||||
return true
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||
assert.Equal(t, []string{"var/run/act"}, mkdirNames)
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStreamErrorInCopyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
merr := errors.New("Failure")
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDockerCopyTarStreamErrorInMkdir(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
merr := errors.New("Failure")
|
||||
|
||||
client := &mockDockerClient{}
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
client.On("CopyToContainer", ctx, "123", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act" && opts.Content != nil
|
||||
})).Return(mobyclient.CopyToContainerResult{}, merr)
|
||||
cr := &containerReference{
|
||||
id: "123",
|
||||
cli: client,
|
||||
input: &NewContainerInput{
|
||||
Image: "image",
|
||||
},
|
||||
}
|
||||
|
||||
err := cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{})
|
||||
assert.ErrorIs(t, err, merr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// A remove that raced the daemon's AutoRemove teardown is not a failure and must not
|
||||
// be logged as one.
|
||||
func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||
removeOpts := mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}
|
||||
killOpts := mobyclient.ContainerKillOptions{Signal: "SIGKILL"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
wantWait bool
|
||||
wantFailure bool
|
||||
}{
|
||||
{name: "removal in progress", err: cerrdefs.ErrConflict.WithMessage("removal of container abc is already in progress"), wantWait: true},
|
||||
{name: "already removed", err: cerrdefs.ErrNotFound.WithMessage("No such container: abc")},
|
||||
{name: "removed cleanly", err: nil},
|
||||
{name: "real failure", err: errors.New("driver failed to remove root filesystem"), wantFailure: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||
if tc.wantWait {
|
||||
removed := make(chan container.WaitResponse, 1)
|
||||
removed <- container.WaitResponse{}
|
||||
client.On("ContainerWait", mock.Anything, "abc", mobyclient.ContainerWaitOptions{Condition: container.WaitConditionRemoved}).
|
||||
Return(mobyclient.ContainerWaitResult{Result: removed})
|
||||
}
|
||||
cr := &containerReference{id: "abc", cli: client}
|
||||
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
// a failure keeps the id, so a later Remove() can retry it
|
||||
if tc.wantFailure {
|
||||
assert.Equal(t, "abc", cr.id)
|
||||
assert.Len(t, hook.AllEntries(), 1)
|
||||
} else {
|
||||
assert.Empty(t, cr.id)
|
||||
assert.Empty(t, hook.AllEntries())
|
||||
}
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A container whose id was never learned, because find() could not reach the daemon or
|
||||
// create() lost its reply, must still be removed rather than leaking with its network. It
|
||||
// was never started here, so it is not worth a kill of its own.
|
||||
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
|
||||
require.NoError(t, cr.remove()(ctx))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
||||
// daemon with a torn-down container.
|
||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
||||
boom := errors.New("daemon unreachable")
|
||||
newCR := func(id string) (*containerReference, *mockDockerClient) {
|
||||
client := &mockDockerClient{}
|
||||
return &containerReference{id: id, cli: client, input: &NewContainerInput{Name: "job-1"}}, client
|
||||
}
|
||||
listOpts := mobyclient.ContainerListOptions{All: true}
|
||||
inspectOpts := mobyclient.ContainerInspectOptions{}
|
||||
|
||||
t.Run("stale id cleared, name lookup empty", func(t *testing.T) {
|
||||
cr, client := newCR("stale")
|
||||
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
|
||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, nil)
|
||||
require.NoError(t, cr.find()(ctx))
|
||||
assert.Empty(t, cr.id)
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("stale id cleared, name lookup repopulates", func(t *testing.T) {
|
||||
cr, client := newCR("stale")
|
||||
client.On("ContainerInspect", ctx, "stale", inspectOpts).Return(mobyclient.ContainerInspectResult{}, notFound)
|
||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
||||
{ID: "other", Names: []string{"/somebody-else"}},
|
||||
{ID: "fresh", Names: []string{"/job-1"}},
|
||||
}}, nil)
|
||||
require.NoError(t, cr.find()(ctx))
|
||||
assert.Equal(t, "fresh", cr.id)
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("live id kept", func(t *testing.T) {
|
||||
cr, client := newCR("live")
|
||||
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, nil)
|
||||
require.NoError(t, cr.find()(ctx))
|
||||
assert.Equal(t, "live", cr.id)
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("transient inspect error trusts cache", func(t *testing.T) {
|
||||
cr, client := newCR("live")
|
||||
client.On("ContainerInspect", ctx, "live", inspectOpts).Return(mobyclient.ContainerInspectResult{}, boom)
|
||||
require.NoError(t, cr.find()(ctx))
|
||||
assert.Equal(t, "live", cr.id)
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("list error propagates", func(t *testing.T) {
|
||||
cr, client := newCR("")
|
||||
client.On("ContainerList", ctx, listOpts).Return(mobyclient.ContainerListResult{}, boom)
|
||||
require.ErrorIs(t, cr.find()(ctx), boom)
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Every daemon entry point fails fast with a clear, container-named
|
||||
// error when no live cr.id is known.
|
||||
func TestRejectsMissingContainer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
|
||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
check := func(op string, err error) {
|
||||
t.Helper()
|
||||
require.ErrorIs(t, err, ErrContainerNotFound, op)
|
||||
assert.Contains(t, err.Error(), `container "job-1" does not exist`, op)
|
||||
}
|
||||
check("copyContent", cr.copyContent("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||
check("copyDir", cr.copyDir("/var/run/act", "/src", false)(ctx))
|
||||
check("CopyTarStream", cr.CopyTarStream(ctx, "/var/run/act", &bytes.Buffer{}))
|
||||
check("exec", cr.exec([]string{"echo"}, nil, "", "")(ctx))
|
||||
_, err := cr.GetContainerArchive(ctx, "/var/run/act/x")
|
||||
check("GetContainerArchive", err)
|
||||
_, err = cr.Inspect(ctx)
|
||||
check("Inspect", err)
|
||||
|
||||
// a known id the daemon has since dropped
|
||||
client.On("ContainerInspect", ctx, "gone", mobyclient.ContainerInspectOptions{}).
|
||||
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound)
|
||||
removed := &containerReference{id: "gone", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
_, err = removed.Inspect(ctx)
|
||||
check("Inspect after removal", err)
|
||||
}
|
||||
|
||||
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
|
||||
// and the Copy completes against the fresh id.
|
||||
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
|
||||
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
|
||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).
|
||||
Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
||||
{ID: "fresh", Names: []string{"/job-1"}},
|
||||
}}, nil)
|
||||
client.On("CopyToContainer", ctx, "fresh", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act"
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
|
||||
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||
assert.Equal(t, "fresh", cr.id)
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestDockerCopyToSymlinkPath is a regression test for gitea/runner#981. Most base images
|
||||
// symlink /var/run to /run, so copying into /var/run/act traverses that symlink. The broken
|
||||
// docker 29.5.1 daemon fails the extraction with "mkdirat var/run: file exists" (fixed in
|
||||
// 29.5.2). Running against the daemon shipped in the dind image, this catches a bad bump.
|
||||
func TestDockerCopyToSymlinkPath(t *testing.T) {
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
|
||||
rc := NewContainer(&NewContainerInput{
|
||||
Image: "alpine:latest",
|
||||
Entrypoint: []string{"sleep", "30"},
|
||||
Name: "act-test-symlink-" + time.Now().Format("20060102150405.000000"),
|
||||
AutoRemove: true,
|
||||
})
|
||||
require.NoError(t, rc.Pull(false)(ctx))
|
||||
require.NoError(t, rc.Create(nil, nil)(ctx))
|
||||
require.NoError(t, rc.Start(false)(ctx))
|
||||
t.Cleanup(func() {
|
||||
_ = rc.Remove()(ctx)
|
||||
_ = rc.Close()(ctx)
|
||||
})
|
||||
|
||||
// CopyTarStream first creates the destination directory by extracting a tar at "/",
|
||||
// which makes the daemon mkdir var, then var/run (the symlink), then act — the exact
|
||||
// step that fails on the broken daemon.
|
||||
err := rc.CopyTarStream(ctx, "/var/run/act/actions/", &bytes.Buffer{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Type assert containerReference implements ExecutionsEnvironment
|
||||
var _ ExecutionsEnvironment = &containerReference{}
|
||||
|
||||
func TestCheckVolumes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
validVolumes []string
|
||||
binds []string
|
||||
expectedBinds []string
|
||||
}{
|
||||
{
|
||||
desc: "match all volumes",
|
||||
validVolumes: []string{"**"},
|
||||
binds: []string{
|
||||
"shared_volume:/shared_volume",
|
||||
"/home/test/data:/test_data",
|
||||
"/etc/conf.d/base.json:/config/base.json",
|
||||
"sql_data:/sql_data",
|
||||
"/secrets/keys:/keys",
|
||||
},
|
||||
expectedBinds: []string{
|
||||
"shared_volume:/shared_volume",
|
||||
"/home/test/data:/test_data",
|
||||
"/etc/conf.d/base.json:/config/base.json",
|
||||
"sql_data:/sql_data",
|
||||
"/secrets/keys:/keys",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "no volumes can be matched",
|
||||
validVolumes: []string{},
|
||||
binds: []string{
|
||||
"shared_volume:/shared_volume",
|
||||
"/home/test/data:/test_data",
|
||||
"/etc/conf.d/base.json:/config/base.json",
|
||||
"sql_data:/sql_data",
|
||||
"/secrets/keys:/keys",
|
||||
},
|
||||
expectedBinds: []string{},
|
||||
},
|
||||
{
|
||||
desc: "only allowed volumes can be matched",
|
||||
validVolumes: []string{
|
||||
"shared_volume",
|
||||
"/home/test/data",
|
||||
"/etc/conf.d/*.json",
|
||||
},
|
||||
binds: []string{
|
||||
"shared_volume:/shared_volume",
|
||||
"/home/test/data:/test_data",
|
||||
"/etc/conf.d/base.json:/config/base.json",
|
||||
"sql_data:/sql_data",
|
||||
"/secrets/keys:/keys",
|
||||
},
|
||||
expectedBinds: []string{
|
||||
"shared_volume:/shared_volume",
|
||||
"/home/test/data:/test_data",
|
||||
"/etc/conf.d/base.json:/config/base.json",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
ValidVolumes: tc.validVolumes,
|
||||
},
|
||||
}
|
||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
|
||||
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
|
||||
dangerous := func() *container.HostConfig {
|
||||
return &container.HostConfig{
|
||||
PidMode: "host",
|
||||
IpcMode: "host",
|
||||
UTSMode: "host",
|
||||
CgroupnsMode: "host",
|
||||
UsernsMode: "host",
|
||||
CapAdd: []string{"ALL"},
|
||||
SecurityOpt: []string{"seccomp=unconfined", "apparmor=unconfined"},
|
||||
VolumesFrom: []string{"other"},
|
||||
Runtime: "runc",
|
||||
Resources: container.Resources{
|
||||
CgroupParent: "/custom",
|
||||
Devices: []container.DeviceMapping{{PathOnHost: "/dev/sda", PathInContainer: "/dev/sda", CgroupPermissions: "rwm"}},
|
||||
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||
},
|
||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||
}
|
||||
}
|
||||
|
||||
hostConfig := dangerous()
|
||||
sanitizeOptionsHostConfig(logger, hostConfig)
|
||||
|
||||
assert.Empty(t, string(hostConfig.PidMode))
|
||||
assert.Empty(t, string(hostConfig.IpcMode))
|
||||
assert.Empty(t, string(hostConfig.UTSMode))
|
||||
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||
assert.Empty(t, string(hostConfig.UsernsMode))
|
||||
assert.Empty(t, hostConfig.CapAdd)
|
||||
assert.Empty(t, hostConfig.SecurityOpt)
|
||||
assert.Empty(t, hostConfig.Devices)
|
||||
assert.Empty(t, hostConfig.DeviceCgroupRules)
|
||||
assert.Empty(t, hostConfig.VolumesFrom)
|
||||
assert.Empty(t, hostConfig.Runtime)
|
||||
assert.Empty(t, hostConfig.CgroupParent)
|
||||
assert.Empty(t, hostConfig.Sysctls)
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.T) {
|
||||
// OS-independent options only: --device parsing requires a linux/windows
|
||||
// server OS, which is not guaranteed for the test host.
|
||||
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||
"--security-opt apparmor=unconfined --volumes-from other " +
|
||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
||||
|
||||
t.Run("unprivileged strips host-escape options", func(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
Options: dangerousOptions,
|
||||
NetworkMode: "bridge",
|
||||
UsernsMode: "private",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Privileged: false,
|
||||
UsernsMode: container.UsernsMode("private"),
|
||||
NetworkMode: container.NetworkMode("bridge"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, hostConfig.Privileged)
|
||||
assert.Empty(t, string(hostConfig.PidMode))
|
||||
assert.Empty(t, string(hostConfig.IpcMode))
|
||||
assert.Empty(t, string(hostConfig.UTSMode))
|
||||
assert.Empty(t, string(hostConfig.CgroupnsMode))
|
||||
// UsernsMode must keep the runner-controlled value, not the one from options.
|
||||
assert.Equal(t, "private", string(hostConfig.UsernsMode))
|
||||
assert.Empty(t, hostConfig.CapAdd)
|
||||
assert.Empty(t, hostConfig.SecurityOpt)
|
||||
assert.Empty(t, hostConfig.VolumesFrom)
|
||||
assert.Empty(t, hostConfig.Runtime)
|
||||
assert.Empty(t, hostConfig.CgroupParent)
|
||||
assert.Empty(t, hostConfig.Sysctls)
|
||||
})
|
||||
|
||||
t.Run("privileged preserves options", func(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
Options: "--pid=host --cap-add=ALL --security-opt seccomp=unconfined",
|
||||
NetworkMode: "bridge",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConfig, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Privileged: true,
|
||||
NetworkMode: container.NetworkMode("bridge"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "host", string(hostConfig.PidMode))
|
||||
assert.Equal(t, []string{"ALL"}, hostConfig.CapAdd)
|
||||
assert.Equal(t, []string{"seccomp=unconfined"}, hostConfig.SecurityOpt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
|
||||
base := t.TempDir()
|
||||
allowed := filepath.Join(base, "allowed")
|
||||
denied := filepath.Join(base, "denied")
|
||||
require.NoError(t, os.MkdirAll(allowed, 0o700))
|
||||
require.NoError(t, os.MkdirAll(denied, 0o700))
|
||||
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
ValidVolumes: []string{filepath.Join(allowed, "**")},
|
||||
},
|
||||
}
|
||||
|
||||
escapingPath := allowed + string(filepath.Separator) + ".." + string(filepath.Separator) + "denied"
|
||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
||||
Binds: []string{escapingPath + ":/mnt"},
|
||||
})
|
||||
assert.Empty(t, hostConf.Binds)
|
||||
|
||||
linkPath := filepath.Join(allowed, "link")
|
||||
if err := os.Symlink(denied, linkPath); err != nil {
|
||||
t.Skipf("cannot create symlink: %v", err)
|
||||
}
|
||||
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
||||
Binds: []string{linkPath + ":/mnt"},
|
||||
})
|
||||
assert.Empty(t, hostConf.Binds)
|
||||
|
||||
_, hostConf = cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{
|
||||
Binds: []string{filepath.Join(linkPath, "missing") + ":/mnt"},
|
||||
})
|
||||
assert.Empty(t, hostConf.Binds)
|
||||
}
|
||||
|
||||
func TestContainerInfoFromInspect(t *testing.T) {
|
||||
t.Run("reports no healthcheck when the image declares none", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{
|
||||
ID: "abc123",
|
||||
State: &container.State{Status: "running", Running: true},
|
||||
})
|
||||
|
||||
assert.Equal(t, "abc123", info.ID)
|
||||
assert.Equal(t, "running", info.State)
|
||||
assert.Equal(t, HealthNone, info.Health)
|
||||
assert.Empty(t, info.Ports)
|
||||
})
|
||||
|
||||
t.Run("reports the health status and the last probe output", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{
|
||||
State: &container.State{
|
||||
Status: "running",
|
||||
Health: &container.Health{
|
||||
Status: container.Unhealthy,
|
||||
Log: []*container.HealthcheckResult{
|
||||
{Output: "first\n"},
|
||||
{Output: "connection refused\n"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, HealthUnhealthy, info.Health)
|
||||
assert.Equal(t, "connection refused", info.HealthOutput)
|
||||
})
|
||||
|
||||
t.Run("reports the published ports", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{
|
||||
State: &container.State{Status: "running"},
|
||||
NetworkSettings: &container.NetworkSettings{
|
||||
Ports: network.PortMap{
|
||||
network.MustParsePort("5432/tcp"): []network.PortBinding{{HostPort: "49153"}},
|
||||
network.MustParsePort("6379/tcp"): nil,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, map[string]string{"5432": "49153"}, info.Ports)
|
||||
})
|
||||
|
||||
t.Run("tolerates a container without state", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{ID: "abc123"})
|
||||
|
||||
assert.Equal(t, "abc123", info.ID)
|
||||
assert.Equal(t, HealthNone, info.Health)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
NetworkMode: "bridge",
|
||||
Options: "--volume /host/tools:/opt/hostedtoolcache",
|
||||
},
|
||||
}
|
||||
|
||||
_, hostConf, err := cr.mergeContainerConfigs(ctx, &container.Config{}, &container.HostConfig{
|
||||
Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"},
|
||||
Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "act-toolcache", Target: "/opt/hostedtoolcache"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"/var/run/docker.sock:/var/run/docker.sock", "/host/tools:/opt/hostedtoolcache"}, hostConf.Binds)
|
||||
assert.Empty(t, hostConf.Mounts)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var CommonSocketLocations = []string{
|
||||
"/var/run/docker.sock",
|
||||
"/run/podman/podman.sock",
|
||||
"$HOME/.colima/docker.sock",
|
||||
"$XDG_RUNTIME_DIR/docker.sock",
|
||||
"$XDG_RUNTIME_DIR/podman/podman.sock",
|
||||
`\\.\pipe\docker_engine`,
|
||||
"$HOME/.docker/run/docker.sock",
|
||||
}
|
||||
|
||||
// returns socket URI or false if not found any
|
||||
func socketLocation() (string, bool) {
|
||||
if dockerHost, exists := os.LookupEnv("DOCKER_HOST"); exists {
|
||||
return dockerHost, true
|
||||
}
|
||||
|
||||
for _, p := range CommonSocketLocations {
|
||||
if _, err := os.Lstat(os.ExpandEnv(p)); err == nil {
|
||||
if strings.HasPrefix(p, `\\.\`) {
|
||||
return "npipe://" + filepath.ToSlash(os.ExpandEnv(p)), true
|
||||
}
|
||||
return "unix://" + filepath.ToSlash(os.ExpandEnv(p)), true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// This function, `isDockerHostURI`, takes a string argument `daemonPath`. It checks if the
|
||||
// `daemonPath` is a valid Docker host URI. It does this by checking if the scheme of the URI (the
|
||||
// part before "://") contains only alphabetic characters. If it does, the function returns true,
|
||||
// indicating that the `daemonPath` is a Docker host URI. If it doesn't, or if the "://" delimiter
|
||||
// is not found in the `daemonPath`, the function returns false.
|
||||
func isDockerHostURI(daemonPath string) bool {
|
||||
if before, _, ok := strings.Cut(daemonPath, "://"); ok {
|
||||
scheme := before
|
||||
if strings.IndexFunc(scheme, func(r rune) bool {
|
||||
return (r < 'a' || r > 'z') && (r < 'A' || r > 'Z')
|
||||
}) == -1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type SocketAndHost struct {
|
||||
Socket string
|
||||
Host string
|
||||
}
|
||||
|
||||
func GetSocketAndHost(containerSocket string) (SocketAndHost, error) {
|
||||
log.Debugf("Handling container host and socket")
|
||||
|
||||
// Prefer DOCKER_HOST, don't override it
|
||||
dockerHost, hasDockerHost := socketLocation()
|
||||
socketHost := SocketAndHost{Socket: containerSocket, Host: dockerHost}
|
||||
|
||||
// ** socketHost.Socket cases **
|
||||
// Case 1: User does _not_ want to mount a daemon socket (passes a dash)
|
||||
// Case 2: User passes a filepath to the socket; is that even valid?
|
||||
// Case 3: User passes a valid socket; do nothing
|
||||
// Case 4: User omitted the flag; set a sane default
|
||||
|
||||
// ** DOCKER_HOST cases **
|
||||
// Case A: DOCKER_HOST is set; use it, i.e. do nothing
|
||||
// Case B: DOCKER_HOST is empty; use sane defaults
|
||||
|
||||
// Set host for sanity's sake, when the socket isn't useful
|
||||
if !hasDockerHost && (socketHost.Socket == "-" || !isDockerHostURI(socketHost.Socket) || socketHost.Socket == "") {
|
||||
// Cases: 1B, 2B, 4B
|
||||
socket, found := socketLocation()
|
||||
socketHost.Host = socket
|
||||
hasDockerHost = found
|
||||
}
|
||||
|
||||
// A - (dash) in socketHost.Socket means don't mount, preserve this value
|
||||
// otherwise if socketHost.Socket is a filepath don't use it as socket
|
||||
// Exit early if we're in an invalid state (e.g. when no DOCKER_HOST and user supplied "-", a dash or omitted)
|
||||
if !hasDockerHost && socketHost.Socket != "" && !isDockerHostURI(socketHost.Socket) {
|
||||
// Cases: 1B, 2B
|
||||
// Should we early-exit here, since there is no host nor socket to talk to?
|
||||
return SocketAndHost{}, fmt.Errorf("DOCKER_HOST was not set, couldn't be found in the usual locations, and the container daemon socket ('%s') is invalid", socketHost.Socket)
|
||||
}
|
||||
|
||||
// Default to DOCKER_HOST if set
|
||||
if socketHost.Socket == "" && hasDockerHost {
|
||||
// Cases: 4A
|
||||
log.Debugf("Defaulting container socket to DOCKER_HOST")
|
||||
socketHost.Socket = socketHost.Host
|
||||
}
|
||||
// Set sane default socket location if user omitted it
|
||||
if socketHost.Socket == "" {
|
||||
// Cases: 4B
|
||||
socket, _ := socketLocation()
|
||||
// socket is empty if it isn't found, so assignment here is at worst a no-op
|
||||
log.Debugf("Defaulting container socket to default '%s'", socket)
|
||||
socketHost.Socket = socket
|
||||
}
|
||||
|
||||
// Exit if both the DOCKER_HOST and socket are fulfilled
|
||||
if hasDockerHost {
|
||||
// Cases: 1A, 2A, 3A, 4A
|
||||
if !isDockerHostURI(socketHost.Socket) {
|
||||
// Cases: 1A, 2A
|
||||
log.Debugf("DOCKER_HOST is set, but socket is invalid '%s'", socketHost.Socket)
|
||||
}
|
||||
return socketHost, nil
|
||||
}
|
||||
|
||||
// Set a sane DOCKER_HOST default if we can
|
||||
if isDockerHostURI(socketHost.Socket) {
|
||||
// Cases: 3B
|
||||
log.Debugf("Setting DOCKER_HOST to container socket '%s'", socketHost.Socket)
|
||||
socketHost.Host = socketHost.Socket
|
||||
// Both DOCKER_HOST and container socket are valid; short-circuit exit
|
||||
return socketHost, nil
|
||||
}
|
||||
|
||||
// Here there is no DOCKER_HOST _and_ the supplied container socket is not a valid URI (either invalid or a file path)
|
||||
// Cases: 2B <- but is already handled at the top
|
||||
// I.e. this path should never be taken
|
||||
return SocketAndHost{}, fmt.Errorf("no DOCKER_HOST and an invalid container socket '%s'", socketHost.Socket)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2024 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
}
|
||||
|
||||
var originalCommonSocketLocations = CommonSocketLocations
|
||||
|
||||
func isolateSocketEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() { CommonSocketLocations = originalCommonSocketLocations })
|
||||
if host, ok := os.LookupEnv("DOCKER_HOST"); ok {
|
||||
t.Setenv("DOCKER_HOST", host)
|
||||
} else {
|
||||
t.Cleanup(func() { os.Unsetenv("DOCKER_HOST") })
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSocketAndHostWithSocket(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
dockerHost := "unix:///my/docker/host.sock"
|
||||
socketURI := "/path/to/my.socket"
|
||||
t.Setenv("DOCKER_HOST", dockerHost)
|
||||
|
||||
// Act
|
||||
ret, err := GetSocketAndHost(socketURI)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, SocketAndHost{socketURI, dockerHost}, ret)
|
||||
}
|
||||
|
||||
func TestGetSocketAndHostNoSocket(t *testing.T) {
|
||||
// Arrange
|
||||
dockerHost := "unix:///my/docker/host.sock"
|
||||
t.Setenv("DOCKER_HOST", dockerHost)
|
||||
|
||||
// Act
|
||||
ret, err := GetSocketAndHost("")
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, SocketAndHost{dockerHost, dockerHost}, ret)
|
||||
}
|
||||
|
||||
func TestGetSocketAndHostOnlySocket(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
socketURI := "/path/to/my.socket"
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
defaultSocket, defaultSocketFound := socketLocation()
|
||||
|
||||
// Act
|
||||
ret, err := GetSocketAndHost(socketURI)
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.True(t, defaultSocketFound, "Expected to find default socket")
|
||||
assert.Equal(t, socketURI, ret.Socket, "Expected socket to match common location")
|
||||
assert.Equal(t, defaultSocket, ret.Host, "Expected ret.Host to match default socket location")
|
||||
}
|
||||
|
||||
func TestGetSocketAndHostDontMount(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
dockerHost := "unix:///my/docker/host.sock"
|
||||
t.Setenv("DOCKER_HOST", dockerHost)
|
||||
|
||||
// Act
|
||||
ret, err := GetSocketAndHost("-")
|
||||
|
||||
// Assert
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, SocketAndHost{"-", dockerHost}, ret)
|
||||
}
|
||||
|
||||
func TestGetSocketAndHostNoHostNoSocket(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
defaultSocket, found := socketLocation()
|
||||
|
||||
// Act
|
||||
ret, err := GetSocketAndHost("")
|
||||
|
||||
// Assert
|
||||
assert.True(t, found, "Expected a default socket to be found")
|
||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, SocketAndHost{defaultSocket, defaultSocket}, ret, "Expected to match default socket location")
|
||||
}
|
||||
|
||||
// Catch
|
||||
// > Your code breaks setting DOCKER_HOST if shouldMount is false.
|
||||
// > This happens if neither DOCKER_HOST nor --container-daemon-socket has a value, but socketLocation() returns a URI
|
||||
func TestGetSocketAndHostNoHostNoSocketDefaultLocation(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
mySocketFile, tmpErr := os.CreateTemp(t.TempDir(), "act-*.sock")
|
||||
mySocket := mySocketFile.Name()
|
||||
unixSocket := "unix://" + mySocket
|
||||
defer os.RemoveAll(mySocket)
|
||||
assert.NoError(t, tmpErr) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
|
||||
CommonSocketLocations = []string{mySocket}
|
||||
defaultSocket, found := socketLocation()
|
||||
|
||||
// Act
|
||||
ret, err := GetSocketAndHost("")
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, unixSocket, defaultSocket, "Expected default socket to match common socket location")
|
||||
assert.True(t, found, "Expected default socket to be found")
|
||||
assert.NoError(t, err, "Expected no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, SocketAndHost{unixSocket, unixSocket}, ret, "Expected to match default socket location")
|
||||
}
|
||||
|
||||
func TestGetSocketAndHostNoHostInvalidSocket(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
mySocket := "/my/socket/path.sock"
|
||||
CommonSocketLocations = []string{"/unusual", "/socket", "/location"}
|
||||
defaultSocket, found := socketLocation()
|
||||
|
||||
// Act
|
||||
ret, err := GetSocketAndHost(mySocket)
|
||||
|
||||
// Assert
|
||||
assert.False(t, found, "Expected no default socket to be found")
|
||||
assert.Equal(t, "", defaultSocket, "Expected no default socket to be found") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, SocketAndHost{}, ret, "Expected to match default socket location")
|
||||
assert.Error(t, err, "Expected an error in invalid state")
|
||||
}
|
||||
|
||||
func TestGetSocketAndHostOnlySocketValidButUnusualLocation(t *testing.T) {
|
||||
// Arrange
|
||||
isolateSocketEnv(t)
|
||||
socketURI := "unix:///path/to/my.socket"
|
||||
CommonSocketLocations = []string{"/unusual", "/location"}
|
||||
os.Unsetenv("DOCKER_HOST")
|
||||
defaultSocket, found := socketLocation()
|
||||
|
||||
// Act
|
||||
ret, err := GetSocketAndHost(socketURI)
|
||||
|
||||
// Assert
|
||||
// Default socket locations
|
||||
assert.Equal(t, "", defaultSocket, "Expect default socket location to be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.False(t, found, "Expected no default socket to be found")
|
||||
// Sane default
|
||||
assert.NoError(t, err, "Expect no error from GetSocketAndHost") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, socketURI, ret.Host, "Expect host to default to unusual socket")
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2023 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"github.com/moby/moby/api/types/system"
|
||||
)
|
||||
|
||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||
// requested name, tag and architecture exists in the local docker image store
|
||||
func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool, error) {
|
||||
return false, errors.New("Unsupported Operation")
|
||||
}
|
||||
|
||||
// RemoveImage removes image from local store, the function is used to run different
|
||||
// container image architectures
|
||||
func RemoveImage(ctx context.Context, imageName string, force, pruneChildren bool) (bool, error) {
|
||||
return false, errors.New("Unsupported Operation")
|
||||
}
|
||||
|
||||
// NewDockerBuildExecutor function to create a run executor for the container
|
||||
func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return errors.New("Unsupported Operation")
|
||||
}
|
||||
}
|
||||
|
||||
// NewDockerPullExecutor function to create a run executor for the container
|
||||
func NewDockerPullExecutor(input NewDockerPullExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return errors.New("Unsupported Operation")
|
||||
}
|
||||
}
|
||||
|
||||
// NewContainer creates a reference to a container
|
||||
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||
return nil
|
||||
}
|
||||
|
||||
func RunnerArch(ctx context.Context) string {
|
||||
return runtime.GOOS
|
||||
}
|
||||
|
||||
func GetHostInfo(ctx context.Context) (info system.Info, err error) {
|
||||
return system.Info{}, nil
|
||||
}
|
||||
|
||||
func NewDockerVolumeRemoveExecutor(volume string, force bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveOrphanNetworks(ctx context.Context, runnerUUID string, createdBefore time.Time) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
func NewDockerVolumeRemoveExecutor(volumeName string, force bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
list, err := cli.VolumeList(ctx, client.VolumeListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, vol := range list.Items {
|
||||
if vol.Name == volumeName {
|
||||
return removeExecutor(volumeName, force)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Volume not found - do nothing
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func removeExecutor(volume string, force bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
logger.Debugf("docker volume rm %s", volume)
|
||||
|
||||
if common.Dryrun(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
_, err = cli.VolumeRemove(ctx, volume, client.VolumeRemoveOptions{Force: force})
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import "context"
|
||||
|
||||
type ExecutionsEnvironment interface {
|
||||
Container
|
||||
ToContainerPath(string) string
|
||||
GetActPath() string
|
||||
GetPathVariableName() string
|
||||
DefaultPathVariable() string
|
||||
JoinPathVariable(...string) string
|
||||
GetRunnerContext(ctx context.Context) map[string]any
|
||||
// On windows PATH and Path are the same key
|
||||
IsEnvironmentCaseInsensitive() bool
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// requireDocker skips the test unless a reachable docker daemon is available.
|
||||
// GetDockerClient succeeds even without a running daemon (its ping is best-effort),
|
||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
||||
func requireDocker(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
|
||||
t.Skipf("skipping: docker daemon unreachable: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
"gitea.com/gitea/runner/internal/act/filecollector"
|
||||
"gitea.com/gitea/runner/internal/act/lookpath"
|
||||
"gitea.com/gitea/runner/internal/pkg/process"
|
||||
|
||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type HostEnvironment struct {
|
||||
Path string
|
||||
TmpDir string
|
||||
ToolCache string
|
||||
Workdir string
|
||||
// CleanWorkdir means teardown owns Workdir and may delete it. Leave false
|
||||
// when Workdir points at a caller-owned checkout (e.g. `act` local mode).
|
||||
CleanWorkdir bool
|
||||
ActPath string
|
||||
CleanUp func()
|
||||
StdOut io.Writer
|
||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||
|
||||
// procGroup owns every process the job's steps start. Atomic: Remove may read
|
||||
// it while a step is still starting.
|
||||
procGroupOnce sync.Once
|
||||
procGroup atomic.Pointer[process.Group]
|
||||
}
|
||||
|
||||
// processGroup returns the job-scoped process group, creating it on first use.
|
||||
// Returns nil if the job object could not be created; Group is nil-safe.
|
||||
func (e *HostEnvironment) processGroup(ctx context.Context) *process.Group {
|
||||
e.procGroupOnce.Do(func() {
|
||||
group, err := process.NewGroup()
|
||||
if err != nil {
|
||||
common.Logger(ctx).Warnf("could not create the job's process group; processes a step leaves behind can only be reclaimed by the workspace scan: %v", err)
|
||||
return
|
||||
}
|
||||
e.procGroup.Store(group)
|
||||
})
|
||||
return e.procGroup.Load()
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Create(_, _ []string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) ConnectToNetwork(name string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Close() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Copy(destPath string, files ...*FileEntry) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
for _, f := range files {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(destPath, f.Name)), 0o777); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(destPath, f.Name), []byte(f.Body), fs.FileMode(f.Mode)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
||||
if err := os.RemoveAll(destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(tarStream)
|
||||
cp := &filecollector.CopyCollector{
|
||||
DstDir: destPath,
|
||||
}
|
||||
for {
|
||||
ti, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if ti.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return errors.New("CopyTarStream has been cancelled")
|
||||
}
|
||||
if err := cp.WriteFile(ti.Name, ti.FileInfo(), ti.Linkname, tr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
srcPrefix := filepath.Dir(srcPath)
|
||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||
srcPrefix += string(filepath.Separator)
|
||||
}
|
||||
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
||||
var ignorer gitignore.Matcher
|
||||
if useGitIgnore {
|
||||
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
||||
if err != nil {
|
||||
logger.Debugf("Error loading .gitignore: %v", err)
|
||||
}
|
||||
|
||||
ignorer = gitignore.NewMatcher(ps)
|
||||
}
|
||||
fc := &filecollector.FileCollector{
|
||||
Fs: &filecollector.DefaultFs{},
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Handler: &filecollector.CopyCollector{
|
||||
DstDir: destPath,
|
||||
},
|
||||
}
|
||||
return filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) DumpLogs(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Inspect(_ context.Context) (*Info, error) {
|
||||
return &Info{Health: HealthNone, Ports: map[string]string{}}, nil
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
tw := tar.NewWriter(buf)
|
||||
defer tw.Close()
|
||||
srcPath = filepath.Clean(srcPath)
|
||||
fi, err := os.Lstat(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc := &filecollector.TarCollector{
|
||||
TarWriter: tw,
|
||||
}
|
||||
if fi.IsDir() {
|
||||
srcPrefix := srcPath
|
||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||
srcPrefix += string(filepath.Separator)
|
||||
}
|
||||
fc := &filecollector.FileCollector{
|
||||
Fs: &filecollector.DefaultFs{},
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
Handler: tc,
|
||||
}
|
||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
var f io.ReadCloser
|
||||
var linkname string
|
||||
if fi.Mode()&fs.ModeSymlink != 0 {
|
||||
linkname, err = os.Readlink(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
f, err = os.Open(srcPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
}
|
||||
err := tc.WriteFile(fi.Name(), fi, linkname, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return io.NopCloser(buf), nil
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Pull(_ bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Start(_ bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type ptyWriter struct {
|
||||
Out io.Writer
|
||||
AutoStop atomic.Bool
|
||||
dirtyLine bool
|
||||
}
|
||||
|
||||
func (w *ptyWriter) Write(buf []byte) (int, error) {
|
||||
if w.AutoStop.Load() && len(buf) > 0 && buf[len(buf)-1] == 4 {
|
||||
n, err := w.Out.Write(buf[:len(buf)-1])
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if w.dirtyLine || len(buf) > 1 && buf[len(buf)-2] != '\n' {
|
||||
_, _ = w.Out.Write([]byte("\n"))
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, io.EOF
|
||||
}
|
||||
w.dirtyLine = strings.LastIndex(string(buf), "\n") < len(buf)-1
|
||||
return w.Out.Write(buf)
|
||||
}
|
||||
|
||||
type localEnv struct {
|
||||
env map[string]string
|
||||
}
|
||||
|
||||
func (l *localEnv) Getenv(name string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
for k, v := range l.env {
|
||||
if strings.EqualFold(name, k) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return l.env[name]
|
||||
}
|
||||
|
||||
func lookupPathHost(cmd string, env map[string]string, writer io.Writer) (string, error) {
|
||||
f, err := lookpath.LookPath2(cmd, &localEnv{env: env})
|
||||
if err != nil {
|
||||
err := "Cannot find: " + cmd + " in PATH"
|
||||
if _, _err := writer.Write([]byte(err + "\n")); _err != nil {
|
||||
return "", fmt.Errorf("%v: %w", err, _err)
|
||||
}
|
||||
return "", errors.New(err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func setupPty(cmd *exec.Cmd, cmdline string) (*os.File, *os.File, error) {
|
||||
ppty, tty, err := openPty()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if term.IsTerminal(int(tty.Fd())) {
|
||||
_, err := term.MakeRaw(int(tty.Fd()))
|
||||
if err != nil {
|
||||
ppty.Close()
|
||||
tty.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
cmd.Stdin = tty
|
||||
cmd.Stdout = tty
|
||||
cmd.Stderr = tty
|
||||
cmd.SysProcAttr = process.SysProcAttr(cmdline, true)
|
||||
return ppty, tty, nil
|
||||
}
|
||||
|
||||
func writeKeepAlive(ppty io.Writer) {
|
||||
c := 1
|
||||
var err error
|
||||
for c == 1 && err == nil {
|
||||
c, err = ppty.Write([]byte{4})
|
||||
<-time.After(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func copyPtyOutput(writer io.Writer, ppty io.Reader, finishLog context.CancelFunc) {
|
||||
defer func() {
|
||||
finishLog()
|
||||
}()
|
||||
if _, err := io.Copy(writer, ppty); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) UpdateFromImageEnv(_ *map[string]string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvListFromMap(env map[string]string) []string {
|
||||
envList := make([]string, 0)
|
||||
for k, v := range env {
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
return envList
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline string, env map[string]string, _, workdir string) error {
|
||||
envList := getEnvListFromMap(env)
|
||||
var wd string
|
||||
if workdir != "" {
|
||||
if filepath.IsAbs(workdir) {
|
||||
wd = workdir
|
||||
} else {
|
||||
wd = filepath.Join(e.Path, workdir)
|
||||
}
|
||||
} else {
|
||||
wd = e.Path
|
||||
}
|
||||
// Flush any buffered, not-yet-newline-terminated trailing line, as the docker backend
|
||||
// does in waitForCommand, so the final line of a command's output is not lost.
|
||||
defer common.FlushWriter(e.StdOut)
|
||||
|
||||
f, err := lookupPathHost(command[0], env, e.StdOut)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, f)
|
||||
cmd.Path = f
|
||||
cmd.Args = command
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = e.StdOut
|
||||
cmd.Env = envList
|
||||
cmd.Stderr = e.StdOut
|
||||
cmd.Dir = wd
|
||||
cmd.SysProcAttr = process.SysProcAttr(cmdline, false)
|
||||
|
||||
// Kills the step's whole tree on cancellation and bounds the post-exit I/O
|
||||
// wait, so an orphan holding cmd's stdout pipe cannot hang cmd.Wait().
|
||||
treeKill := process.NewTreeKill(cmd)
|
||||
|
||||
var ppty *os.File
|
||||
var tty *os.File
|
||||
defer func() {
|
||||
if ppty != nil {
|
||||
ppty.Close()
|
||||
}
|
||||
if tty != nil {
|
||||
tty.Close()
|
||||
}
|
||||
}()
|
||||
if e.AllocatePTY {
|
||||
var err error
|
||||
ppty, tty, err = setupPty(cmd, cmdline)
|
||||
if err != nil {
|
||||
common.Logger(ctx).Debugf("Failed to setup Pty %v\n", err.Error())
|
||||
}
|
||||
}
|
||||
var writer *ptyWriter
|
||||
var logctx context.Context
|
||||
if ppty != nil {
|
||||
writer = &ptyWriter{Out: e.StdOut}
|
||||
var finishLog context.CancelFunc
|
||||
logctx, finishLog = context.WithCancel(context.Background())
|
||||
go copyPtyOutput(writer, ppty, finishLog)
|
||||
go writeKeepAlive(ppty)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Assign before the step's Killer so the step's job nests inside the group's;
|
||||
// cancellation still scopes to this step's tree.
|
||||
if err := e.processGroup(ctx).Assign(cmd.Process); err != nil {
|
||||
common.Logger(ctx).Warnf("could not assign the step's process to the job's process group; a process it leaves behind may outlive the job: %v", err)
|
||||
}
|
||||
if k, kerr := treeKill.Capture(cmd.Process); kerr != nil {
|
||||
common.Logger(ctx).Warnf("process tree kill setup failed, falling back to single-process kill: %v", kerr)
|
||||
} else {
|
||||
defer k.Close()
|
||||
}
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return ExitCodeError(exitErr.ExitCode())
|
||||
}
|
||||
return err
|
||||
}
|
||||
if tty != nil {
|
||||
writer.AutoStop.Store(true)
|
||||
if _, err := tty.WriteString("\x04"); err != nil {
|
||||
common.Logger(ctx).Debug("Failed to write EOT")
|
||||
}
|
||||
<-logctx.Done()
|
||||
ppty.Close()
|
||||
ppty = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Exec(command []string /*cmdline string, */, env map[string]string, user, workdir string) common.Executor {
|
||||
return e.ExecWithCmdLine(command, "", env, user, workdir)
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) ExecWithCmdLine(command []string, cmdline string, env map[string]string, user, workdir string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if err := e.exec(ctx, command, cmdline, env, user, workdir); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("this step has been cancelled: %w", err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||
return parseEnvFile(e, srcPath, env)
|
||||
}
|
||||
|
||||
// removeAll is a var so tests can substitute a blocking stub.
|
||||
var removeAll = os.RemoveAll
|
||||
|
||||
// removeAllWithContext returns once the delete finishes or ctx is cancelled. On
|
||||
// cancellation the goroutine leaks: a delete inside a syscall cannot be
|
||||
// interrupted (see runWithTimeout).
|
||||
func removeAllWithContext(ctx context.Context, path string) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- removeAll(path) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func removePathWithRetry(ctx context.Context, path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
attempts := 1
|
||||
delay := time.Duration(0)
|
||||
if runtime.GOOS == "windows" {
|
||||
attempts = 5
|
||||
delay = 200 * time.Millisecond
|
||||
}
|
||||
var lastErr error
|
||||
for i := 0; i < attempts; i++ {
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
lastErr = removeAllWithContext(ctx, path)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(lastErr, context.DeadlineExceeded) {
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// buildWindowsWorkspaceKillScript builds a PowerShell command that taskkills
|
||||
// every process tree whose ExecutablePath or CommandLine references one of the
|
||||
// given workspace dirs, releasing file handles for cleanup. Win32_Process
|
||||
// exposes both fields (Get-Process doesn't, wmic is deprecated); matching is on
|
||||
// the dir+separator prefix via ordinal String methods, so a name-prefix sibling
|
||||
// (job1 vs job10) is spared and path metacharacters stay literal.
|
||||
func buildWindowsWorkspaceKillScript(dirs []string) string {
|
||||
quoted := make([]string, len(dirs))
|
||||
for i, d := range dirs {
|
||||
// Single-quoted PowerShell literal; escape ' by doubling it.
|
||||
quoted[i] = "'" + strings.ReplaceAll(d, "'", "''") + "'"
|
||||
}
|
||||
|
||||
return `$paths = @(` + strings.Join(quoted, ",") + `)
|
||||
$selfPid = $PID
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
|
||||
if ($_.ProcessId -eq $selfPid) { return $false }
|
||||
foreach ($p in $paths) {
|
||||
$prefix = $p + '\'
|
||||
if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
|
||||
if ($_.CommandLine -and $_.CommandLine.IndexOf($prefix, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
|
||||
}
|
||||
return $false
|
||||
} | ForEach-Object {
|
||||
& taskkill.exe /PID $_.ProcessId /T /F 2>$null | Out-Null
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) terminateRunningProcesses(ctx context.Context) {
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
|
||||
// Detached: exec.CommandContext won't start on a cancelled ctx, and a
|
||||
// server cancel has already cancelled the parent ctx.
|
||||
killCtx, killCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer killCancel()
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// Dirs we own; a process referencing one is a leftover. ToolCache is shared
|
||||
// across jobs, and Workdir may be a caller-owned checkout.
|
||||
owned := []string{e.Path, e.TmpDir}
|
||||
if e.CleanWorkdir {
|
||||
owned = append(owned, e.Workdir)
|
||||
}
|
||||
dirs := make([]string, 0, len(owned))
|
||||
for _, d := range owned {
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dirs = append(dirs, abs)
|
||||
}
|
||||
if len(dirs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
script := buildWindowsWorkspaceKillScript(dirs)
|
||||
|
||||
cmd := exec.CommandContext(killCtx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
logger.Debugf("workspace process-tree kill via PowerShell failed: %v output=%s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// Win32_Process exposes no working directory, so the scan above misses a
|
||||
// process that merely runs in a workspace dir while pinning a handle on it.
|
||||
if killed, err := process.KillProcessesWithCWDUnder(killCtx, dirs); err != nil {
|
||||
logger.Debugf("workspace process kill by working directory reported errors: %v", err)
|
||||
} else if killed > 0 {
|
||||
logger.Debugf("terminated %d leftover process(es) by workspace working directory", killed)
|
||||
}
|
||||
}
|
||||
|
||||
// hostCleanupTimeout bounds each teardown phase so one stalled delete cannot
|
||||
// wedge the runner slot. A var so tests can shrink it.
|
||||
var hostCleanupTimeout = 30 * time.Second
|
||||
|
||||
// runWithTimeout returns context.DeadlineExceeded once timeout elapses, leaking
|
||||
// the goroutine: a delete blocked in a syscall (AV filter driver, dead network
|
||||
// mount) cannot be interrupted, and leaking scratch state beats losing the
|
||||
// runner's capacity slot forever. The idle stale-dir sweep reclaims it later.
|
||||
func runWithTimeout(fn func(), timeout time.Duration) error {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
fn()
|
||||
}()
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) Remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// End lingering processes before removing the workspace; on Windows their
|
||||
// file locks block cleanup. Closing the group is deterministic, the scan a net.
|
||||
if err := e.procGroup.Load().Close(); err != nil {
|
||||
logger.Debugf("closing the job's process group failed: %v", err)
|
||||
}
|
||||
e.terminateRunningProcesses(ctx)
|
||||
|
||||
// Removes per-job misc state only, never the toolcache root. Bounded because
|
||||
// CleanUp is a caller-supplied, typically unbounded os.RemoveAll.
|
||||
if e.CleanUp != nil {
|
||||
logger.Debugf("running host environment cleanup callback")
|
||||
if err := runWithTimeout(e.CleanUp, hostCleanupTimeout); err != nil {
|
||||
logger.Warnf("host environment cleanup did not finish within %s; continuing job completion, scratch state may be leaked and is reclaimed by the idle stale-dir sweep", hostCleanupTimeout)
|
||||
} else {
|
||||
logger.Debugf("host environment cleanup callback finished")
|
||||
}
|
||||
}
|
||||
|
||||
// Detach: a cancelled ctx would skip removePathWithRetry's retries,
|
||||
// which absorb Windows file-handle release lag after the kill above.
|
||||
rmCtx, rmCancel := context.WithTimeout(context.Background(), hostCleanupTimeout)
|
||||
defer rmCancel()
|
||||
|
||||
var errs []error
|
||||
if err := removePathWithRetry(rmCtx, e.Path); err != nil {
|
||||
logger.Warnf("failed to remove host misc state %s: %v", e.Path, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if e.CleanWorkdir {
|
||||
if err := removePathWithRetry(rmCtx, e.Workdir); err != nil {
|
||||
logger.Warnf("failed to remove host workspace %s: %v", e.Workdir, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
for _, err := range errs {
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
}
|
||||
// Teardown timed out; warned above. Do not fail job completion over it.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) ToContainerPath(path string) string {
|
||||
if bp, err := filepath.Rel(e.Workdir, path); err != nil {
|
||||
return filepath.Join(e.Path, bp)
|
||||
} else if filepath.Clean(e.Workdir) == filepath.Clean(path) {
|
||||
return e.Path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetActPath() string {
|
||||
actPath := e.ActPath
|
||||
if runtime.GOOS == "windows" {
|
||||
actPath = strings.ReplaceAll(actPath, "\\", "/")
|
||||
}
|
||||
return actPath
|
||||
}
|
||||
|
||||
func (*HostEnvironment) GetPathVariableName() string {
|
||||
switch runtime.GOOS {
|
||||
case "plan9":
|
||||
return "path"
|
||||
case "windows":
|
||||
return "Path" // Actually we need a case insensitive map
|
||||
}
|
||||
return "PATH"
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) DefaultPathVariable() string {
|
||||
v, _ := os.LookupEnv(e.GetPathVariableName())
|
||||
return v
|
||||
}
|
||||
|
||||
func (*HostEnvironment) JoinPathVariable(paths ...string) string {
|
||||
return strings.Join(paths, string(filepath.ListSeparator))
|
||||
}
|
||||
|
||||
// Reference for Arch values for runner.arch
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
|
||||
func goArchToActionArch(arch string) string {
|
||||
archMapper := map[string]string{
|
||||
"x86_64": "X64",
|
||||
"386": "X86",
|
||||
"aarch64": "ARM64",
|
||||
}
|
||||
if arch, ok := archMapper[arch]; ok {
|
||||
return arch
|
||||
}
|
||||
return arch
|
||||
}
|
||||
|
||||
func goOsToActionOs(os string) string {
|
||||
osMapper := map[string]string{
|
||||
"darwin": "macOS",
|
||||
}
|
||||
if os, ok := osMapper[os]; ok {
|
||||
return os
|
||||
}
|
||||
return os
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) GetRunnerContext(_ context.Context) map[string]any {
|
||||
return map[string]any{
|
||||
"os": goOsToActionOs(runtime.GOOS),
|
||||
"arch": goArchToActionArch(runtime.GOARCH),
|
||||
"temp": e.TmpDir,
|
||||
"tool_cache": e.ToolCache,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HostEnvironment) ReplaceLogWriter(stdout, _ io.Writer) (io.Writer, io.Writer) {
|
||||
org := e.StdOut
|
||||
e.StdOut = stdout
|
||||
return org, org
|
||||
}
|
||||
|
||||
func (*HostEnvironment) IsEnvironmentCaseInsensitive() bool {
|
||||
return runtime.GOOS == "windows"
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Type assert HostEnvironment implements ExecutionsEnvironment
|
||||
var _ ExecutionsEnvironment = &HostEnvironment{}
|
||||
|
||||
func TestCopyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
e := &HostEnvironment{
|
||||
Path: filepath.Join(dir, "path"),
|
||||
TmpDir: filepath.Join(dir, "tmp"),
|
||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||
ActPath: filepath.Join(dir, "act_path"),
|
||||
StdOut: os.Stdout,
|
||||
Workdir: path.Join("testdata", "scratch"),
|
||||
}
|
||||
_ = os.MkdirAll(e.Path, 0o700)
|
||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||
err := e.CopyDir(e.Workdir, e.Path, true)(ctx)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGetContainerArchive(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
e := &HostEnvironment{
|
||||
Path: filepath.Join(dir, "path"),
|
||||
TmpDir: filepath.Join(dir, "tmp"),
|
||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||
ActPath: filepath.Join(dir, "act_path"),
|
||||
StdOut: os.Stdout,
|
||||
Workdir: path.Join("testdata", "scratch"),
|
||||
}
|
||||
_ = os.MkdirAll(e.Path, 0o700)
|
||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
||||
_ = os.MkdirAll(e.ToolCache, 0o700)
|
||||
_ = os.MkdirAll(e.ActPath, 0o700)
|
||||
expectedContent := []byte("sdde/7sh")
|
||||
err := os.WriteFile(filepath.Join(e.Path, "action.yml"), expectedContent, 0o600)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
archive, err := e.GetContainerArchive(ctx, e.Path)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
defer archive.Close()
|
||||
reader := tar.NewReader(archive)
|
||||
h, err := reader.Next()
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, "action.yml", h.Name)
|
||||
content, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, expectedContent, content)
|
||||
_, err = reader.Next()
|
||||
assert.ErrorIs(t, err, io.EOF)
|
||||
}
|
||||
|
||||
func TestHostEnvironmentExecExitCode(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
e := &HostEnvironment{
|
||||
Path: filepath.Join(dir, "path"),
|
||||
TmpDir: filepath.Join(dir, "tmp"),
|
||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||
ActPath: filepath.Join(dir, "act_path"),
|
||||
StdOut: io.Discard,
|
||||
Workdir: filepath.Join(dir, "path"),
|
||||
}
|
||||
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
|
||||
assert.NoError(t, os.MkdirAll(p, 0o700)) //nolint:testifylint // test setup
|
||||
}
|
||||
|
||||
err := e.Exec([]string{"sh", "-c", "exit 3"}, map[string]string{"PATH": os.Getenv("PATH")}, "", "")(ctx)
|
||||
var exitErr ExitCodeError
|
||||
require.ErrorAs(t, err, &exitErr)
|
||||
assert.Equal(t, ExitCodeError(3), exitErr)
|
||||
assert.Equal(t, "Process completed with exit code 3.", err.Error())
|
||||
}
|
||||
|
||||
func TestHostEnvironmentAllocatePTY(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
allocPTY bool
|
||||
expect string
|
||||
}{
|
||||
{name: "off", allocPTY: false, expect: "NOTTY"},
|
||||
{name: "on", allocPTY: true, expect: "TTY"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
buf := &bytes.Buffer{}
|
||||
e := &HostEnvironment{
|
||||
Path: filepath.Join(dir, "path"),
|
||||
TmpDir: filepath.Join(dir, "tmp"),
|
||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||
ActPath: filepath.Join(dir, "act_path"),
|
||||
StdOut: buf,
|
||||
Workdir: filepath.Join(dir, "path"),
|
||||
AllocatePTY: tc.allocPTY,
|
||||
}
|
||||
for _, p := range []string{e.Path, e.TmpDir, e.ToolCache, e.ActPath} {
|
||||
require.NoError(t, os.MkdirAll(p, 0o700))
|
||||
}
|
||||
|
||||
err := e.Exec(
|
||||
[]string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"},
|
||||
map[string]string{"PATH": os.Getenv("PATH")}, "", "",
|
||||
)(context.Background())
|
||||
require.NoError(t, err)
|
||||
got := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", ""))
|
||||
assert.Equal(t, tc.expect, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
miscRoot := filepath.Join(base, "misc")
|
||||
path := filepath.Join(miscRoot, "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
workdir := filepath.Join(base, "workspace", "owner", "repo")
|
||||
require.NoError(t, os.MkdirAll(workdir, 0o700))
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
Workdir: workdir,
|
||||
CleanUp: func() {
|
||||
_ = os.RemoveAll(miscRoot)
|
||||
},
|
||||
StdOut: os.Stdout,
|
||||
}
|
||||
require.NoError(t, e.Remove()(ctx))
|
||||
_, err := os.Stat(workdir)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
miscRoot := filepath.Join(base, "misc")
|
||||
path := filepath.Join(miscRoot, "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
workdir := filepath.Join(base, "workspace", "123", "owner", "repo")
|
||||
require.NoError(t, os.MkdirAll(workdir, 0o700))
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
Workdir: workdir,
|
||||
CleanWorkdir: true,
|
||||
CleanUp: func() {
|
||||
_ = os.RemoveAll(miscRoot)
|
||||
},
|
||||
StdOut: os.Stdout,
|
||||
}
|
||||
require.NoError(t, e.Remove()(ctx))
|
||||
_, err := os.Stat(workdir)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
stubDone := make(chan struct{})
|
||||
|
||||
orig := removeAll
|
||||
removeAll = func(string) error {
|
||||
defer close(stubDone)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
// removeAllWithContext intentionally leaks the delete goroutine on timeout,
|
||||
// and that goroutine still references removeAll. Unblock it and wait for it
|
||||
// to return before restoring the var, so the restore can't race the read.
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
<-stubDone
|
||||
removeAll = orig
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := removeAllWithContext(ctx, t.TempDir())
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp guards against a stalled
|
||||
// CleanUp callback (e.g. an os.RemoveAll blocked by an AV/EDR filter driver or
|
||||
// an unresponsive mount) wedging the runner slot forever at "Cleaning up
|
||||
// container". Remove must time out the callback and complete job teardown.
|
||||
func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) {
|
||||
// Keep the suite fast: shrink the per-phase teardown timeout for this test.
|
||||
orig := hostCleanupTimeout
|
||||
hostCleanupTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { hostCleanupTimeout = orig })
|
||||
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "misc", "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
|
||||
release := make(chan struct{})
|
||||
t.Cleanup(func() { close(release) }) // unblock the leaked goroutine at test end
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
CleanUp: func() {
|
||||
<-release // simulate a delete syscall stuck indefinitely
|
||||
},
|
||||
StdOut: os.Stdout,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- e.Remove()(ctx) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Remove() hung on a stuck CleanUp callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval guards against a
|
||||
// stalled os.RemoveAll on the misc/workspace paths (same AV/EDR wedge as
|
||||
// #1023) wedging job completion after the CleanUp callback has already timed
|
||||
// out or finished.
|
||||
func TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval(t *testing.T) {
|
||||
origTimeout := hostCleanupTimeout
|
||||
hostCleanupTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { hostCleanupTimeout = origTimeout })
|
||||
|
||||
release := make(chan struct{})
|
||||
stubDone := make(chan struct{})
|
||||
|
||||
origRemoveAll := removeAll
|
||||
removeAll = func(string) error {
|
||||
defer close(stubDone)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
// The stuck delete goroutine outlives the timed-out Remove and still reads
|
||||
// removeAll; unblock it and wait before restoring to avoid a restore/read race.
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
<-stubDone
|
||||
removeAll = origRemoveAll
|
||||
})
|
||||
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "misc", "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
|
||||
e := &HostEnvironment{
|
||||
Path: path,
|
||||
StdOut: os.Stdout,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- e.Remove()(ctx) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Remove() hung on a stuck path removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWindowsWorkspaceKillScript(t *testing.T) {
|
||||
t.Run("single dir", func(t *testing.T) {
|
||||
s := buildWindowsWorkspaceKillScript([]string{`C:\workspace\job1`})
|
||||
assert.Contains(t, s, `$paths = @('C:\workspace\job1')`)
|
||||
// Self-PID guard is essential — without it the script could taskkill
|
||||
// the PowerShell process running it.
|
||||
assert.Contains(t, s, "$selfPid = $PID")
|
||||
assert.Contains(t, s, "$_.ProcessId -eq $selfPid")
|
||||
// Must match both ExecutablePath (binaries from the workspace) and
|
||||
// CommandLine (system binaries invoked with workspace paths in args),
|
||||
// both bounded by dir+separator so a name-prefix sibling is spared.
|
||||
assert.Contains(t, s, `$prefix = $p + '\'`)
|
||||
assert.Contains(t, s, "$_.ExecutablePath.StartsWith($prefix")
|
||||
assert.Contains(t, s, "$_.CommandLine.IndexOf($prefix")
|
||||
// Each matched PID must be tree-killed, not just stopped.
|
||||
assert.Contains(t, s, "taskkill.exe /PID $_.ProcessId /T /F")
|
||||
})
|
||||
|
||||
t.Run("multiple dirs comma-separated", func(t *testing.T) {
|
||||
s := buildWindowsWorkspaceKillScript([]string{
|
||||
`C:\work\path`,
|
||||
`C:\work\workdir`,
|
||||
`C:\Users\runner\AppData\Local\Temp\job-42`,
|
||||
})
|
||||
assert.Contains(t, s, `'C:\work\path'`)
|
||||
assert.Contains(t, s, `'C:\work\workdir'`)
|
||||
assert.Contains(t, s, `'C:\Users\runner\AppData\Local\Temp\job-42'`)
|
||||
// Commas between entries — no trailing comma, no leading comma.
|
||||
assert.Contains(t, s, `'C:\work\path','C:\work\workdir',`)
|
||||
})
|
||||
|
||||
t.Run("path with single quote is escaped", func(t *testing.T) {
|
||||
// In PowerShell single-quoted strings the only special char is the
|
||||
// quote itself, escaped by doubling. A workspace path that ever
|
||||
// contained `'` would inject a command into the script otherwise.
|
||||
s := buildWindowsWorkspaceKillScript([]string{`C:\work\it's\path`})
|
||||
assert.Contains(t, s, `'C:\work\it''s\path'`)
|
||||
// And it must NOT appear unescaped — otherwise the quote would
|
||||
// terminate the literal early.
|
||||
assert.NotContains(t, s, `'C:\work\it's\path'`)
|
||||
})
|
||||
|
||||
t.Run("path with wildcard metacharacters is matched literally", func(t *testing.T) {
|
||||
// A path containing [ ] ? * must be embedded verbatim and matched with
|
||||
// ordinal String methods, not -like, otherwise the metacharacters would
|
||||
// be interpreted as wildcards and the leftover process could escape.
|
||||
s := buildWindowsWorkspaceKillScript([]string{`C:\work\[job]?1`})
|
||||
assert.Contains(t, s, `'C:\work\[job]?1'`)
|
||||
assert.NotContains(t, s, "-like")
|
||||
assert.Contains(t, s, "StartsWith")
|
||||
assert.Contains(t, s, "IndexOf")
|
||||
})
|
||||
|
||||
t.Run("empty dir list still produces a valid script", func(t *testing.T) {
|
||||
s := buildWindowsWorkspaceKillScript(nil)
|
||||
// Empty array literal — script runs, matches nothing, is a no-op.
|
||||
assert.Contains(t, s, "$paths = @()")
|
||||
assert.Contains(t, s, "Get-CimInstance Win32_Process")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type LinuxContainerEnvironmentExtensions struct{}
|
||||
|
||||
// Resolves the equivalent host path inside the container
|
||||
// This is required for windows and WSL 2 to translate things like C:\Users\Myproject to /mnt/users/Myproject
|
||||
// For use in docker volumes and binds
|
||||
func (*LinuxContainerEnvironmentExtensions) ToContainerPath(path string) string {
|
||||
if runtime.GOOS == "windows" && strings.Contains(path, "/") {
|
||||
log.Error("You cannot specify linux style local paths (/mnt/etc) on Windows as it does not understand them.")
|
||||
return ""
|
||||
}
|
||||
|
||||
abspath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Test if the path is a windows path
|
||||
windowsPathRegex := regexp.MustCompile(`^([a-zA-Z]):\\(.+)$`)
|
||||
windowsPathComponents := windowsPathRegex.FindStringSubmatch(abspath)
|
||||
|
||||
// Return as-is if no match
|
||||
if windowsPathComponents == nil {
|
||||
return abspath
|
||||
}
|
||||
|
||||
// Convert to WSL2-compatible path if it is a windows path
|
||||
// NOTE: Cannot use filepath because it will use the wrong path separators assuming we want the path to be windows
|
||||
// based if running on Windows, and because we are feeding this to Docker, GoLang auto-path-translate doesn't work.
|
||||
driveLetter := strings.ToLower(windowsPathComponents[1])
|
||||
translatedPath := strings.ReplaceAll(windowsPathComponents[2], `\`, `/`)
|
||||
// Should make something like /mnt/c/Users/person/My Folder/MyActProject
|
||||
result := strings.Join([]string{"/mnt", driveLetter, translatedPath}, `/`)
|
||||
return result
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetActPath() string {
|
||||
return "/var/run/act"
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetPathVariableName() string {
|
||||
return "PATH"
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) DefaultPathVariable() string {
|
||||
return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) JoinPathVariable(paths ...string) string {
|
||||
return strings.Join(paths, ":")
|
||||
}
|
||||
|
||||
// DefaultToolCache is where the runner mounts the tool cache inside job containers.
|
||||
const DefaultToolCache = "/opt/hostedtoolcache"
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) GetRunnerContext(ctx context.Context) map[string]any {
|
||||
return map[string]any{
|
||||
"os": "Linux",
|
||||
"arch": RunnerArch(ctx),
|
||||
"temp": "/tmp",
|
||||
"tool_cache": DefaultToolCache,
|
||||
}
|
||||
}
|
||||
|
||||
func (*LinuxContainerEnvironmentExtensions) IsEnvironmentCaseInsensitive() bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestContainerPath(t *testing.T) {
|
||||
type containerPathJob struct {
|
||||
destinationPath string
|
||||
sourcePath string
|
||||
workDir string
|
||||
}
|
||||
|
||||
linuxcontainerext := &LinuxContainerEnvironmentExtensions{}
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
|
||||
rootDrive := os.Getenv("SystemDrive")
|
||||
rootDriveLetter := strings.ReplaceAll(strings.ToLower(rootDrive), `:`, "")
|
||||
for _, v := range []containerPathJob{
|
||||
{"/mnt/c/Users/act/go/src/github.com/nektos/act", "C:\\Users\\act\\go\\src\\github.com\\nektos\\act\\", ""},
|
||||
{"/mnt/f/work/dir", `F:\work\dir`, ""},
|
||||
{"/mnt/c/windows/to/unix", "windows\\to\\unix", rootDrive + "\\"},
|
||||
{fmt.Sprintf("/mnt/%v/act", rootDriveLetter), "act", rootDrive + "\\"},
|
||||
} {
|
||||
if v.workDir != "" {
|
||||
t.Chdir(v.workDir)
|
||||
}
|
||||
|
||||
assert.Equal(t, v.destinationPath, linuxcontainerext.ToContainerPath(v.sourcePath))
|
||||
}
|
||||
|
||||
t.Chdir(cwd)
|
||||
} else {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
for _, v := range []containerPathJob{
|
||||
{"/home/act/go/src/github.com/nektos/act", "/home/act/go/src/github.com/nektos/act", ""},
|
||||
{"/home/act", `/home/act/`, ""},
|
||||
{cwd, ".", ""},
|
||||
} {
|
||||
assert.Equal(t, v.destinationPath, linuxcontainerext.ToContainerPath(v.sourcePath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type typeAssertMockContainer struct {
|
||||
Container
|
||||
LinuxContainerEnvironmentExtensions
|
||||
}
|
||||
|
||||
// Type assert Container + LinuxContainerEnvironmentExtensions implements ExecutionsEnvironment
|
||||
var _ ExecutionsEnvironment = &typeAssertMockContainer{}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/internal/act/common"
|
||||
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
func parseEnvFile(e Container, srcPath string, env *map[string]string) common.Executor {
|
||||
localEnv := *env
|
||||
return func(ctx context.Context) error {
|
||||
envTar, err := e.GetContainerArchive(ctx, srcPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer envTar.Close()
|
||||
reader := tar.NewReader(envTar)
|
||||
_, err = reader.Next()
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
// Decode by BOM: Windows PowerShell 5.1 redirection writes UTF-16, and some
|
||||
// tools emit a UTF-8 BOM. Without a BOM the file is read as UTF-8, as before.
|
||||
decoded := transform.NewReader(reader, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
|
||||
|
||||
s := bufio.NewScanner(decoded)
|
||||
// Default 64 KiB max token size is too small for realistic env-file lines; allow up to 16 MiB.
|
||||
s.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
// GitHub's runner ignores blank lines
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
singleLineEnv := strings.Index(line, "=")
|
||||
multiLineEnv := strings.Index(line, "<<")
|
||||
if singleLineEnv != -1 && (multiLineEnv == -1 || singleLineEnv < multiLineEnv) {
|
||||
localEnv[line[:singleLineEnv]] = line[singleLineEnv+1:]
|
||||
} else if multiLineEnv != -1 {
|
||||
multiLineEnvContent := ""
|
||||
multiLineEnvDelimiter := line[multiLineEnv+2:]
|
||||
delimiterFound := false
|
||||
for s.Scan() {
|
||||
content := s.Text()
|
||||
if content == multiLineEnvDelimiter {
|
||||
delimiterFound = true
|
||||
break
|
||||
}
|
||||
if multiLineEnvContent != "" {
|
||||
multiLineEnvContent += "\n"
|
||||
}
|
||||
multiLineEnvContent += content
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return fmt.Errorf("reading env file: %w", err)
|
||||
}
|
||||
if !delimiterFound {
|
||||
return fmt.Errorf("invalid format delimiter '%v' not found before end of file", multiLineEnvDelimiter)
|
||||
}
|
||||
localEnv[line[:multiLineEnv]] = multiLineEnvContent
|
||||
} else {
|
||||
return fmt.Errorf("invalid format '%v', expected a line with '=' or '<<'", line)
|
||||
}
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return fmt.Errorf("reading env file: %w", err)
|
||||
}
|
||||
env = &localEnv
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/encoding"
|
||||
"golang.org/x/text/encoding/unicode"
|
||||
)
|
||||
|
||||
func newTestHostEnv(t *testing.T) (*HostEnvironment, string) {
|
||||
t.Helper()
|
||||
e := &HostEnvironment{Path: t.TempDir()}
|
||||
return e, filepath.Join(e.Path, "envfile")
|
||||
}
|
||||
|
||||
func TestParseEnvFileSingleLine(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "bar", env["FOO"])
|
||||
assert.Equal(t, "qux", env["BAZ"])
|
||||
}
|
||||
|
||||
func TestParseEnvFileMultiLine(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
content := "FOO<<EOF\nline1\nline2\nEOF\n"
|
||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "line1\nline2", env["FOO"])
|
||||
}
|
||||
|
||||
func TestParseEnvFileLargeValueWithinLimit(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
big := strings.Repeat("x", 2*1024*1024)
|
||||
content := "FOO<<EOF\n" + big + "\nEOF\n"
|
||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, big, env["FOO"])
|
||||
}
|
||||
|
||||
func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
tooBig := strings.Repeat("x", 17*1024*1024) // over the 16 MiB cap
|
||||
content := "FOO<<EOF\n" + tooBig + "\nEOF\n"
|
||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
err := parseEnvFile(e, envPath, &env)(context.Background())
|
||||
require.ErrorIs(t, err, bufio.ErrTooLong)
|
||||
assert.Contains(t, err.Error(), "reading env file")
|
||||
}
|
||||
|
||||
// Regression test: a blank line used to fail the job at "Complete Job", after
|
||||
// every step had already been recorded as successful.
|
||||
func TestParseEnvFileBlankLines(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("\nFOO=bar\n\n \nBAZ=qux\n\n"), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "bar", env["FOO"])
|
||||
assert.Equal(t, "qux", env["BAZ"])
|
||||
}
|
||||
|
||||
// blank lines inside a heredoc value are content, not separators
|
||||
func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\n\nline2\nEOF\n"), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
||||
}
|
||||
|
||||
func TestParseEnvFileUTF8BOM(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("FOO=bar\n")...)
|
||||
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "bar", env["FOO"])
|
||||
}
|
||||
|
||||
// Windows host mode: PowerShell 5.1 redirection writes UTF-16, which used to be
|
||||
// unrecognisable as KEY=VALUE, so the writes were silently ignored.
|
||||
func TestParseEnvFileUTF16(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
encoder *encoding.Encoder
|
||||
}{
|
||||
{"little endian", unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewEncoder()},
|
||||
{"big endian", unicode.UTF16(unicode.BigEndian, unicode.UseBOM).NewEncoder()},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
content, err := tt.encoder.Bytes([]byte("FOO=bar\r\nMULTI<<EOF\r\nline1\r\nEOF\r\n"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
||||
assert.Equal(t, "bar", env["FOO"])
|
||||
assert.Equal(t, "line1", env["MULTI"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnvFileMissingDelimiter(t *testing.T) {
|
||||
e, envPath := newTestHostEnv(t)
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
err := parseEnvFile(e, envPath, &env)(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "delimiter")
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
FROM scratch
|
||||
ENV PATH="/this/path/does/not/exists/anywhere:/this/either"
|
||||
ENV SOME_RANDOM_VAR=""
|
||||
ENV ANOTHER_ONE="BUT_I_HAVE_VALUE"
|
||||
ENV CONFLICT_VAR="I_EXIST_ONLY_HERE"
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"auths": {
|
||||
"https://index.docker.io/v1/": {
|
||||
"auth": "dXNlcm5hbWU6cGFzc3dvcmQK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
testfile
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
@@ -0,0 +1,3 @@
|
||||
FOO=BAR
|
||||
HELLO=您好
|
||||
BAR=FOO
|
||||
+1
@@ -0,0 +1 @@
|
||||
ENV1=value1
|
||||
+1
@@ -0,0 +1 @@
|
||||
LABEL1=value1
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build (!windows && !plan9 && !openbsd) || (!windows && !plan9 && !mips64)
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return pty.Open()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
func openPty() (*os.File, *os.File, error) {
|
||||
return nil, nil, errors.New("Unsupported")
|
||||
}
|
||||
Reference in New Issue
Block a user