1
0
mirror of https://github.com/woodpecker-ci/woodpecker.git synced 2026-06-03 16:35:37 +02:00
Files
woodpecker/cli/exec/exec.go
T
2026-05-27 14:16:25 +02:00

309 lines
9.2 KiB
Go

// Copyright 2022 Woodpecker Authors
//
// 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
//
// http://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.
package exec
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"codeberg.org/6543/xyaml"
"github.com/oklog/ulid/v2"
"github.com/urfave/cli/v3"
"go.uber.org/multierr"
"go.woodpecker-ci.org/woodpecker/v3/cli/common"
"go.woodpecker-ci.org/woodpecker/v3/cli/lint"
"go.woodpecker-ci.org/woodpecker/v3/pipeline"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/docker"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/kubernetes"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/local"
backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/builder"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler"
"go.woodpecker-ci.org/woodpecker/v3/pipeline/logging"
pipeline_runtime "go.woodpecker-ci.org/woodpecker/v3/pipeline/runtime"
pipeline_utils "go.woodpecker-ci.org/woodpecker/v3/pipeline/utils"
"go.woodpecker-ci.org/woodpecker/v3/shared/constant"
"go.woodpecker-ci.org/woodpecker/v3/shared/utils"
)
// Command exports the exec command.
var Command = &cli.Command{
Name: "exec",
Usage: "execute a local pipeline",
ArgsUsage: "[path/to/.woodpecker.yaml]",
Action: run,
Flags: slices.Concat(flags, docker.Flags, kubernetes.Flags, local.Flags),
}
var backends = []backend_types.Backend{
kubernetes.New(),
docker.New(),
local.New(),
}
func run(ctx context.Context, c *cli.Command) error {
return common.RunPipelineFunc(ctx, c, execFile, execDir)
}
// TODO: do parallel runs with output to multiple _windows_ e.g. tmux like
func execDir(ctx context.Context, c *cli.Command, dir string) error {
// TODO: respect pipeline dependency
repoPath := c.String("repo-path")
if repoPath != "" {
repoPath, _ = filepath.Abs(repoPath)
} else {
repoPath, _ = filepath.Abs(filepath.Dir(dir))
}
if runtime.GOOS == "windows" && c.String("backend-engine") != "local" {
repoPath = convertPathForWindows(repoPath)
}
var yamls []*builder.YamlFile
walkErr := filepath.Walk(dir, func(path string, info os.FileInfo, e error) error {
if e != nil {
return e
}
if info.Mode().IsRegular() && (strings.HasSuffix(info.Name(), ".yaml") || strings.HasSuffix(info.Name(), ".yml")) {
dat, err := os.ReadFile(path)
if err != nil {
return err
}
yamls = append(yamls, &builder.YamlFile{Name: path, Data: dat})
}
return nil
})
if walkErr != nil {
return walkErr
}
return runExec(ctx, c, yamls, repoPath)
}
func execFile(ctx context.Context, c *cli.Command, file string) error {
repoPath := c.String("repo-path")
if repoPath != "" {
repoPath, _ = filepath.Abs(repoPath)
} else {
repoPath, _ = filepath.Abs(filepath.Dir(file))
}
if runtime.GOOS == "windows" && c.String("backend-engine") != "local" {
repoPath = convertPathForWindows(repoPath)
}
dat, err := os.ReadFile(file)
if err != nil {
return err
}
return runExec(ctx, c, []*builder.YamlFile{{Name: file, Data: dat}}, repoPath)
}
func runExec(ctx context.Context, c *cli.Command, yamls []*builder.YamlFile, repoPath string) error {
// if we use the local backend we should signal to run at $repoPath
if c.String("backend-engine") == "local" {
local.CLIWorkaroundExecAtDir = repoPath
}
// collect secrets from flags
var secrets []compiler.Secret
for key, val := range c.StringMap("secrets") {
secrets = append(secrets, compiler.Secret{Name: key, Value: val})
}
if secretsFile := c.String("secrets-file"); secretsFile != "" {
fileContent, err := os.ReadFile(secretsFile)
if err != nil {
return err
}
var m map[string]string
if err := xyaml.Unmarshal(fileContent, &m); err != nil {
return err
}
for key, val := range m {
secrets = append(secrets, compiler.Secret{Name: key, Value: val})
}
}
// collect extra env vars from --env flags
pipelineEnv := make(map[string]string)
for _, env := range c.StringSlice("env") {
before, after, _ := strings.Cut(env, "=")
pipelineEnv[before] = after
}
privilegedPlugins := c.StringSlice("plugins-privileged")
// emulate server prefix for volume/network naming
prefix := "wp_" + ulid.Make().String()
// build compiler options — mirrors server behavior
compilerOpts := []compiler.Option{
compiler.WithEscalated(privilegedPlugins...),
compiler.WithNetworks(c.StringSlice("network")...),
compiler.WithPrefix(prefix),
compiler.WithProxy(compiler.ProxyOptions{
NoProxy: c.String("backend-no-proxy"),
HTTPProxy: c.String("backend-http-proxy"),
HTTPSProxy: c.String("backend-https-proxy"),
}),
compiler.WithLocal(c.Bool("local")),
compiler.WithNetrc(
c.String("netrc-username"),
c.String("netrc-password"),
c.String("netrc-machine"),
),
compiler.WithSecret(secrets...),
compiler.WithEnviron(pipelineEnv),
}
// configure volumes for local execution
volumes := c.StringSlice("volumes")
if c.Bool("local") {
compilerOpts = append(
compilerOpts,
compiler.WithWorkspace(
c.String("workspace-base"),
c.String("workspace-path"),
),
)
volumes = append(
volumes,
prefix+"_default:"+c.String("workspace-base"),
repoPath+":"+c.String("workspace-base")+"/"+c.String("workspace-path"),
)
} else {
compilerOpts = append(
compilerOpts,
compiler.WithWorkspace(
c.String("workspace-base"),
c.String("workspace-path"),
),
)
}
compilerOpts = append(compilerOpts, compiler.WithVolumes(volumes...))
// build the metadata once — the CLI has a single pipeline context for all
// workflows, so every workflow gets the same metadata.
baseMetadata, err := metadataFromContext(ctx, c, nil)
if err != nil {
return fmt.Errorf("could not create metadata: %w", err)
}
b := builder.PipelineBuilder{
Yamls: yamls,
Envs: pipelineEnv,
RepoTrusted: &metadata.TrustedConfiguration{
Network: c.Bool("repo-trusted-network"),
Volumes: c.Bool("repo-trusted-volumes"),
Security: c.Bool("repo-trusted-security"),
},
TrustedClonePlugins: constant.TrustedClonePlugins,
PrivilegedPlugins: privilegedPlugins,
CompilerOptions: compilerOpts,
// GetWorkflowMetadata provides per-workflow metadata. In the CLI there
// is no server context, so we derive it from the base metadata and
// populate the workflow name/matrix from the builder.Workflow.
GetWorkflowMetadata: func(w *builder.Workflow) metadata.Metadata {
m := *baseMetadata
m.Workflow = metadata.Workflow{
Name: w.Name,
Number: w.PID,
Matrix: w.Environ,
}
return m
},
}
items, err := b.Build()
if err != nil {
str, fmtErr := lint.FormatLintError("pipeline", err, false)
fmt.Print(str)
if fmtErr != nil {
return fmtErr
}
}
if len(items) == 0 {
return fmt.Errorf("no workflows to execute (all filtered out)")
}
backendCtx := context.WithValue(ctx, backend_types.CliCommand, c)
backendEngine, err := backend.FindBackend(backendCtx, backends, c.String("backend-engine"))
if err != nil {
return err
}
if _, err = backendEngine.Load(backendCtx); err != nil {
return err
}
var execErr error
// TODO: respect depends_on and run in parallel where possible
for _, item := range items {
fmt.Println("#", item.Workflow.Name)
pipelineCtx, cancel := context.WithTimeout(context.Background(), c.Duration("timeout"))
defer cancel()
pipelineCtx = utils.WithContextSigtermCallback(pipelineCtx, func() {
fmt.Printf("ctrl+c received, terminating workflow '%s'\n", item.Workflow.Name)
})
err := pipeline_runtime.New(
item.Config, backendEngine,
pipeline_runtime.WithContext(pipelineCtx), //nolint:contextcheck
pipeline_runtime.WithLogger(defaultLogger),
pipeline_runtime.WithDescription(map[string]string{
"CLI": "exec",
}),
).Run(ctx)
if err != nil {
fmt.Println(err)
execErr = multierr.Append(execErr, err)
}
fmt.Println("")
}
return execErr
}
// convertPathForWindows converts a path to use slash separators
// for Windows. If the path is a Windows volume name like C:, it
// converts it to an absolute root path starting with slash (e.g.
// C: -> /c). Otherwise it just converts backslash separators to
// slashes.
func convertPathForWindows(path string) string {
base := filepath.VolumeName(path)
// Check if path is volume name like C:
//nolint:mnd
if len(base) == 2 {
path = strings.TrimPrefix(path, base)
base = strings.ToLower(strings.TrimSuffix(base, ":"))
return "/" + base + filepath.ToSlash(path)
}
return filepath.ToSlash(path)
}
var defaultLogger = logging.Logger(func(step *backend_types.Step, rc io.ReadCloser) error {
logWriter := NewLineWriter(step.Name, step.UUID)
return pipeline_utils.CopyLineByLine(logWriter, rc, pipeline.MaxLogLineLength)
})