diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..347a19c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +# Runs on every push to the mainline branches and on all pull requests. Tag +# pushes are handled by release.yml, so they are excluded here. +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: go vet + run: go vet ./... + + - name: Test + run: go test -race ./... + + no-docker-sdk: + # The runner shells out to the docker / pack / buildctl CLIs by design; it + # deliberately links NO Docker SDK. Linking one re-couples Miabi's module + # graph to github.com/docker/docker (deprecated, no v29) or moby/moby — the + # exact coupling the platform's SDK migration removes. See the "No Docker + # SDK" section of README.md for the full rationale. + name: No Docker SDK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: the runner must not link a Docker SDK + run: | + if go list -m all | grep -qE 'github.com/docker/docker|github.com/moby/moby'; then + echo "::error::The runner shells out to the docker CLI by design." + echo "::error::Linking a Docker SDK re-couples Miabi's module graph to it." + echo "::error::See the 'No Docker SDK' section of README.md before removing this guard." + go list -m all | grep -E 'github.com/docker/docker|github.com/moby/moby' + exit 1 + fi + echo "OK: no Docker SDK in the module graph." diff --git a/README.md b/README.md index d28924f..f07199a 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,29 @@ Register a runner in the Miabi UI (**Settings → Runners → Add runner**, or ```sh docker run -d --name miabi-runner \ - -e MIABI_CONTROL_URL=https://panel.example.com \ + -e MIABI_CONTROL_URL=https://miabi.example.com \ -e MIABI_RUNNER_TOKEN=mbr_xxxxxxxx \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /srv/miabi/builds:/srv/miabi/builds \ + -e MIABI_RUNNER_BUILDS_DIR=/srv/miabi/builds \ miabi/runner:latest ``` +The default `docker` backend builds and runs steps against a Docker daemon, so +the container needs the **host Docker socket** bind above (this is the runner's +*own* daemon — it is never exposed to the control plane). The builds-dir volume +is mounted at the same path inside and out so the per-step `-v` mounts resolve on +the host daemon (see `MIABI_RUNNER_BUILDS_DIR` below). Using the rootless +`buildkit` backend (`-e MIABI_RUNNER_BUILDER=buildkit`) needs neither. + Or as a binary: `MIABI_CONTROL_URL=… MIABI_RUNNER_TOKEN=… ./miabi-runner`. +### CI/CD pipelines in Miabi + +

+ CI/CD pipelines — build, test, and deploy runs on a Miabi runner with live per-step logs +

