Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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."
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<p align="center">
<img src="pipelines.png" alt="CI/CD pipelines — build, test, and deploy runs on a Miabi runner with live per-step logs" width="900"/>
</p>

## Configuration (environment)

| Variable | Required | Meaning |
Expand All @@ -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.
14 changes: 12 additions & 2 deletions executor_buildkit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
74 changes: 68 additions & 6 deletions executor_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions executor_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
119 changes: 119 additions & 0 deletions executor_docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Binary file added pipelines.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 15 additions & 1 deletion proto/proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dockerfile> <context>`, 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.
Expand Down
Loading
Loading