aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/workflows
diff options
context:
space:
mode:
Diffstat (limited to 'internal/workflows')
-rw-r--r--internal/workflows/Containerfile93
-rwxr-xr-xinternal/workflows/build.sh5
-rw-r--r--internal/workflows/doc.go162
-rw-r--r--internal/workflows/main.go151
-rw-r--r--internal/workflows/release.go50
-rw-r--r--internal/workflows/seccomp.patch18
-rw-r--r--internal/workflows/step.go60
-rw-r--r--internal/workflows/test.go86
8 files changed, 625 insertions, 0 deletions
diff --git a/internal/workflows/Containerfile b/internal/workflows/Containerfile
new file mode 100644
index 00000000..82ce8820
--- /dev/null
+++ b/internal/workflows/Containerfile
@@ -0,0 +1,93 @@
+FROM docker.gitea.com/runner-images:ubuntu-latest
+
+RUN rm /var/run && ln -sf ../run /var && mkdir /var/empty
+RUN add-apt-repository -y ppa:savoury1/pipewire && \
+ apt-get update && apt-get install -y \
+ msft-golang \
+ libmount-dev \
+ acl \
+ sway \
+ xwayland \
+ x11-xserver-utils \
+ xdg-dbus-proxy \
+ libnotify-bin \
+ pipewire \
+ pipewire-pulse \
+ wireplumber- \
+ pulseaudio-utils \
+ foot \
+ xterm \
+ grim \
+ fuse3 \
+ fsmark && \
+ apt-get clean && rm -rf /var/lib/apt/lists/*
+
+RUN PACKAGES=" \
+ build-essential \
+ ninja-build \
+ bison \
+ libxcb-composite0-dev \
+ libxcb-ewmh-dev \
+ libxcb-icccm4-dev \
+ libxcb-res0-dev \
+ libjson-c-dev \
+ libpango1.0-dev \
+ libevdev-dev \
+ libudev-dev \
+ libpipewire-0.3-dev" && \
+ apt-get update && apt-get install -y $PACKAGES && \
+ VERSION="1.12.1" && \
+ curl -L "https://github.com/mesonbuild/meson/releases/download/$VERSION/meson-$VERSION.tar.gz" | \
+ tar -C /tmp -zx && \
+ MESON_DIR="/tmp/meson-$VERSION" && \
+ MESON="$MESON_DIR/meson.py" && \
+ VERSION="0.20.2" && \
+ curl "https://gitlab.freedesktop.org/wlroots/wlroots/-/archive/$VERSION/wlroots-${VERSION}.tar.bz2?ref_type=tags" | \
+ tar -C /tmp -jx && \
+ "$MESON" setup \
+ --wrap-mode=default \
+ -Dprefix=/usr \
+ -Dbuildtype=release \
+ -Dc_args='-w' \
+ -Dxwayland=enabled \
+ "/tmp/wlroots-$VERSION" build && \
+ "$MESON" compile -C build && \
+ "$MESON" install -C build && \
+ rm -rf build "/tmp/wlroots-$VERSION" && \
+ VERSION="1.32.0" && \
+ curl "https://gitlab.freedesktop.org/libinput/libinput/-/archive/$VERSION/libinput-$VERSION.tar.bz2?ref_type=tags" | \
+ tar -C /tmp -jx && \
+ "$MESON" setup \
+ -Dprefix=/usr \
+ -Dbuildtype=release \
+ -Dmtdev=false \
+ -Dlibwacom=false \
+ -Ddebug-gui=false \
+ "/tmp/libinput-$VERSION" build && \
+ "$MESON" compile -C build && \
+ "$MESON" install -C build && \
+ rm -rf build "/tmp/libinput-$VERSION" && \
+ VERSION="1.12" && \
+ curl -L "https://github.com/swaywm/sway/releases/download/$VERSION/sway-$VERSION.tar.gz" | \
+ tar -C /tmp -zx && \
+ "$MESON" setup \
+ -Dprefix=/usr \
+ -Dbuildtype=release \
+ "/tmp/sway-$VERSION" build && \
+ "$MESON" compile -C build && \
+ "$MESON" install -C build && \
+ rm -rf build "/tmp/sway-$VERSION" && \
+ VERSION='0.5.14' && \
+ curl "https://gitlab.freedesktop.org/pipewire/wireplumber/-/archive/${VERSION}/wireplumber-${VERSION}.tar.bz2" | \
+ tar -C /tmp -jx && \
+ "$MESON" setup \
+ -Dprefix=/usr \
+ -Dbuildtype=release \
+ "/tmp/wireplumber-${VERSION}" build && \
+ "$MESON" compile -C build && \
+ "$MESON" install -C build && \
+ rm -rf build "/tmp/wireplumber-${VERSION}" && \
+ apt-get remove -y $PACKAGES && \
+ apt-get autoremove -y && \
+ apt-get clean && rm -rf /var/lib/apt/lists/* && \
+ rm -rf "$MESON_DIR"
diff --git a/internal/workflows/build.sh b/internal/workflows/build.sh
new file mode 100755
index 00000000..2df8656e
--- /dev/null
+++ b/internal/workflows/build.sh
@@ -0,0 +1,5 @@
+#!/bin/sh -e
+
+TAG="git.gensokyo.uk/rosa/runner:latest"
+podman build -t "$TAG" --format docker "$(dirname -- "$0")"
+podman push "$TAG"
diff --git a/internal/workflows/doc.go b/internal/workflows/doc.go
new file mode 100644
index 00000000..e220ed67
--- /dev/null
+++ b/internal/workflows/doc.go
@@ -0,0 +1,162 @@
+//go:build !workflows
+
+/*
+The workflows program manages the .gitea directory to avoid having to write
+yaml. It is never invoked directly or built for the distribution tarball.
+
+This program is an internal detail of the hakurei project and is not usable
+on its own. It is not covered by the compatibility promise.
+
+# Configuring act_runner
+
+It is unreasonable and impractical to bootstrap Rosa OS each time a job runs.
+This section documents an ideal configuration for exposing the cmd/mbf CI
+service to all job containers, while avoiding some issues with the container
+setup documented upstream.
+
+Microsoft Github workflows expect a docker socket in the job container. This is
+problematic because docker has a hard time running inside a docker container.
+The Gitea act_runner simply bind mounts whatever socket it sees into the
+container. With a regular docker daemon, this allows not only a simple container
+escape, but also privilege escalation as unconstrained root in the init
+namespace. To mitigate this, set up an unprivileged podman daemon and expose its
+socket to the container instead. Since mountinfo always use credentials from the
+init user namespace, subordinate user and group ID must always be 100000.
+
+On Alpine Linux, this is achieved by:
+
+ # Configuration for /etc/init.d/podman
+
+ # See podman-system-service(1) for service description
+ # and available options.
+ #podman_opts="--time 0"
+
+ # API endpoint in URI form. Leave empty to use defaults.
+ podman_uri="unix:///srv/act_runner/podman"
+
+ # Setting root user will start rootful service.
+ # Use any other user for rootless mode.
+ podman_user="act_runner"
+
+ # Required by cmd/hakurei test suite.
+ rc_ulimit="-r 49"
+
+A symlink must then be created at /var/run/docker.sock pointing to the socket
+owned by the unprivileged daemon. This should be done in a /etc/local.d script.
+
+The container can be specified via docker-compose:
+
+ version: "3.8"
+ services:
+ runner:
+ restart: always
+ image: docker.io/gitea/act_runner:nightly
+ networks:
+ - act_runner
+ environment:
+ CONFIG_FILE: /config.yaml
+ GITEA_INSTANCE_URL: "https://git.gensokyo.uk"
+
+ GITEA_RUNNER_REGISTRATION_TOKEN: "${REGISTRATION_TOKEN}"
+ GITEA_RUNNER_NAME: "${RUNNER_NAME}"
+ volumes:
+ - ./config.yaml:/config.yaml
+ - ./data:/data
+ - ./podman:/var/run/docker.sock
+
+ networks:
+ act_runner:
+ name: act_runner
+
+Before starting the container, configure act_runner via config.yaml:
+
+ container:
+ network: act_runner
+ options: -e PAGER=cat -e MBF_CACHE_DIR=/rosa -e MBF_POISON_OPEN=1
+ -v /var/lib/rosa:/rosa
+ --security-opt='unmask=/proc/*'
+ --cap-add=SYS_ADMIN
+ --cap-add=SYS_PTRACE
+ --device=/dev/kvm
+ --device=/dev/fuse
+ valid_volumes:
+ - /var/lib/rosa
+
+ runner:
+ labels:
+ - 'rosa:docker://git.gensokyo.uk/rosa/runner:latest'
+
+where /var/lib/rosa is the absolute pathname of the cache directory in the init
+namespace. Setting MBF_POISON_OPEN enables cmd/mbf to run as root. It is also
+a good idea here to set runner.capacity to reflect the capacity of the guest, so
+jobs can be consumed quicker. Removing mount points covering /proc enables
+testing of cmd/hakurei. Exposing the fuse device and adding capability
+CAP_SYS_ADMIN enables testing of cmd/sharefs. Adding capability CAP_SYS_PTRACE
+enables dumping seccomp filters via ptrace on the patched kernel.
+
+Build a statically-linked cmd/mbf:
+
+ go build -trimpath \
+ -ldflags='-s -w
+ -buildid=
+ -linkmode external
+ -extldflags=-static' \
+ ./cmd/mbf
+
+Create a directory named "bin" in the cache directory and install the resulting
+binary.
+
+The cmd/mbf CI service should generally run as a separate user. To achieve this,
+change the socket permission to 0777 after the service starts. On Alpine Linux,
+this can be achieved by the init script:
+
+ #!/sbin/openrc-run
+ supervisor=supervise-daemon
+
+ name="Rosa OS cmd/mbf CI backend"
+ description="Listening service that services CI workloads from act_runner"
+
+ command=/var/lib/rosa/bin/mbf
+ command_args="-d /var/lib/rosa ci daemon"
+ command_user="${mbf_user:=mbf}"
+
+ output_logger="logger"
+ error_logger="logger"
+
+ depend() {
+ need sysfs
+ }
+
+ start_post() {
+ while [ ! -e /var/lib/rosa/ci ]; do sleep 0.1; done
+ chmod 0777 /var/lib/rosa/ci
+ }
+
+It is often a good idea to populate the cache from a mirror service before the
+first workflow job is started and re-populate it after every cmd/mbf update.
+
+# Configuring the kernel
+
+In order to attach to the container process, the sysctl kernel.yama.ptrace_scope
+must be set to 0. After which, apply the patch internal/workflows/seccomp.patch
+to your kernel sources, compile and install the new kernel. Refer to
+https://wiki.alpinelinux.org/wiki/Custom_Kernel if the guest runs Alpine Linux.
+If running podman or docker as root, the patch is not required. Do not apply
+this patch on a system meant to be secure.
+
+# Security
+
+The design of Microsoft Github workflows is inherently insecure: it requires
+internet access just to fetch actions, and these actions all expect internet
+access as well. Gitea, being a drop-in replacement, retains the same problems.
+
+The setup documented above already avoids the obvious container escape via the
+bind-mounted docker socket, but it is still easily possible to escape as the
+context of the unconstrained runner user. For this reason, the runner must
+reside in a hardened guest. Since internet access cannot be disabled, extra care
+must be taken when setting up the host firewall to prevent access to internal
+systems from the untrusted guest.
+*/
+package workflows
+
+//go:generate go run -tags=workflows .
diff --git a/internal/workflows/main.go b/internal/workflows/main.go
new file mode 100644
index 00000000..81b14a12
--- /dev/null
+++ b/internal/workflows/main.go
@@ -0,0 +1,151 @@
+//go:build workflows
+
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+)
+
+// KV represents a string to value pair.
+type KV[T any] struct {
+ K string
+ V T
+}
+
+// A Map represents an ordered set of key-value pairs.
+type Map[T any] []KV[T]
+
+// MarshalJSON encodes m as a key-value pair in JSON.
+func (m Map[T]) MarshalJSON() ([]byte, error) {
+ if m == nil || len(m) == 0 {
+ return []byte("{}"), nil
+ }
+
+ buf := bytes.NewBuffer(make([]byte, 0, len(m)<<5))
+ buf.WriteByte('{')
+
+ last := len(m) - 1
+ for i, pair := range m {
+ p, err := json.Marshal(pair.K)
+ if err != nil {
+ return nil, fmt.Errorf("key %q: %w", pair.K, err)
+ }
+ buf.Write(p)
+ buf.WriteByte(':')
+
+ p, err = json.Marshal(pair.V)
+ if err != nil {
+ return nil, fmt.Errorf("value of key %q: %w", pair.K, err)
+ }
+ buf.Write(p)
+
+ if i < last {
+ buf.WriteByte(',')
+ }
+ }
+
+ buf.WriteByte('}')
+ return buf.Bytes(), nil
+}
+
+// A Step is a task ran as part of a [Job].
+type Step struct {
+ // A name for the step.
+ Name string `json:"name,omitempty"`
+ // Selects an action to run as part of a step in the job.
+ Uses string `json:"uses,omitempty"`
+ // A unique identifier for the step.
+ ID string `json:"id,omitempty"`
+ // Prevent a step from running unless a condition is met.
+ If string `json:"if,omitempty"`
+ // Runs command-line programs that do not exceed 21,000 characters using the
+ // operating system's shell.
+ Run string `json:"run,omitempty"`
+ // Key-value pairs of input parameters defined by the action.
+ With Map[any] `json:"with,omitempty"`
+}
+
+// A Job is an entry in a [Workflow] document.
+type Job struct {
+ // Set a name for the job.
+ Name string `json:"name,omitempty"`
+ // Identifies any jobs that must complete successfully before this job will
+ // run.
+ Needs []string `json:"needs,omitempty"`
+ // Define the type of machine to run the job on.
+ On string `json:"runs-on,omitempty"`
+ // Create a map of outputs for a job.
+ Outputs Map[string] `json:"outputs,omitempty"`
+ // A sequence of tasks.
+ Steps []Step `json:"steps,omitempty"`
+}
+
+// Workflow describes a workflow document.
+type Workflow struct {
+ // The name of the workflow.
+ Name string `json:"name,omitempty"`
+ // The name for workflow runs generated from the workflow.
+ RunName string `json:"run-name,omitempty"`
+ // Define which events can cause the workflow to run.
+ On any `json:"on"`
+
+ // Named jobs part of the workflow run.
+ Jobs Map[Job] `json:"jobs,omitempty"`
+}
+
+// workflowsDir is the relative pathname of the workflows directory.
+const workflowsDir = "../../.gitea/workflows"
+
+// writeFile writes the action to the .gitea directory.
+func (w *Workflow) writeFile(name string) error {
+ f, err := os.OpenFile(
+ filepath.Join(workflowsDir, name+".yml"),
+ os.O_CREATE|os.O_EXCL|os.O_WRONLY,
+ 0444,
+ )
+ if err != nil {
+ return err
+ }
+
+ err = json.NewEncoder(f).Encode(w)
+ if closeErr := f.Close(); err == nil {
+ err = closeErr
+ }
+ return err
+}
+
+// removeOnce synchronises removal of stale workflow files.
+var removeOnce sync.Once
+
+// removeWorkflows removes existing workflow files once.
+func removeWorkflows() {
+ removeOnce.Do(func() {
+ if err := os.RemoveAll(workflowsDir); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ if err := os.Mkdir(workflowsDir, 0755); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ })
+}
+
+// mustWriteFile is like writeFile, but terminates the program if a non-nil
+// error is returned.
+func (w *Workflow) mustWriteFile(name string) (_ struct{}) {
+ removeWorkflows()
+
+ if err := w.writeFile(name); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+ return
+}
+
+func main() {}
diff --git a/internal/workflows/release.go b/internal/workflows/release.go
new file mode 100644
index 00000000..a2f4c687
--- /dev/null
+++ b/internal/workflows/release.go
@@ -0,0 +1,50 @@
+//go:build workflows
+
+package main
+
+var _ = (&Workflow{
+ Name: "Release",
+ On: Map[any]{{"push", Map[[]string]{{"tags", []string{"v*"}}}}},
+
+ Jobs: []KV[Job]{
+ {"release", Job{
+ Name: "Create release",
+ On: "rosa",
+
+ Steps: []Step{
+ {
+ Name: "Checkout",
+ Uses: "actions/checkout@v4",
+
+ With: []KV[any]{
+ {"fetch-depth", 0},
+ {"fetch-tags", true},
+ },
+ },
+
+ {
+ Name: "Request distribution",
+ ID: "dist",
+ Run: "/rosa/bin/mbf ci dist -o result " +
+ ". \"$(cat cmd/dist/VERSION)-act\" && " +
+ "git tag " +
+ "--list " +
+ "--format='%(contents:body)' " +
+ "\"$(cat cmd/dist/VERSION)\" >> " +
+ "\"/tmp/release-body\"",
+ },
+
+ {
+ Name: "Create release",
+ Uses: "https://gitea.com/actions/gitea-release-action@v1.3.7",
+
+ With: []KV[any]{
+ {"files", "result/hakurei-**"},
+ {"body_path", "/tmp/release-body"},
+ {"draft", true},
+ },
+ },
+ },
+ }},
+ },
+}).mustWriteFile("release")
diff --git a/internal/workflows/seccomp.patch b/internal/workflows/seccomp.patch
new file mode 100644
index 00000000..ddabc71e
--- /dev/null
+++ b/internal/workflows/seccomp.patch
@@ -0,0 +1,18 @@
+diff --git a/kernel/seccomp.c b/kernel/seccomp.c
+index 25f62867a16d..7b63ccc8daf4 100644
+--- a/kernel/seccomp.c
++++ b/kernel/seccomp.c
+@@ -2216,8 +2216,12 @@ long seccomp_get_filter(struct task_struct *task, unsigned long filter_off,
+ struct seccomp_filter *filter;
+ struct sock_fprog_kern *fprog;
+ long ret;
++ struct user_namespace *user_ns = current_user_ns();
+
+- if (!capable(CAP_SYS_ADMIN) ||
++ if (in_userns(user_ns, task_cred_xxx(task, user_ns))) {
++ if (!ns_capable(user_ns, CAP_SYS_ADMIN))
++ return -EACCES;
++ } else if (!capable(CAP_SYS_ADMIN) ||
+ current->seccomp.mode != SECCOMP_MODE_DISABLED) {
+ return -EACCES;
+ }
diff --git a/internal/workflows/step.go b/internal/workflows/step.go
new file mode 100644
index 00000000..3458c0ea
--- /dev/null
+++ b/internal/workflows/step.go
@@ -0,0 +1,60 @@
+//go:build workflows
+
+package main
+
+// checkout is the standard actions/checkout step.
+var checkout = Step{Name: "Checkout", Uses: "actions/checkout@v4"}
+
+// newUploadArtifact returns an actions/upload-artifact step uploading
+// everything in the result directory as the specified name.
+func newUploadArtifact(display, name string) Step {
+ return Step{
+ Name: "Upload " + display,
+ Uses: "actions/upload-artifact@v3",
+
+ With: Map[any]{
+ {"name", name},
+ {"path", "result/*"},
+
+ {"retention-days", 1},
+ },
+ }
+}
+
+// newCIRequest returns a step for requesting a mbf CI task.
+func newCIRequest(display, name, id string) Step {
+ return Step{
+ Name: "Request " + display,
+ ID: id,
+ Run: "HAKUREI_REV=\"$(git rev-parse --short HEAD)\" && " +
+ "/rosa/bin/mbf ci " + name + " " +
+ ". \"$(cat cmd/dist/VERSION)-$HAKUREI_REV\" && " +
+ "echo \"rev=$HAKUREI_REV\" >> \"$GITHUB_OUTPUT\"",
+ }
+}
+
+var (
+ // dist is a step to obtain a hakurei distribution.
+ dist = newCIRequest("distribution", "dist -o /tmp/dist", "dist")
+ // _dist is like dist, but with race instrument.
+ _dist = newCIRequest("distribution", "race -o /tmp/dist", "dist")
+
+ // install is a step to unpack and deploy a hakurei distribution in /tmp/dist.
+ install = Step{
+ Name: "Install hakurei",
+ Run: "HAKUREI_VERSION=\"$(cat cmd/dist/VERSION)\" && " +
+ "tar -C /tmp/dist " +
+ "-xf /tmp/dist/hakurei-$HAKUREI_VERSION*-amd64.tar.gz && " +
+ "/tmp/dist/hakurei-$HAKUREI_VERSION*-amd64/install.sh && " +
+ "rm -rf /tmp/dist && " +
+ "sudo -u ubuntu hakurei version",
+ }
+)
+
+// newTestsuite returns a step for running an integration test suite.
+func newTestsuite(name, prefix string) Step {
+ return Step{
+ Name: "Compile and run test suite",
+ Run: prefix + "go run -tags=testsuite ./cmd/" + name,
+ }
+}
diff --git a/internal/workflows/test.go b/internal/workflows/test.go
new file mode 100644
index 00000000..b0819916
--- /dev/null
+++ b/internal/workflows/test.go
@@ -0,0 +1,86 @@
+//go:build workflows
+
+package main
+
+var _ = (&Workflow{
+ Name: "Test",
+ On: []any{"push"},
+
+ Jobs: Map[Job]{
+ {"hakurei", Job{
+ Name: "Hakurei",
+ On: "rosa",
+
+ Steps: []Step{
+ checkout,
+ dist,
+ install,
+ newTestsuite("hakurei/testsuite", "unshare -n "),
+ },
+ }},
+
+ {"hakurei-race", Job{
+ Name: "Hakurei (with race instrument)",
+ On: "rosa",
+
+ Steps: []Step{
+ checkout,
+ _dist,
+ install,
+ newTestsuite("hakurei/testsuite", "unshare -n "),
+ },
+ }},
+
+ {"sandbox", Job{
+ Name: "Sandbox",
+ On: "rosa",
+
+ Steps: []Step{
+ checkout,
+ dist,
+ install,
+ newTestsuite("hakurei/testsuite/sandbox", "unshare -n "),
+ },
+ }},
+
+ {"sandbox-race", Job{
+ Name: "Sandbox (with race instrument)",
+ On: "rosa",
+
+ Steps: []Step{
+ checkout,
+ _dist,
+ install,
+ newTestsuite("hakurei/testsuite/sandbox", "unshare -n "),
+ },
+ }},
+
+ {"sharefs", Job{
+ Name: "ShareFS",
+ On: "rosa",
+
+ Steps: []Step{
+ checkout,
+ dist,
+ install,
+
+ {
+ Name: "Mount sharefs",
+ Run: "useradd " +
+ "-ru 1023 " +
+ "-md /var/lib/sdcard " +
+ "-k /var/empty " +
+ "-s /sbin/nologin " +
+ "media_rw && install -dm0 /sdcard && " +
+ "sharefs " +
+ "-o rw,noexec,nosuid,nodev,noatime," +
+ "allow_other,mkdir,source=/var/lib/sdcard," +
+ "setuid=1023,setgid=1023 /sdcard",
+ },
+
+ newTestsuite("sharefs/testsuite", "sharefs -V && "),
+ newUploadArtifact("test output", "fs_mark"),
+ },
+ }},
+ },
+}).mustWriteFile("test")