-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexecutor_common.go
More file actions
282 lines (258 loc) · 8.88 KB
/
Copy pathexecutor_common.go
File metadata and controls
282 lines (258 loc) · 8.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
/*
* Copyright 2026 Jonas Kaninda
* SPDX-License-Identifier: Apache-2.0
*/
package main
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/jkaninda/logger"
"github.com/miabi-io/runner/proto"
)
// defaultBuilder is the CNB builder image used when a buildpack build supplies
// none (the control plane normally resolves and sends one). Overridable via
// MIABI_RUNNER_DEFAULT_BUILDER.
const defaultBuilder = "paketobuildpacks/builder-jammy-base"
// buildsDir is the parent directory for per-job workspaces
func buildsDir() string {
dir := strings.TrimSpace(os.Getenv("MIABI_RUNNER_BUILDS_DIR"))
if dir == "" {
return os.TempDir()
}
if err := os.MkdirAll(dir, 0o700); err != nil {
logger.Warn("MIABI_RUNNER_BUILDS_DIR unusable, falling back to temp dir", "dir", dir, "error", err)
return os.TempDir()
}
return dir
}
// resolveBuildMethod decides how a build step builds. No build config keeps the
// historical Dockerfile behavior; an explicit method is honored; "auto"/"" (with
// a config present) inspects the tree — a root Dockerfile selects Dockerfile,
// otherwise Cloud Native Buildpacks.
func resolveBuildMethod(dir string, cfg *proto.BuildConfig) string {
if cfg == nil {
return "dockerfile"
}
switch strings.ToLower(strings.TrimSpace(cfg.Method)) {
case "dockerfile":
return "dockerfile"
case "buildpack":
return "buildpack"
default: // "auto" or ""
if hasFile(dir, dockerfilePath(cfg)) {
return "dockerfile"
}
return "buildpack"
}
}
// dockerfilePath is the configured Dockerfile name, defaulting to "Dockerfile".
func dockerfilePath(cfg *proto.BuildConfig) string {
if cfg != nil && strings.TrimSpace(cfg.Dockerfile) != "" {
return cfg.Dockerfile
}
return "Dockerfile"
}
// contextDir resolves a build step's context to an absolute path under workdir.
//
// The control plane already rejects absolute paths and `..` escapes, but this is
// the process that actually runs the build: a runner is shared across a
// workspace's pipelines, and a pipeline file is editable by anyone who can push a
// branch. Re-checking here means a control plane that ever stops validating —
// or a runner driven by something else — cannot be talked into mounting the
// runner's own filesystem as a build context.
func contextDir(workdir string, cfg *proto.BuildConfig) (string, error) {
if cfg == nil || strings.TrimSpace(cfg.Context) == "" {
return workdir, nil
}
rel := strings.TrimSpace(cfg.Context)
if filepath.IsAbs(rel) {
return "", fmt.Errorf("build context %q must be relative to the repository root", rel)
}
abs := filepath.Join(workdir, rel)
// Join cleans the result, so a contained path keeps workdir as its prefix.
if abs != workdir && !strings.HasPrefix(abs, workdir+string(filepath.Separator)) {
return "", fmt.Errorf("build context %q escapes the repository", rel)
}
info, err := os.Stat(abs)
if err != nil {
return "", fmt.Errorf("build context %q not found in the repository", rel)
}
if !info.IsDir() {
return "", fmt.Errorf("build context %q is not a directory", rel)
}
return abs, nil
}
// sortedKeys returns a map's keys in order, so every argv this package builds is
// deterministic and therefore testable.
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// buildArgFlags renders Dockerfile ARG values as repeated flags. flag is the
// spelling the backend wants: "--build-arg" with the docker CLI, "--opt" with
// buildctl (whose values carry a "build-arg:" prefix, supplied by the caller).
func buildArgFlags(cfg *proto.BuildConfig, flag, prefix string) []string {
if cfg == nil || len(cfg.BuildArgs) == 0 {
return nil
}
out := make([]string, 0, len(cfg.BuildArgs)*2)
for _, k := range sortedKeys(cfg.BuildArgs) {
out = append(out, flag, prefix+k+"="+cfg.BuildArgs[k])
}
return out
}
// contextLabel renders a context directory for the build log, relative to the
// source root so the line reads like the pipeline file ("." or "services/api")
// rather than leaking the runner's own workdir layout.
func contextLabel(workdir, dir string) string {
rel, err := filepath.Rel(workdir, dir)
if err != nil || rel == "" {
return "."
}
return rel
}
// hasFile reports whether dir contains a regular file named name.
func hasFile(dir, name string) bool {
info, err := os.Stat(filepath.Join(dir, name))
return err == nil && !info.IsDir()
}
// packArgs assembles the `pack build` argv for a buildpack build. Env keys are
// sorted for a deterministic, testable argv.
func packArgs(tag, builder string, cfg *proto.BuildConfig) []string {
args := []string{
"build", tag,
"--path", ".",
"--builder", builder,
// inherit: use the runner's Docker host; trust-builder: skip the prompt for
// a known builder; if-not-present: reuse a cached builder image.
"--docker-host", "inherit",
"--trust-builder",
"--pull-policy", "if-not-present",
}
if cfg == nil {
return args
}
for _, bp := range cfg.Buildpacks {
if strings.TrimSpace(bp) != "" {
args = append(args, "--buildpack", bp)
}
}
for _, k := range sortedKeys(cfg.BuildEnv) {
args = append(args, "--env", k+"="+cfg.BuildEnv[k])
}
return args
}
// commander runs external commands; abstracted so the executors are unit-testable
// without a real docker/buildkit/git.
type commander interface {
// run executes name+args in dir, streaming combined output to log line by
// line, and returns the process exit code. A non-zero exit is (code, nil); a
// failure to start is (-1, err).
run(ctx context.Context, dir string, log func(string), name string, args ...string) (int, error)
// capture runs a command and returns its trimmed stdout.
capture(ctx context.Context, dir, name string, args ...string) (string, error)
// loginStdin runs a command with secret piped to its stdin (registry login).
loginStdin(ctx context.Context, secret, name string, args ...string) error
}
// gitCheckout clones a job's source and checks out its commit into workdir. The
// source URL is never logged (it may embed a credential).
func gitCheckout(ctx context.Context, cmd commander, gitBin, workdir string, job proto.JobSpec, log func(string)) error {
log("cloning source")
if code, err := cmd.run(ctx, "", log, gitBin, "clone", job.SourceURL, workdir); err != nil || code != 0 {
return fmt.Errorf("git clone failed (exit %d): %w", code, err)
}
if job.Commit != "" {
if code, err := cmd.run(ctx, workdir, log, gitBin, "checkout", "--detach", job.Commit); err != nil || code != 0 {
return fmt.Errorf("git checkout %s failed (exit %d): %w", job.Commit, code, err)
}
}
return nil
}
// writeDockerConfig writes a docker config.json into dir with a registry login,
// so daemonless builders (buildkit) authenticate their push with no login step.
// Returns the DOCKER_CONFIG dir. A blank token writes nothing (anonymous).
func writeDockerConfig(dir, registry, user, token string) error {
if token == "" || registry == "" {
return nil
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + token))
cfg := fmt.Sprintf(`{"auths":{%q:{"auth":%q}}}`, registry, auth)
return os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfg), 0o600)
}
// envMap indexes a KEY=VALUE slice.
func envMap(env []string) map[string]string {
m := make(map[string]string, len(env))
for _, e := range env {
if k, v, ok := strings.Cut(e, "="); ok {
m[k] = v
}
}
return m
}
// execCommander is the real commander over os/exec.
type execCommander struct{}
func (execCommander) run(ctx context.Context, dir string, log func(string), name string, args ...string) (int, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
w := &lineWriter{emit: log}
cmd.Stdout, cmd.Stderr = w, w
err := cmd.Run()
w.flush()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return ee.ExitCode(), nil // ran, exited non-zero — the step handles it
}
return -1, err // failed to start
}
return 0, nil
}
func (execCommander) capture(ctx context.Context, dir, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
out, err := cmd.Output()
return strings.TrimSpace(string(out)), err
}
func (execCommander) loginStdin(ctx context.Context, secret, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = strings.NewReader(secret)
return cmd.Run()
}
// lineWriter splits streamed output into lines and emits each via a callback.
type lineWriter struct {
emit func(string)
buf []byte
}
func (w *lineWriter) Write(p []byte) (int, error) {
w.buf = append(w.buf, p...)
for {
i := bytes.IndexByte(w.buf, '\n')
if i < 0 {
break
}
w.emit(string(w.buf[:i]))
w.buf = w.buf[i+1:]
}
return len(p), nil
}
func (w *lineWriter) flush() {
if len(w.buf) > 0 {
w.emit(string(w.buf))
w.buf = nil
}
}