+ ## Configuration (environment) | Variable | Required | Meaning | @@ -50,4 +66,8 @@ root `Dockerfile` → Dockerfile build, otherwise buildpacks): extra buildpacks/build-env come from the job. The runner reports its OS/arch/version to the control plane on connect (used for -label/arch job scheduling). Licensed under Apache-2.0. +label/arch job scheduling). + + + +Licensed under Apache-2.0. diff --git a/executor_buildkit.go b/executor_buildkit.go index 06e78b5..5445f6a 100644 --- a/executor_buildkit.go +++ b/executor_buildkit.go @@ -112,19 +112,29 @@ func (r *buildkitJobRun) build(ctx context.Context, step proto.StepSpec, log fun ref := r.job.Repository + ":" + buildTag(r.job) meta := filepath.Join(r.workdir, ".miabi-build-metadata.json") + cdir, err := contextDir(r.workdir, step.Build) + if err != nil { + return StepResult{}, err + } buildArgs := []string{ "build", "--frontend", "dockerfile.v0", - "--local", "context=" + r.workdir, + "--local", "context=" + cdir, + // The dockerfile local stays the source root, and `filename` is resolved + // against it — so a Dockerfile outside the context still builds, matching + // `docker build -f` semantics rather than BuildKit's default of expecting + // the Dockerfile inside the context. "--local", "dockerfile=" + r.workdir, "--opt", "filename=" + dockerfilePath(step.Build), "--output", fmt.Sprintf("type=image,name=%s,push=true", ref), "--metadata-file", meta, } + // buildctl spells a Dockerfile ARG as `--opt build-arg:KEY=VALUE`. + buildArgs = append(buildArgs, buildArgFlags(step.Build, "--opt", "build-arg:")...) // Point BuildKit at the per-job docker config for its push credential. name, args := r.buildctlCmd(buildArgs) - log("building " + ref + " (rootless buildkit)") + log("building " + ref + " (rootless buildkit, context " + contextLabel(r.workdir, cdir) + ")") if code, err := r.e.cmd.run(ctx, r.workdir, log, name, args...); err != nil { return StepResult{}, fmt.Errorf("buildctl: %w", err) } else if code != 0 { diff --git a/executor_common.go b/executor_common.go index 644bada..5987b2a 100644 --- a/executor_common.go +++ b/executor_common.go @@ -68,6 +68,73 @@ func dockerfilePath(cfg *proto.BuildConfig) string { 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)) @@ -95,12 +162,7 @@ func packArgs(tag, builder string, cfg *proto.BuildConfig) []string { args = append(args, "--buildpack", bp) } } - keys := make([]string, 0, len(cfg.BuildEnv)) - for k := range cfg.BuildEnv { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { + for _, k := range sortedKeys(cfg.BuildEnv) { args = append(args, "--env", k+"="+cfg.BuildEnv[k]) } return args diff --git a/executor_docker.go b/executor_docker.go index bc25176..6be66e4 100644 --- a/executor_docker.go +++ b/executor_docker.go @@ -216,10 +216,19 @@ func (r *dockerJobRun) build(ctx context.Context, step proto.StepSpec, log func( default: // dockerfile buildArgs := []string{"build", "-t", tag} if df := dockerfilePath(step.Build); df != "Dockerfile" { + // -f is resolved from the working directory (the source root), not from + // the context — same as the docker CLI, so `dockerfile: docker/Dockerfile` + // with the default context behaves the way an operator expects. buildArgs = append(buildArgs, "-f", df) } - buildArgs = append(buildArgs, ".") - log("building " + tag) + cdir, err := contextDir(r.workdir, step.Build) + if err != nil { + return StepResult{}, err + } + buildArgs = append(buildArgs, buildArgFlags(step.Build, "--build-arg", "")...) + rel := contextLabel(r.workdir, cdir) + buildArgs = append(buildArgs, rel) + log("building " + tag + " (context " + rel + ")") name, args := r.authCmd(r.e.docker, buildArgs...) if code, err := r.e.cmd.run(ctx, r.workdir, log, name, args...); err != nil { return StepResult{}, fmt.Errorf("docker build: %w", err) diff --git a/executor_docker_test.go b/executor_docker_test.go index abdd064..a66e901 100644 --- a/executor_docker_test.go +++ b/executor_docker_test.go @@ -344,3 +344,122 @@ func TestDeployStepIsNoop(t *testing.T) { t.Errorf("deploy must not run any command, got %v", fc.calls) } } + +// The reported bug: `dockerfile:` reached the runner but a custom path has to +// become `-f`, and the context has to stay independent of it — a monorepo keeps +// docker/Dockerfile while still building from the root. +func TestBuildStepDockerfileAndContext(t *testing.T) { + cases := []struct { + name string + build *proto.BuildConfig + want string + }{ + { + "defaults are unchanged", + nil, + "docker build -t reg.example.com/ws-42/web:9 .", + }, + { + "custom dockerfile, default context", + &proto.BuildConfig{Method: "dockerfile", Dockerfile: "docker/Dockerfile"}, + "docker build -t reg.example.com/ws-42/web:9 -f docker/Dockerfile .", + }, + { + "custom context, default dockerfile", + &proto.BuildConfig{Method: "dockerfile", Context: "services/api"}, + "docker build -t reg.example.com/ws-42/web:9 services/api", + }, + { + "both, independently", + &proto.BuildConfig{Method: "dockerfile", Dockerfile: "docker/Dockerfile", Context: "services/api"}, + "docker build -t reg.example.com/ws-42/web:9 -f docker/Dockerfile services/api", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fc := &fakeCommander{digestOut: "reg.example.com/ws-42/web@sha256:cafebabe"} + e := newTestExecutor(t, fc) + job := proto.JobSpec{RunID: 9, Repository: "reg.example.com/ws-42/web", Commit: "abcdef1234567890"} + run, err := e.Begin(context.Background(), job, func(string) {}) + if err != nil { + t.Fatalf("Begin: %v", err) + } + defer run.Close() + // The context directory must exist in the checked-out source. + if tc.build != nil && tc.build.Context != "" { + if err := os.MkdirAll(filepath.Join(run.(*dockerJobRun).workdir, tc.build.Context), 0o755); err != nil { + t.Fatal(err) + } + } + if _, err := run.Step(context.Background(), + proto.StepSpec{Ordinal: 0, Name: "build", Uses: "build", Build: tc.build}, + func(string) {}); err != nil { + t.Fatalf("build step: %v", err) + } + if !fc.called(tc.want) { + t.Errorf("build command wrong:\n got %v\n want %q", fc.calls, tc.want) + } + }) + } +} + +// A context is joined against the checked-out source, so an absolute path or a +// climbing one would hand the build the runner's own filesystem. The runner is +// shared across a workspace's pipelines and a pipeline file is editable by anyone +// who can push a branch, so it re-checks rather than trusting the control plane. +func TestBuildStepRejectsEscapingContext(t *testing.T) { + for _, bad := range []string{"/etc", "../../etc", "sub/../../.."} { + t.Run(bad, func(t *testing.T) { + fc := &fakeCommander{digestOut: "reg.example.com/ws-42/web@sha256:cafebabe"} + e := newTestExecutor(t, fc) + job := proto.JobSpec{RunID: 10, Repository: "reg.example.com/ws-42/web", Commit: "abcdef1234567890"} + run, err := e.Begin(context.Background(), job, func(string) {}) + if err != nil { + t.Fatalf("Begin: %v", err) + } + defer run.Close() + _, err = run.Step(context.Background(), proto.StepSpec{ + Ordinal: 0, Name: "build", Uses: "build", + Build: &proto.BuildConfig{Method: "dockerfile", Context: bad}, + }, func(string) {}) + if err == nil { + t.Fatalf("accepted context %q — the build would read outside the repository", bad) + } + for _, c := range fc.calls { + if strings.HasPrefix(c, "docker build") { + t.Errorf("ran a build despite the bad context: %q", c) + } + } + }) + } +} + +// Build args must be deterministic (sorted) so the argv is testable, and must sit +// before the positional context — docker reads the context as the last argument. +func TestBuildStepBuildArgs(t *testing.T) { + fc := &fakeCommander{digestOut: "reg.example.com/ws-42/web@sha256:cafebabe"} + e := newTestExecutor(t, fc) + job := proto.JobSpec{RunID: 11, Repository: "reg.example.com/ws-42/web", Commit: "abcdef1234567890"} + run, err := e.Begin(context.Background(), job, func(string) {}) + if err != nil { + t.Fatalf("Begin: %v", err) + } + defer run.Close() + + if _, err := run.Step(context.Background(), proto.StepSpec{ + Ordinal: 0, Name: "build", Uses: "build", + Build: &proto.BuildConfig{ + Method: "dockerfile", + Dockerfile: "docker/Dockerfile", + BuildArgs: map[string]string{"VERSION": "1.2.3", "APP_ENV": "prod"}, + }, + }, func(string) {}); err != nil { + t.Fatalf("build step: %v", err) + } + + want := "docker build -t reg.example.com/ws-42/web:11 -f docker/Dockerfile " + + "--build-arg APP_ENV=prod --build-arg VERSION=1.2.3 ." + if !fc.called(want) { + t.Errorf("build command wrong:\n got %v\n want %q", fc.calls, want) + } +} diff --git a/pipelines.png b/pipelines.png new file mode 100644 index 0000000..1962a6a Binary files /dev/null and b/pipelines.png differ diff --git a/proto/proto.go b/proto/proto.go index ae63c35..3ea52c8 100644 --- a/proto/proto.go +++ b/proto/proto.go @@ -64,14 +64,28 @@ type StepSpec struct { type BuildConfig struct { // Method is "" | "auto" | "dockerfile" | "buildpack" (empty/auto auto-detects). Method string `json:"method,omitempty"` - // Dockerfile is the Dockerfile path for the dockerfile method (default "Dockerfile"). + // Dockerfile is the Dockerfile path for the dockerfile method (default "Dockerfile"), + // relative to the checked-out source root — NOT to Context, matching + // `docker build -f `, where the two are independent. Dockerfile string `json:"dockerfile,omitempty"` + // Context is the build context directory, relative to the checked-out source + // root (default: the root itself). A monorepo commonly keeps its Dockerfile in + // docker/ while still building from the root, so this must be settable apart + // from Dockerfile. + Context string `json:"context,omitempty"` // Builder is the CNB builder image for the buildpack method (empty = runner default). Builder string `json:"builder,omitempty"` // Buildpacks are extra buildpacks to apply (pack --buildpack). Buildpacks []string `json:"buildpacks,omitempty"` // BuildEnv is build-time env for the buildpack method (pack --env KEY=VALUE). BuildEnv map[string]string `json:"build_env,omitempty"` + // BuildArgs are Dockerfile ARG values for the dockerfile method + // (docker build --build-arg KEY=VALUE). The buildpack equivalent is BuildEnv. + // + // NOT for secrets: a build arg is recorded in the image's own history, so + // anyone who can pull the image can read it back. Pass credentials through the + // job's env instead. + BuildArgs map[string]string `json:"build_args,omitempty"` } // FrameType is the kind of report a runner sends back. diff --git a/runner.go b/runner.go index d6d9a8b..eb6a1e7 100644 --- a/runner.go +++ b/runner.go @@ -34,7 +34,7 @@ const connectPath = "/api/v1/runner/connect" // Config configures the runner runtime. type Config struct { - ControlURL string // e.g. https://panel.example.com + ControlURL string // e.g. https://miabi.example.com Token string // registration token (mbr_...) Insecure bool // skip TLS verification of the control plane Version string // runner build version, reported to the control plane