aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/workflows
diff options
context:
space:
mode:
Diffstat (limited to 'internal/workflows')
-rw-r--r--internal/workflows/doc.go150
-rw-r--r--internal/workflows/main.go151
-rw-r--r--internal/workflows/release.go52
-rw-r--r--internal/workflows/seccomp.patch18
-rw-r--r--internal/workflows/step.go124
-rw-r--r--internal/workflows/test.go150
6 files changed, 645 insertions, 0 deletions
diff --git a/internal/workflows/doc.go b/internal/workflows/doc.go
new file mode 100644
index 00000000..24e86b8c
--- /dev/null
+++ b/internal/workflows/doc.go
@@ -0,0 +1,150 @@
+//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"
+
+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
+ network_mode: host
+ environment:
+ CONFIG_FILE: /config.yaml
+ GITEA_INSTANCE_URL: "https://git.gensokyo.uk"
+ GITEA_RUNNER_LABELS: "rosa:docker://docker.gitea.com/runner-images:ubuntu-latest"
+
+ GITEA_RUNNER_REGISTRATION_TOKEN: "${REGISTRATION_TOKEN}"
+ GITEA_RUNNER_NAME: "${RUNNER_NAME}"
+ volumes:
+ - ./config.yaml:/config.yaml
+ - ./data:/data
+ - ./podman:/var/run/docker.sock
+
+Before starting the container, configure act_runner via config.yaml:
+
+ container:
+ 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
+
+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..f220c0f7
--- /dev/null
+++ b/internal/workflows/release.go
@@ -0,0 +1,52 @@
+//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{
+ fixup,
+
+ {
+ 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..fc95fd69
--- /dev/null
+++ b/internal/workflows/step.go
@@ -0,0 +1,124 @@
+//go:build workflows
+
+package main
+
+import "strings"
+
+// fixup is a step to work around a Podman regression introduced by a bad
+// container escape mitigation.
+var fixup = Step{
+ Name: "Fix container filesystem",
+ Run: "rm /var/run && ln -sf ../run /var",
+}
+
+// checkout is the standard actions/checkout step.
+var checkout = Step{Name: "Checkout", Uses: "actions/checkout@v4"}
+
+// toolchain is a step for setting up the Go toolchain.
+var toolchain = Step{
+ Name: "Set up Go toolchain",
+ Uses: "actions/setup-go@v6",
+
+ With: []KV[any]{
+ {"go-version-file", "go.mod"},
+ },
+}
+
+// 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\"",
+ }
+}
+
+// download is a step to obtain a hakurei distribution.
+var download = Step{
+ Name: "Download hakurei distribution",
+ ID: "download",
+ Uses: "actions/download-artifact@v3",
+
+ With: []KV[any]{
+ {"name", "dist-${{ needs.dist.outputs.rev }}"},
+ {"path", "result"},
+ },
+}
+
+// _download is like download, but with race instrument.
+var _download = Step{
+ Name: "Download hakurei distribution",
+ ID: "download",
+ Uses: "actions/download-artifact@v3",
+
+ With: []KV[any]{
+ {"name", "dist-${{ needs.dist-race.outputs.rev }}-race"},
+ {"path", "result"},
+ },
+}
+
+// install is a step to unpack and deploy a hakurei distribution created by a
+// step with identifier dist.
+var install = Step{
+ Name: "Install hakurei",
+ Run: "HAKUREI_VERSION=\"$(cat cmd/dist/VERSION)\" && " +
+ "tar xf result/hakurei-$HAKUREI_VERSION*-amd64.tar.gz && " +
+ "./hakurei-$HAKUREI_VERSION*-amd64/install.sh && " +
+ "sudo -u ubuntu hakurei version && " +
+ "mkdir /var/empty",
+}
+
+// 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 + "rm -rf result && " +
+ "go run -tags=testsuite ./cmd/" + name,
+ }
+}
+
+// newPackages returns a job for installing the specified packages with
+// best-effort caching. Package names must not contain spaces.
+func newPackages(rev int, repos []string, packages ...string) Step {
+ return Step{
+ Name: "Install packages",
+ Uses: "awalsh128/cache-apt-pkgs-action@v1",
+ With: []KV[any]{
+ {"add-repository", strings.Join(repos, " ")},
+ {"packages", strings.Join(packages, " ")},
+ {"version", rev},
+ {"execute_install_scripts", true},
+ },
+ }
+}
+
+// newNixOSTest returns a step for running the named NixOS test.
+func newNixOSTest(name string) Step {
+ return Step{
+ Name: "Run NixOS test",
+ Run: "nix build " +
+ "--out-link result " +
+ "--print-out-paths " +
+ "--print-build-logs " +
+ "./cmd/hakurei/testsuite#checks.x86_64-linux." + name,
+ }
+}
diff --git a/internal/workflows/test.go b/internal/workflows/test.go
new file mode 100644
index 00000000..1f9bba58
--- /dev/null
+++ b/internal/workflows/test.go
@@ -0,0 +1,150 @@
+//go:build workflows
+
+package main
+
+var _ = (&Workflow{
+ Name: "Test",
+ On: []any{"push"},
+
+ Jobs: Map[Job]{
+ {"hakurei-legacy", Job{
+ Name: "Hakurei (legacy)",
+ On: "nix",
+
+ Steps: []Step{
+ checkout,
+ newNixOSTest("hakurei"),
+ newUploadArtifact("test output", "hakurei-vm-output"),
+ },
+ }},
+
+ {"hakurei-race-legacy", Job{
+ Name: "Hakurei (legacy with race instrument)",
+ On: "nix",
+
+ Steps: []Step{
+ checkout,
+ newNixOSTest("race"),
+ newUploadArtifact("test output", "hakurei-race-vm-output"),
+ },
+ }},
+
+ {"sandbox", Job{
+ Name: "Sandbox",
+ On: "rosa",
+
+ Needs: []string{"dist"},
+ Steps: []Step{
+ fixup,
+ checkout,
+ toolchain,
+ newPackages(0, []string{"ppa:savoury1/pipewire"},
+ "libmount-dev",
+ "sway",
+ "xwayland",
+ "xdg-dbus-proxy",
+ "pipewire",
+ ),
+
+ download,
+ install,
+ newTestsuite("hakurei/testsuite/sandbox", ""),
+ },
+ }},
+
+ {"sandbox-race", Job{
+ Name: "Sandbox (with race instrument)",
+ On: "rosa",
+
+ Needs: []string{"dist-race"},
+ Steps: []Step{
+ fixup,
+ checkout,
+ toolchain,
+
+ newPackages(0, []string{"ppa:savoury1/pipewire"},
+ "libmount-dev",
+ "sway",
+ "xwayland",
+ "xdg-dbus-proxy",
+ "pipewire",
+ ),
+
+ _download,
+ install,
+ newTestsuite("hakurei/testsuite/sandbox", ""),
+ },
+ }},
+
+ {"sharefs", Job{
+ Name: "ShareFS",
+ On: "rosa",
+
+ Needs: []string{"dist"},
+ Steps: []Step{
+ fixup,
+ checkout,
+ toolchain,
+ newPackages(0, nil, "fuse3", "fsmark"),
+
+ download,
+ 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"),
+ },
+ }},
+
+ {"dist", Job{
+ Name: "Create distribution",
+ On: "rosa",
+
+ Outputs: []KV[string]{
+ {"rev", "${{ steps.dist.outputs.rev }}"},
+ },
+
+ Steps: []Step{
+ fixup,
+ checkout,
+ newCIRequest("distribution", "dist -o result", "dist"),
+ newUploadArtifact(
+ "distribution",
+ "dist-${{ steps.dist.outputs.rev }}",
+ ),
+ },
+ }},
+
+ {"dist-race", Job{
+ Name: "Create distribution (with race instrument)",
+ On: "rosa",
+
+ Outputs: []KV[string]{
+ {"rev", "${{ steps.dist.outputs.rev }}"},
+ },
+
+ Steps: []Step{
+ fixup,
+ checkout,
+ newCIRequest("distribution", "race -o result", "dist"),
+ newUploadArtifact(
+ "distribution",
+ "dist-${{ steps.dist.outputs.rev }}-race",
+ ),
+ },
+ }},
+ },
+}).mustWriteFile("test")