A var used in container_limits broke the build, because FileConfigSource parses the file into an atc.TaskConfig before any interpolation happens, and '((container_mem_limit))' is not a memory limit. Only fields that are plain strings survived that first parse. FileConfigSource can now hand back the file as written, and InterpolateTemplateConfigSource resolves those bytes and parses once, instead of parsing, marshalling back and parsing again. For a task file the interpolation pass now always runs, with the pipeline vars followed by the credential variables, so a var resolves the same way wherever it appears in the file.
640 lines
18 KiB
Go
640 lines
18 KiB
Go
package exec
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.cloudfoundry.org/lager/v3"
|
|
"code.cloudfoundry.org/lager/v3/lagerctx"
|
|
"github.com/concourse/concourse/atc"
|
|
"github.com/concourse/concourse/atc/db"
|
|
"github.com/concourse/concourse/atc/exec/build"
|
|
"github.com/concourse/concourse/atc/runtime"
|
|
"github.com/concourse/concourse/atc/worker"
|
|
"github.com/concourse/concourse/tracing"
|
|
"github.com/concourse/concourse/vars"
|
|
"github.com/concourse/concourse/worker/baggageclaim"
|
|
"go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
const taskProcessID = "task"
|
|
|
|
// MissingInputsError is returned when any of the task's required inputs are
|
|
// missing.
|
|
type MissingInputsError struct {
|
|
Inputs []string
|
|
}
|
|
|
|
// Error prints a human-friendly message listing the inputs that were missing.
|
|
func (err MissingInputsError) Error() string {
|
|
return fmt.Sprintf("missing inputs: %s", strings.Join(err.Inputs, ", "))
|
|
}
|
|
|
|
type MissingTaskImageSourceError struct {
|
|
SourceName string
|
|
}
|
|
|
|
func (err MissingTaskImageSourceError) Error() string {
|
|
return fmt.Sprintf(`missing image artifact source: %s
|
|
|
|
make sure there's a corresponding 'get' step, or a task that produces it as an output`, err.SourceName)
|
|
}
|
|
|
|
type TaskImageSourceParametersError struct {
|
|
Err error
|
|
}
|
|
|
|
func (err TaskImageSourceParametersError) Error() string {
|
|
return fmt.Sprintf("failed to evaluate image resource parameters: %s", err.Err)
|
|
}
|
|
|
|
//counterfeiter:generate . TaskDelegateFactory
|
|
type TaskDelegateFactory interface {
|
|
TaskDelegate(state RunState) TaskDelegate
|
|
}
|
|
|
|
//counterfeiter:generate . TaskDelegate
|
|
type TaskDelegate interface {
|
|
StartSpan(context.Context, string, tracing.Attrs) (context.Context, trace.Span)
|
|
|
|
FetchImage(context.Context, atc.ImageResource, atc.ResourceTypes, bool, atc.Tags, bool) (runtime.ImageSpec, error)
|
|
|
|
Stdout() io.Writer
|
|
Stderr() io.Writer
|
|
|
|
SetTaskConfig(config atc.TaskConfig)
|
|
|
|
Initializing(lager.Logger)
|
|
Starting(lager.Logger)
|
|
Finished(lager.Logger, ExitStatus)
|
|
Errored(lager.Logger, string)
|
|
|
|
BeforeSelectWorker(lager.Logger) error
|
|
WaitingForWorker(lager.Logger)
|
|
SelectedWorker(lager.Logger, string)
|
|
StreamingVolume(lager.Logger, string, string, string)
|
|
WaitingForStreamedVolume(lager.Logger, string, string)
|
|
BuildStartTime() time.Time
|
|
}
|
|
|
|
// TaskStep executes a TaskConfig, whose inputs will be fetched from the
|
|
// artifact.Repository and outputs will be added to the artifact.Repository.
|
|
type TaskStep struct {
|
|
planID atc.PlanID
|
|
plan atc.TaskPlan
|
|
defaultLimits atc.ContainerLimits
|
|
metadata StepMetadata
|
|
containerMetadata db.ContainerMetadata
|
|
strategy worker.PlacementStrategy
|
|
workerPool Pool
|
|
streamer Streamer
|
|
delegateFactory TaskDelegateFactory
|
|
defaultTaskTimeout time.Duration
|
|
defaultTaskCacheTTL time.Duration
|
|
}
|
|
|
|
func NewTaskStep(
|
|
planID atc.PlanID,
|
|
plan atc.TaskPlan,
|
|
defaultLimits atc.ContainerLimits,
|
|
metadata StepMetadata,
|
|
containerMetadata db.ContainerMetadata,
|
|
strategy worker.PlacementStrategy,
|
|
workerPool Pool,
|
|
streamer Streamer,
|
|
delegateFactory TaskDelegateFactory,
|
|
defaultTaskTimeout time.Duration,
|
|
defaultTaskCacheTTL time.Duration,
|
|
) Step {
|
|
return &TaskStep{
|
|
planID: planID,
|
|
plan: plan,
|
|
defaultLimits: defaultLimits,
|
|
metadata: metadata,
|
|
containerMetadata: containerMetadata,
|
|
strategy: strategy,
|
|
workerPool: workerPool,
|
|
streamer: streamer,
|
|
delegateFactory: delegateFactory,
|
|
defaultTaskTimeout: defaultTaskTimeout,
|
|
defaultTaskCacheTTL: defaultTaskCacheTTL,
|
|
}
|
|
}
|
|
|
|
// Run will first select the worker based on the TaskConfig's platform and the
|
|
// TaskStep's tags, and prioritize it by availability of volumes for the TaskConfig's
|
|
// inputs. Inputs that did not have volumes available on the worker will be streamed
|
|
// in to the container.
|
|
//
|
|
// If any inputs are not available in the artifact.Repository, MissingInputsError
|
|
// is returned.
|
|
//
|
|
// Once all the inputs are satisfied, the task's script will be executed. If
|
|
// the task is canceled via the context, the script will be interrupted.
|
|
//
|
|
// If the script exits successfully, the outputs specified in the TaskConfig
|
|
// are registered with the artifact.Repository. If no outputs are specified, the
|
|
// task's entire working directory is registered as an StreamableArtifactSource under the
|
|
// name of the task.
|
|
func (step *TaskStep) Run(ctx context.Context, state RunState) (bool, error) {
|
|
delegate := step.delegateFactory.TaskDelegate(state)
|
|
ctx, span := delegate.StartSpan(ctx, "task", tracing.Attrs{
|
|
"name": step.plan.Name,
|
|
})
|
|
|
|
ok, err := step.run(ctx, state, delegate)
|
|
tracing.End(span, err)
|
|
|
|
return ok, err
|
|
}
|
|
|
|
func (step *TaskStep) run(ctx context.Context, state RunState, delegate TaskDelegate) (bool, error) {
|
|
logger := lagerctx.FromContext(ctx)
|
|
logger = logger.Session("task-step", lager.Data{
|
|
"step-name": step.plan.Name,
|
|
"job-id": step.metadata.JobID,
|
|
})
|
|
|
|
var taskConfigSource TaskConfigSource
|
|
var taskVars []vars.Variables
|
|
|
|
if step.plan.ConfigPath != "" {
|
|
// external task - construct a source which reads it from file, and apply base resource type defaults.
|
|
taskConfigSource = FileConfigSource{ConfigPath: step.plan.ConfigPath, Streamer: step.streamer}
|
|
|
|
// for interpolation - use 'vars' from the pipeline, and then fill remaining with cred variables.
|
|
// this pass reads the file as written, before it is parsed, so a var may
|
|
// stand in for a field that is not a string, such as container_limits.
|
|
configVars := []vars.Variables{}
|
|
if len(step.plan.Vars) > 0 {
|
|
configVars = append(configVars, vars.StaticVariables(step.plan.Vars))
|
|
}
|
|
configVars = append(configVars, state)
|
|
|
|
taskConfigSource = InterpolateTemplateConfigSource{
|
|
ConfigSource: taskConfigSource,
|
|
Vars: configVars,
|
|
ExpectAllKeys: false,
|
|
}
|
|
taskVars = []vars.Variables{state}
|
|
} else {
|
|
// embedded task - first we take it
|
|
taskConfigSource = StaticConfigSource{Config: step.plan.Config}
|
|
|
|
// for interpolation - use just cred variables
|
|
taskVars = []vars.Variables{state}
|
|
}
|
|
|
|
// apply resource type defaults
|
|
taskConfigSource = BaseResourceTypeDefaultsApplySource{
|
|
ConfigSource: taskConfigSource,
|
|
ResourceTypes: step.plan.ResourceTypes,
|
|
}
|
|
|
|
// override limits
|
|
taskConfigSource = &OverrideContainerLimitsSource{ConfigSource: taskConfigSource, Limits: step.plan.Limits}
|
|
|
|
// override params
|
|
taskConfigSource = &OverrideParamsConfigSource{ConfigSource: taskConfigSource, Params: step.plan.Params}
|
|
|
|
// interpolate template vars
|
|
taskConfigSource = InterpolateTemplateConfigSource{
|
|
ConfigSource: taskConfigSource,
|
|
Vars: taskVars,
|
|
ExpectAllKeys: true,
|
|
}
|
|
|
|
// validate
|
|
taskConfigSource = ValidatingConfigSource{ConfigSource: taskConfigSource}
|
|
|
|
repository := state.ArtifactRepository()
|
|
|
|
config, err := taskConfigSource.FetchConfig(ctx, logger, repository)
|
|
|
|
for _, warning := range taskConfigSource.Warnings() {
|
|
fmt.Fprintln(delegate.Stderr(), "[WARNING]", warning)
|
|
}
|
|
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if config.Limits == nil {
|
|
config.Limits = &atc.ContainerLimits{}
|
|
}
|
|
if config.Limits.CPU == nil {
|
|
config.Limits.CPU = step.defaultLimits.CPU
|
|
}
|
|
if config.Limits.Memory == nil {
|
|
config.Limits.Memory = step.defaultLimits.Memory
|
|
}
|
|
|
|
delegate.Initializing(logger)
|
|
|
|
imageSpec, err := step.imageSpec(ctx, logger, state, delegate, config)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if config.Run.Path == "" {
|
|
path, args, err := step.getOciEntrypoint(ctx, imageSpec)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
config.Run.Path = path
|
|
// Append any args defined in the Task config to those from the
|
|
// container image
|
|
config.Run.Args = append(args, config.Run.Args...)
|
|
}
|
|
|
|
if config.Run.Path == "" {
|
|
return false, errors.New("No executable defined for task. Specify an executable to run in either the task config (run.path) or as an ENTRYPOINT/CMD in the container image.")
|
|
}
|
|
|
|
delegate.SetTaskConfig(config)
|
|
|
|
containerSpec, err := step.containerSpec(logger, state, imageSpec, config, step.containerMetadata)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
tracing.Inject(ctx, &containerSpec)
|
|
|
|
owner := db.NewBuildStepContainerOwner(step.metadata.BuildID, step.planID, step.metadata.TeamID)
|
|
|
|
err = delegate.BeforeSelectWorker(logger)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
worker, err := step.workerPool.FindOrSelectWorker(
|
|
ctx,
|
|
owner,
|
|
containerSpec,
|
|
step.workerSpec(config),
|
|
step.strategy,
|
|
delegate,
|
|
)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
defer func() {
|
|
step.workerPool.ReleaseWorker(
|
|
logger,
|
|
containerSpec,
|
|
worker,
|
|
step.strategy,
|
|
)
|
|
}()
|
|
|
|
ctx, cancel, err := MaybeTimeout(ctx, step.plan.Timeout, step.defaultTaskTimeout)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer cancel()
|
|
|
|
ctx = lagerctx.NewContext(ctx, logger)
|
|
|
|
delegate.SelectedWorker(logger, worker.Name())
|
|
|
|
container, volumeMounts, err := worker.FindOrCreateContainer(ctx, owner, step.containerMetadata, containerSpec, delegate)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "rootfs must not be empty") {
|
|
return false, errors.Join(
|
|
err,
|
|
errors.New(`No image specified for task? Make sure to specify "image" on your task step or "image_resource" in your task config. The image must be in rootfs format.`),
|
|
)
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
delegate.Starting(logger)
|
|
process, err := attachOrRun(
|
|
ctx,
|
|
container,
|
|
runtime.ProcessSpec{
|
|
ID: taskProcessID,
|
|
Path: config.Run.Path,
|
|
Args: config.Run.Args,
|
|
Dir: resolvePath(step.containerMetadata.WorkingDirectory, config.Run.Dir),
|
|
User: config.Run.User,
|
|
// Guardian sets the default TTY window size to width: 80, height: 24,
|
|
// which creates ANSI control sequences that do not work with other window sizes
|
|
TTY: &runtime.TTYSpec{
|
|
WindowSize: runtime.WindowSize{
|
|
Columns: 500,
|
|
Rows: 500,
|
|
},
|
|
},
|
|
},
|
|
runtime.ProcessIO{
|
|
Stdout: delegate.Stdout(),
|
|
Stderr: delegate.Stderr(),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
result, runErr := process.Wait(ctx)
|
|
|
|
step.registerOutputs(logger, repository, config, volumeMounts, step.containerMetadata)
|
|
|
|
// Do not initialize caches for one-off builds
|
|
if step.metadata.JobID != 0 {
|
|
if err := step.registerCaches(ctx, config, volumeMounts, step.containerMetadata); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
if runErr != nil {
|
|
if errors.Is(runErr, context.DeadlineExceeded) {
|
|
delegate.Errored(logger, TimeoutLogMessage)
|
|
return false, nil
|
|
}
|
|
|
|
return false, runErr
|
|
}
|
|
|
|
delegate.Finished(logger, ExitStatus(result.ExitStatus))
|
|
return result.ExitStatus == 0, nil
|
|
}
|
|
|
|
func attachOrRun(ctx context.Context, container runtime.Container, spec runtime.ProcessSpec, io runtime.ProcessIO) (runtime.Process, error) {
|
|
process, err := container.Attach(ctx, spec.ID, io)
|
|
if err == nil {
|
|
return process, nil
|
|
}
|
|
return container.Run(ctx, spec, io)
|
|
}
|
|
|
|
func (step *TaskStep) imageSpec(ctx context.Context, logger lager.Logger, state RunState, delegate TaskDelegate, config atc.TaskConfig) (runtime.ImageSpec, error) {
|
|
imageSpec := runtime.ImageSpec{
|
|
Privileged: bool(step.plan.Privileged),
|
|
}
|
|
|
|
// Determine the source of the container image
|
|
// a reference to an artifact (get step, task output) ?
|
|
if step.plan.ImageArtifactName != "" {
|
|
artifact, _, found := state.ArtifactRepository().ArtifactFor(build.ArtifactName(step.plan.ImageArtifactName))
|
|
if !found {
|
|
return runtime.ImageSpec{}, MissingTaskImageSourceError{step.plan.ImageArtifactName}
|
|
}
|
|
imageSpec.ImageArtifact = artifact
|
|
|
|
//an image_resource
|
|
} else if config.ImageResource != nil {
|
|
imageSpec, err := delegate.FetchImage(
|
|
ctx,
|
|
*config.ImageResource,
|
|
step.plan.ResourceTypes,
|
|
step.plan.Privileged,
|
|
step.plan.Tags,
|
|
step.plan.CheckSkipInterval,
|
|
)
|
|
return imageSpec, err
|
|
|
|
// a rootfs_uri
|
|
} else if config.RootfsURI != "" {
|
|
imageSpec.ImageURL = config.RootfsURI
|
|
}
|
|
|
|
return imageSpec, nil
|
|
}
|
|
|
|
func (step *TaskStep) containerInputs(logger lager.Logger, repository *build.Repository, config atc.TaskConfig, metadata db.ContainerMetadata) ([]runtime.Input, error) {
|
|
var inputs []runtime.Input
|
|
|
|
var missingRequiredInputs []string
|
|
|
|
for _, input := range config.Inputs {
|
|
inputName := input.Name
|
|
if sourceName, ok := step.plan.InputMapping[inputName]; ok {
|
|
inputName = sourceName
|
|
}
|
|
|
|
artifact, fromCache, found := repository.ArtifactFor(build.ArtifactName(inputName))
|
|
if !found {
|
|
if !input.Optional {
|
|
missingRequiredInputs = append(missingRequiredInputs, inputName)
|
|
}
|
|
continue
|
|
}
|
|
inputs = append(inputs, runtime.Input{
|
|
Artifact: artifact,
|
|
DestinationPath: artifactPath(metadata.WorkingDirectory, input.Name, input.Path),
|
|
FromCache: fromCache,
|
|
})
|
|
}
|
|
|
|
if len(missingRequiredInputs) > 0 {
|
|
return nil, MissingInputsError{missingRequiredInputs}
|
|
}
|
|
|
|
return inputs, nil
|
|
}
|
|
|
|
func (step *TaskStep) containerSpec(logger lager.Logger, state RunState, imageSpec runtime.ImageSpec, config atc.TaskConfig, metadata db.ContainerMetadata) (runtime.ContainerSpec, error) {
|
|
env := step.metadata.TaskEnv()
|
|
env = append(env, config.Params.Env()...)
|
|
|
|
containerSpec := runtime.ContainerSpec{
|
|
TeamID: step.metadata.TeamID,
|
|
TeamName: step.metadata.TeamName,
|
|
JobID: step.metadata.JobID,
|
|
StepName: step.plan.Name,
|
|
|
|
ImageSpec: imageSpec,
|
|
Env: env,
|
|
Type: metadata.Type,
|
|
|
|
Dir: metadata.WorkingDirectory,
|
|
Hermetic: step.plan.Hermetic,
|
|
User: config.Run.User,
|
|
}
|
|
|
|
var err error
|
|
containerSpec.Inputs, err = step.containerInputs(logger, state.ArtifactRepository(), config, metadata)
|
|
if err != nil {
|
|
return runtime.ContainerSpec{}, err
|
|
}
|
|
|
|
containerSpec.Caches = make([]string, len(config.Caches))
|
|
for i, cache := range config.Caches {
|
|
containerSpec.Caches[i] = cache.Path
|
|
}
|
|
|
|
containerSpec.Outputs = make(runtime.OutputPaths, len(config.Outputs))
|
|
for _, output := range config.Outputs {
|
|
containerSpec.Outputs[output.Name] = ensureTrailingSlash(artifactPath(metadata.WorkingDirectory, output.Name, output.Path))
|
|
}
|
|
|
|
if config.Limits != nil {
|
|
containerSpec.Limits.CPU = (*uint64)(config.Limits.CPU)
|
|
containerSpec.Limits.Memory = (*uint64)(config.Limits.Memory)
|
|
}
|
|
|
|
return containerSpec, nil
|
|
}
|
|
|
|
func (step *TaskStep) workerSpec(config atc.TaskConfig) worker.Spec {
|
|
return worker.Spec{
|
|
Platform: config.Platform,
|
|
Tags: step.plan.Tags,
|
|
TeamID: step.metadata.TeamID,
|
|
}
|
|
}
|
|
|
|
func (step *TaskStep) registerOutputs(logger lager.Logger, repository *build.Repository, config atc.TaskConfig, volumeMounts []runtime.VolumeMount, metadata db.ContainerMetadata) {
|
|
logger.Debug("registering-outputs", lager.Data{"outputs": config.Outputs})
|
|
|
|
for _, output := range config.Outputs {
|
|
outputName := output.Name
|
|
if destinationName, ok := step.plan.OutputMapping[output.Name]; ok {
|
|
outputName = destinationName
|
|
}
|
|
|
|
outputPath := artifactPath(metadata.WorkingDirectory, output.Name, output.Path)
|
|
|
|
for _, mount := range volumeMounts {
|
|
if filepath.Clean(mount.MountPath) == filepath.Clean(outputPath) {
|
|
repository.RegisterArtifact(build.ArtifactName(outputName), mount.Volume, false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (step *TaskStep) registerCaches(ctx context.Context, config atc.TaskConfig, volumeMounts []runtime.VolumeMount, metadata db.ContainerMetadata) error {
|
|
logger := lagerctx.FromContext(ctx)
|
|
for _, cacheConfig := range config.Caches {
|
|
var ttl time.Duration
|
|
if cacheConfig.TTL != "" {
|
|
var err error
|
|
ttl, err = time.ParseDuration(cacheConfig.TTL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid cache ttl %q: %w", cacheConfig.TTL, err)
|
|
}
|
|
}
|
|
|
|
if ttl == 0 && step.defaultTaskCacheTTL > 0 {
|
|
ttl = step.defaultTaskCacheTTL
|
|
}
|
|
|
|
for _, volumeMount := range volumeMounts {
|
|
mountPath := resolvePath(metadata.WorkingDirectory, cacheConfig.Path)
|
|
if filepath.Clean(volumeMount.MountPath) == mountPath {
|
|
logger.Debug("initializing-cache", lager.Data{
|
|
"cache": cacheConfig.Path,
|
|
})
|
|
err := volumeMount.Volume.InitializeTaskCache(
|
|
ctx,
|
|
step.metadata.JobID,
|
|
step.plan.Name,
|
|
cacheConfig.Path,
|
|
step.plan.Privileged,
|
|
ttl,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type ociRunConfig struct {
|
|
Cmd []string `json:"cmd"`
|
|
EntryPoint []string `json:"entrypoint"`
|
|
}
|
|
|
|
// Try and use OCI image Entrypoint and/or CMD. This relies on the
|
|
// registry-image resource providing this information when it converts OCI
|
|
// images into our rootfs format.
|
|
func (step *TaskStep) getOciEntrypoint(ctx context.Context, imageSpec runtime.ImageSpec) (string, []string, error) {
|
|
path := ""
|
|
args := []string{}
|
|
|
|
if imageSpec.ImageArtifact == nil {
|
|
// there's no image artifact for Windows and Darwin tasks
|
|
return path, args, nil
|
|
}
|
|
|
|
metadataStream, err := step.streamer.StreamFile(ctx, imageSpec.ImageArtifact, "metadata.json")
|
|
if err != nil {
|
|
if err == baggageclaim.ErrFileNotFound {
|
|
return path, args, FileNotFoundError{
|
|
Name: imageSpec.ImageArtifact.Handle(),
|
|
FilePath: "metadata.json",
|
|
}
|
|
}
|
|
return path, args, err
|
|
}
|
|
|
|
defer metadataStream.Close()
|
|
|
|
imageMetadata, err := io.ReadAll(metadataStream)
|
|
if err != nil {
|
|
return path, args, err
|
|
}
|
|
|
|
imgCmd := ociRunConfig{}
|
|
err = json.Unmarshal(imageMetadata, &imgCmd)
|
|
if err != nil {
|
|
return path, args, fmt.Errorf("error parsing metadata.json from rootfs: %s", err.Error())
|
|
}
|
|
|
|
if len(imgCmd.EntryPoint) >= 1 {
|
|
path = imgCmd.EntryPoint[0]
|
|
}
|
|
|
|
if len(imgCmd.EntryPoint) > 1 {
|
|
args = append(args, imgCmd.EntryPoint[1:]...)
|
|
}
|
|
|
|
if path == "" && len(imgCmd.Cmd) >= 1 {
|
|
path = imgCmd.Cmd[0]
|
|
if len(imgCmd.Cmd) > 1 {
|
|
args = append(args, imgCmd.Cmd[1:]...)
|
|
}
|
|
} else {
|
|
args = append(args, imgCmd.Cmd...)
|
|
}
|
|
|
|
return path, args, err
|
|
}
|
|
|
|
func artifactPath(workingDir string, name string, path string) string {
|
|
subdir := path
|
|
if path == "" {
|
|
subdir = name
|
|
}
|
|
|
|
return resolvePath(workingDir, subdir)
|
|
}
|
|
|
|
func resolvePath(workingDir string, path string) string {
|
|
if filepath.IsAbs(path) {
|
|
return path
|
|
}
|
|
path = strings.ReplaceAll(path, "..", "")
|
|
return filepath.Join(workingDir, path)
|
|
}
|
|
|
|
func ensureTrailingSlash(path string) string {
|
|
if strings.HasSuffix(path, "/") {
|
|
return path
|
|
}
|
|
return path + "/"
|
|
}
|