unos-repository

APK repository for unos
git clone git://git.finwo.net/misc/unos-repository
Log | Files | Refs | README

commit 940e2c245b4543acbc62b88ecbb796df085e9985
parent dc86b047637d6a93eb7bd3e4b17559450c3a6deb
Author: Robin Bron <robin.bron@yourhosting.nl>
Date:   Thu, 24 Sep 2026 23:30:34 +0200

Keys, packages, pipeline, etc

Diffstat:
A.conductor.yml | 399+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.gitignore | 13+++++++++++++
AREADME.md | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmk/README.md | 52+++++++++++++++++++++++++++++++++-------------------
Mmk/bootstrap-host.sh | 15++++++++++-----
Mmk/build-all.sh | 16+++++++++++-----
Mmk/build.sh | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Amk/check-keysize.sh | 43+++++++++++++++++++++++++++++++++++++++++++
Amk/check-revision.sh | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dmk/chroot.sh | 25-------------------------
Amk/ci-setup.sh | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmk/deps.sh | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Amk/gen-pipeline.sh | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmk/kernel.inc | 33++++++++++++++++++++++++---------
Mmk/keymgmt.sh | 4++++
Dmk/make-img.sh | 144-------------------------------------------------------------------------------
Amk/pc-fixup.sh | 29+++++++++++++++++++++++++++++
Amk/publish.sh | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmk/repo-index.sh | 16+++++++++++++++-
Dmk/rootfs.sh | 58----------------------------------------------------------
Dmk/run-qemu.sh | 195-------------------------------------------------------------------------------
Mmk/sign-key.inc | 23++++++++++++++++++++---
Amk/sysroot.sh | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/README.md | 11+++++++++++
Apackages/base-files/files/linkd.cnf | 20++++++++++++++++++++
Mpackages/base-files/files/network/ports | 2+-
Mpackages/base-files/template | 3++-
Mpackages/busybox/template | 2+-
Mpackages/libmnl/template | 3++-
Apackages/linkd/files/linkd-check | 3+++
Apackages/linkd/files/linkd-log-run | 4++++
Apackages/linkd/files/linkd-run | 9+++++++++
Apackages/linkd/template | 36++++++++++++++++++++++++++++++++++++
Mpackages/openssl/template | 7++++++-
Mpackages/unos-firstboot/files/unos-firstboot | 4++--
Mpackages/unos-firstboot/template | 2+-
Apackages/unos-keys/files/apk/unos-ci-62a2ab39@rtr.rsa.pub | 14++++++++++++++
Apackages/unos-keys/files/apk/unos-ci-62a2ab39@sat06.rsa.pub | 14++++++++++++++
Apackages/unos-keys/files/apk/unos-ci-62a2ab39@sat07.rsa.pub | 14++++++++++++++
Apackages/unos-keys/files/apk/unos-ci-62a2ab39@sat13.rsa.pub | 14++++++++++++++
Apackages/unos-keys/files/unos/unos-ci-62a2ab39@rtr.ed25519.pub | 3+++
Apackages/unos-keys/files/unos/unos-ci-62a2ab39@sat06.ed25519.pub | 3+++
Apackages/unos-keys/files/unos/unos-ci-62a2ab39@sat07.ed25519.pub | 3+++
Apackages/unos-keys/files/unos/unos-ci-62a2ab39@sat13.ed25519.pub | 3+++
Mpackages/unos-keys/template | 27+++++++++++++++++++++++----
Dpackages/unosd/files/unosd-check | 3---
Dpackages/unosd/files/unosd-log-run | 4----
Dpackages/unosd/files/unosd-run | 9---------
Dpackages/unosd/template | 28----------------------------
Mpackages/zlib/template | 3++-
Atests/helpers.sh | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/run.sh | 36++++++++++++++++++++++++++++++++++++
Atests/unit/test_ascii.sh | 36++++++++++++++++++++++++++++++++++++
Atests/unit/test_busybox.sh | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/test_deps.sh | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/test_packaging.sh | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
56 files changed, 2196 insertions(+), 539 deletions(-)

diff --git a/.conductor.yml b/.conductor.yml @@ -0,0 +1,399 @@ +# GENERATED by mk/gen-pipeline.sh -- do not edit by hand. +# +# Regenerate with: ./mk/gen-pipeline.sh --write +# Verified by: tests/unit/test_deps.sh +# +# One task per package, with `needs` taken from each template's makedepends, +# so a package never builds before something it links against. The list is +# written out rather than discovered because the conductor treats this file as +# data and will not execute repository code to build its graph. +# +# No mounts are provided and artifacts are not shared between tasks. Each build +# therefore seeds its sysroot from the published S3 repo (mk/sysroot.sh) and +# publishes its own result back (mk/publish.sh). `needs` is what guarantees a +# dependency is already published by the time a dependent starts. +# +# Credentials arrive as environment: BUCKET_ACCESS_KEY, BUCKET_SECRET_KEY, +# BUCKET_NAME, BUCKET_ENDPOINT (URL), BUCKET_REGION (region name). +# +# Signing comes from the unos-sign-key worker feature, which mounts the key and +# exports UNOS_KEY_APK_RSA_PRI. Each worker holds a distinct key, so the signer +# varies with scheduling and packages/unos-keys must trust all of them. + +version: 1 + +defaults: + image: debian:bookworm-slim + timeout: 2h + env: + DEBIAN_FRONTEND: noninteractive + +tasks: + check: + timeout: 30m + script: + - ./mk/ci-setup.sh + # Before deps.sh check, not after: muon is a declared hostmakedepends of + # apk-tools but is built here rather than installed from a distribution, + # so the check reports it missing until bootstrap has produced it. + - ./mk/bootstrap-host.sh + - ./mk/deps.sh check + - ./mk/gen-pipeline.sh --check + - ./tests/run.sh --unit + + pkg-zlib: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" zlib + - ./mk/check-revision.sh zlib + - ./mk/build.sh zlib + - ./mk/publish.sh --arch "$ARCH" zlib + + pkg-openssl: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" openssl + - ./mk/check-revision.sh openssl + - ./mk/build.sh openssl + - ./mk/publish.sh --arch "$ARCH" openssl + + pkg-apk-tools: + needs: + - pkg-zlib + - pkg-openssl + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" apk-tools + - ./mk/check-revision.sh apk-tools + - ./mk/build.sh apk-tools + - ./mk/publish.sh --arch "$ARCH" apk-tools + + pkg-base-files: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" base-files + - ./mk/check-revision.sh base-files + - ./mk/build.sh base-files + - ./mk/publish.sh --arch "$ARCH" base-files + + pkg-busybox: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" busybox + - ./mk/check-revision.sh busybox + - ./mk/build.sh busybox + - ./mk/publish.sh --arch "$ARCH" busybox + + pkg-curl: + needs: + - pkg-openssl + - pkg-zlib + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" curl + - ./mk/check-revision.sh curl + - ./mk/build.sh curl + - ./mk/publish.sh --arch "$ARCH" curl + + pkg-dosfstools: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" dosfstools + - ./mk/check-revision.sh dosfstools + - ./mk/build.sh dosfstools + - ./mk/publish.sh --arch "$ARCH" dosfstools + + pkg-e2fsprogs: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" e2fsprogs + - ./mk/check-revision.sh e2fsprogs + - ./mk/build.sh e2fsprogs + - ./mk/publish.sh --arch "$ARCH" e2fsprogs + + pkg-efivar: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" efivar + - ./mk/check-revision.sh efivar + - ./mk/build.sh efivar + - ./mk/publish.sh --arch "$ARCH" efivar + + pkg-popt: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" popt + - ./mk/check-revision.sh popt + - ./mk/build.sh popt + - ./mk/publish.sh --arch "$ARCH" popt + + pkg-efibootmgr: + needs: + - pkg-efivar + - pkg-popt + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" efibootmgr + - ./mk/check-revision.sh efibootmgr + - ./mk/build.sh efibootmgr + - ./mk/publish.sh --arch "$ARCH" efibootmgr + + pkg-glibc: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" glibc + - ./mk/check-revision.sh glibc + - ./mk/build.sh glibc + - ./mk/publish.sh --arch "$ARCH" glibc + + pkg-libstdcxx: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" libstdc++ + - ./mk/check-revision.sh libstdc++ + - ./mk/build.sh libstdc++ + - ./mk/publish.sh --arch "$ARCH" libstdc++ + + pkg-gptfdisk: + needs: + - pkg-e2fsprogs + - pkg-popt + - pkg-libstdcxx + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" gptfdisk + - ./mk/check-revision.sh gptfdisk + - ./mk/build.sh gptfdisk + - ./mk/publish.sh --arch "$ARCH" gptfdisk + + pkg-xz: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" xz + - ./mk/check-revision.sh xz + - ./mk/build.sh xz + - ./mk/publish.sh --arch "$ARCH" xz + + pkg-grub: + needs: + - pkg-xz + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" grub + - ./mk/check-revision.sh grub + - ./mk/build.sh grub + - ./mk/publish.sh --arch "$ARCH" grub + + pkg-libmnl: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" libmnl + - ./mk/check-revision.sh libmnl + - ./mk/build.sh libmnl + - ./mk/publish.sh --arch "$ARCH" libmnl + + pkg-libcap: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" libcap + - ./mk/check-revision.sh libcap + - ./mk/build.sh libcap + - ./mk/publish.sh --arch "$ARCH" libcap + + pkg-iproute2: + needs: + - pkg-libmnl + - pkg-libcap + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" iproute2 + - ./mk/check-revision.sh iproute2 + - ./mk/build.sh iproute2 + - ./mk/publish.sh --arch "$ARCH" iproute2 + + pkg-linkd: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" linkd + - ./mk/check-revision.sh linkd + - ./mk/build.sh linkd + - ./mk/publish.sh --arch "$ARCH" linkd + + pkg-linux-longterm: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" linux-longterm + - ./mk/check-revision.sh linux-longterm + - ./mk/build.sh linux-longterm + - ./mk/publish.sh --arch "$ARCH" linux-longterm + + pkg-linux-mainline: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" linux-mainline + - ./mk/check-revision.sh linux-mainline + - ./mk/build.sh linux-mainline + - ./mk/publish.sh --arch "$ARCH" linux-mainline + + pkg-linux-stable: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" linux-stable + - ./mk/check-revision.sh linux-stable + - ./mk/build.sh linux-stable + - ./mk/publish.sh --arch "$ARCH" linux-stable + + pkg-smartmontools: + needs: + - pkg-libstdcxx + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" smartmontools + - ./mk/check-revision.sh smartmontools + - ./mk/build.sh smartmontools + - ./mk/publish.sh --arch "$ARCH" smartmontools + + pkg-tinyssh: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" tinyssh + - ./mk/check-revision.sh tinyssh + - ./mk/build.sh tinyssh + - ./mk/publish.sh --arch "$ARCH" tinyssh + + pkg-unos-firstboot: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" unos-firstboot + - ./mk/check-revision.sh unos-firstboot + - ./mk/build.sh unos-firstboot + - ./mk/publish.sh --arch "$ARCH" unos-firstboot + + pkg-unos-keys: + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" unos-keys + - ./mk/check-revision.sh unos-keys + - ./mk/build.sh unos-keys + - ./mk/publish.sh --arch "$ARCH" unos-keys + + index: + needs: + - pkg-zlib + - pkg-openssl + - pkg-apk-tools + - pkg-base-files + - pkg-busybox + - pkg-curl + - pkg-dosfstools + - pkg-e2fsprogs + - pkg-efivar + - pkg-popt + - pkg-efibootmgr + - pkg-glibc + - pkg-libstdcxx + - pkg-gptfdisk + - pkg-xz + - pkg-grub + - pkg-libmnl + - pkg-libcap + - pkg-iproute2 + - pkg-linkd + - pkg-linux-longterm + - pkg-linux-mainline + - pkg-linux-stable + - pkg-smartmontools + - pkg-tinyssh + - pkg-unos-firstboot + - pkg-unos-keys + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + # Pull every published package down so the index describes the whole + # repo, not just whatever this container happened to build. + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" + - ./mk/repo-index.sh + - ./mk/publish.sh --arch "$ARCH" --index-only diff --git a/.gitignore b/.gitignore @@ -0,0 +1,13 @@ +# build trees (sources, work dirs, sysroot, host tools, output repo) +/build/ + +# local package-signing key selection (see mk/keymgmt.sh use) +/.sign-key + +# compiled driver helper +/mk/pax-tar + +# editor / os noise +*.swp +*~ +.DS_Store diff --git a/README.md b/README.md @@ -0,0 +1,135 @@ +unos-repository +=============== + +The UNOS package repository: every `.apk` that makes up the OS, the tooling +that builds and signs them, and the CI pipeline that publishes them. + +This repo produces a signed apk repository. It does not assemble a rootfs, an +image or an installer -- that is the `unos` repo, which consumes what is +published here over HTTP. The `unosd` daemon's source lives in its own repo and +arrives here as a tag-pinned tarball like any other dependency. + +Published to `https://repo.unos.finwo.net/<arch>`. + + +Layout +------ + + packages/<name>/template build recipe (sourced by mk/build.sh) + packages/<name>/files/ static files installed by the template + packages/<name>/patches/ *.patch, applied in order to WRKSRC + mk/ the build driver and its tooling + .conductor.yml CI pipeline, GENERATED from the dep graph + + +Building +-------- + + ./mk/bootstrap-host.sh build pinned muon + apk into build/host/bin + ./mk/build.sh <pkg> build one package + ./mk/build-all.sh build everything in dependency order + ./mk/repo-index.sh (re)generate and sign APKINDEX.tar.gz + +`mk/bootstrap-host.sh` must run first: `apk-tools` declares `muon` as a +hostmakedepends, and it is built here rather than installed from a +distribution, so `mk/deps.sh check` reports it missing until bootstrap has +produced it. + +See `mk/README.md` for the full tooling reference and `packages/README.md` for +the template format. + + +Where source comes from +----------------------- + +Two mutually exclusive mechanisms, kept apart deliberately: + +`distfiles=` is third-party upstream source. It **must** carry a matching +`checksum=` sha256; `mk/build.sh` refuses to build without one. We are trusting +a server nobody here controls, so the tarball is pinned by content. + +`srcfiles=` is our own source, fetched by tag from `git.finwo.net`. It carries +no checksum: the tag is the pin and TLS is the transport, and a forge +regenerates tag archives, so they are not byte-stable and a sha256 would fail +for reasons indistinguishable from tampering. Only `unosd` uses this today. + +Keeping them separate means "no checksum" can never silently apply to an +upstream tarball. + +Cached source lives in `build/work/sources/`, and is only fetched when absent. +`srcfiles` entries are cached under `<pkg>-<basename>` because these URLs end +in the tag, so the bare basename is often just `v0.1.0.tar.gz` and two +first-party packages at the same version would otherwise share one entry. + +**While git.finwo.net does not yet serve tag archives**, seed the cache by +hand and the fetch is skipped: + + git -C ../unosd archive --format=tar --prefix=unosd/ HEAD \ + | gzip -n > build/work/sources/unosd-v0.1.0.tar.gz + + +Signing +------- + +Packages and the index are signed with RSA-4096 (apk mandates RSA). The key is +resolved by `mk/sign-key.inc`, in order: + + $UNOS_KEY_APK_RSA_PRI provided by the unos-sign-key CI worker feature + $UNOS_SIGN_KEY manual override + ./.sign-key local selection, via mk/keymgmt.sh use + the single *.rsa in ~/.unos-keys/ + +`mk/build.sh` names the signature record after the key that signed it +(`.SIGN.RSA.<keyname>.rsa.pub`), and the apk client verifies it against +`/etc/apk/keys/<keyname>.rsa.pub`, shipped by `packages/unos-keys`. + +**Every CI worker has a distinct keypair**, so the signer is whichever worker +the conductor scheduled the build on. `packages/unos-keys` therefore trusts all +four. Ship fewer and verification fails for an arbitrary subset of packages +that changes run to run -- a failure that looks like corruption rather than a +missing key. Adding a worker means publishing `unos-keys` *before* that worker +builds anything. + +Any one worker can consequently sign packages every client trusts. The intended +end state is a release step that re-signs with a single key and merely accepts +these as inputs; it does not exist yet. + + +CI +-- + +`.conductor.yml` is **generated** -- do not edit it: + + ./mk/gen-pipeline.sh --write regenerate + ./mk/gen-pipeline.sh --check fail if stale (run by tests/unit/test_deps.sh) + +The conductor treats the pipeline as data and will not execute repository code +to build its task graph, so the per-package `needs` edges have to be written +out literally. Generating them from `makedepends` keeps the templates the +single source of truth; drift is a test failure rather than a package building +against a stale dependency. + +Tasks run in ephemeral containers with no mounts and no artifact sharing, so +each package seeds its sysroot from the published repo (`mk/sysroot.sh`) and +publishes its own result (`mk/publish.sh`). `needs` provides ordering only. + +The pipeline requires a worker advertising the **`unos-sign-key`** feature, +which mounts the signing key and exports `UNOS_KEY_APK_RSA_PRI`, +`UNOS_KEY_APK_RSA_PUB`, `UNOS_KEY_SUPERCOP_PRI` and `UNOS_KEY_SUPERCOP_PUB`. +Feature names are global to the conductor, which hosts more than UNOS, hence +the project prefix. A task requiring a feature no worker advertises is never +scheduled -- it does not fail, it silently never runs. + +S3 credentials arrive as `BUCKET_ACCESS_KEY`, `BUCKET_SECRET_KEY`, +`BUCKET_NAME`, `BUCKET_ENDPOINT` (a URL) and `BUCKET_REGION` (a region name). + + +Testing +------- + + ./tests/run.sh + +Graph validation, pipeline freshness, applet policy and packaging layout. Tests +that need a package built will skip rather than fail when it is absent, so the +suite is meaningful in a fresh container; anything needing a booted machine +lives in the `unos` repo. diff --git a/mk/README.md b/mk/README.md @@ -10,9 +10,28 @@ The whole of `build/` is generated output. Only `mk/` is source. a single amalgamated C file, so the chain terminates at a C compiler) and `apk`. `--check` verifies presence *and version* and is the gate CI should run first. Everything else assumes these exist. -- `deps.sh {list|vars|graph|order|check}` -- the build-dependency graph, read - from each template's `makedepends`. `order` is a topological sort; `check` - validates it (dangling deps, self-deps, cycles, missing hostmakedepends). +- `deps.sh {list|vars|graph|order|rdeps|changed|check}` -- the build-dependency + graph, read from each template's `makedepends`. `order` is a topological + sort; `check` validates it (dangling deps, self-deps, cycles, missing + hostmakedepends). +- `gen-pipeline.sh [--write|--check]` -- emits `.conductor.yml` from that + graph. The pipeline is data (the conductor will not run repository code to + build its task graph), so the per-package `needs` edges must be written out + literally; generating them keeps `makedepends` the single source of truth. + `--check` is run by `tests/unit/test_deps.sh`, so a template change that is + not reflected in the pipeline fails the suite. +- `ci-setup.sh [--check]` -- installs the build toolchain into an ephemeral CI + container, then verifies every `hostmakedepends` in the graph resolves. That + verification is the point: it stops the apt list falling behind a template. +- `sysroot.sh` -- seed `build/sysroot` from the published apk repo, so a CI + container can build one package without rebuilding the other 26. +- `publish.sh [--arch A] [--index-only] [pkg...]` -- upload `.apk` files and + `APKINDEX.tar.gz` to the S3 bucket serving the public repo, using + `curl --aws-sigv4` (no awscli or rclone). Reads `BUCKET_ACCESS_KEY`, + `BUCKET_SECRET_KEY`, `BUCKET_NAME`, `BUCKET_ENDPOINT` (URL) and + `BUCKET_REGION` (region name); it rejects those last two transposed, since + the natural symptom is an opaque signature mismatch. The index is always + uploaded last, so it never advertises packages that are not there yet. - `build-all.sh [--dry-run] [--skip-built] [pkg...]` -- build everything, or one package and its closure, in `deps.sh order`. - `build.sh <pkg>` -- the pipeline: source template -> check @@ -98,7 +117,6 @@ index. Current keys (dev estate, rotate before any release): ## Host dependencies sh, cc, curl or wget, tar, gzip, openssl, sha256sum, install, patch. -`unshare` (user namespaces) for `chroot.sh` and `rootfs.sh`. apk-tools and muon are **not** host prerequisites -- `bootstrap-host.sh` builds them into `build/host/bin` from pinned sources. @@ -116,20 +134,15 @@ A clean build from nothing is therefore: ./mk/bootstrap-host.sh # muon + apk into build/host/bin ./mk/build-all.sh # every package, in dependency order ./mk/repo-index.sh # signed APKINDEX -./mk/rootfs.sh --force # assemble rootfs/ +./mk/publish.sh # upload packages + index to S3 ``` -## Rootfs (`rootfs.sh`, `chroot.sh`) +## Assembling an OS -`mk/rootfs.sh [--force]` assembles `rootfs/` (ignored) with static apk -alone - no xbps, no foreign content: `unos-keys` via one explicit -`--allow-untrusted` (the trust seed), then `base-files`, `glibc`, -`busybox`, `unos-firstboot` fully trusted. -`mk/chroot.sh [--] <cmd>` enters it via `unshare --user --map-root-user ---mount --pid` with host `/proc` `/sys` `/dev` bound; everything evaporates -on exit, no root needed. Standing acceptance: ash runs, required applets -resolve, `ip` absent, `runsvdir` supervises (`sv status`/`sv stop`), -`tcpsvd` accepts, `firstboot` is `sh -n` clean. +Not here. `rootfs.sh`, `chroot.sh`, `make-img.sh`, `run-qemu.sh` and the +installer live in the `unos` repo, which consumes the published repository over +HTTP rather than reaching into `build/repo` on a shared filesystem. This repo +produces packages and stops there. ## Host apk-tools v3 (bootstrap input, pinned) @@ -141,15 +154,16 @@ meson** (Python is banned from our tooling; upstream supports muon): - dynamic, prefix `build/host/` (`-Dlua=disabled -Dpython=disabled -Dhelp=disabled -Ddocs=disabled`, rpath `$ORIGIN/../lib`): - `build/host/bin/apk`, used by `repo-index.sh` for index generation and by - `rootfs.sh` for assembly. + `build/host/bin/apk`, used by `repo-index.sh` for index generation. The + `unos` repo bootstraps its own copy for rootfs assembly. There is deliberately **no `apk.static`**. An earlier revision built one and justified it here as "the future target binary - no libcrypto needed on the switch". That was already wrong: UNOS packages `openssl`, so `libcrypto.so.3` and `libssl.so.3` are present on the switch and the target `apk` links them -dynamically. Its only consumer was `rootfs.sh`, which produces a byte-identical -rootfs with the dynamic binary. Building it cost 10.6MB against 2.0MB and +dynamically. Its only consumer was rootfs assembly, which produces a +byte-identical rootfs with the dynamic binary. Building it cost 10.6MB against +2.0MB and required `libcrypto.a`/`libssl.a`/`libz.a`/`libzstd.a` on the build host -- static libraries that most CI containers do not ship, turning a cosmetic preference into a hard bootstrap failure. If a static apk is ever wanted for diff --git a/mk/bootstrap-host.sh b/mk/bootstrap-host.sh @@ -7,14 +7,19 @@ # # Produces: # build/host/bin/muon pinned muon, bootstrapped from C (no Python) -# build/host/bin/apk dynamic apk v3, used by repo-index.sh/rootfs.sh +# build/host/bin/apk dynamic apk v3, used by repo-index.sh # +# This file is duplicated in the unos repo, which needs the same pinned apk to +# assemble a rootfs. The alternatives -- a submodule, or fetching a bootstrap +# script over the network in order to bootstrap -- are both worse than two +# copies of a file that only changes when a pin changes. +# # WHY THIS EXISTS # --------------- -# These three binaries are the input to every other build: mk/build.sh puts -# build/host/bin on PATH, repo-index.sh generates APKINDEX with `apk index`, -# and rootfs.sh installs with `apk add`. Until now they were built by hand +# These binaries are the input to every other build: mk/build.sh puts +# build/host/bin on PATH and repo-index.sh generates APKINDEX with +# `apk index`. Until now they were built by hand # following prose in mk/README.md, which meant a fresh checkout or a CI # container could not reproduce the toolchain at all. This script is that # prose, executable. @@ -197,7 +202,7 @@ msg "apk -> ${HOSTDIR}/bin/apk" # - no libcrypto needed on the switch". That justification was already false: # UNOS packages openssl, so libcrypto.so.3/libssl.so.3 are on the switch and # the target apk links them dynamically like everything else. Its only real -# consumer was mk/rootfs.sh, which works identically with the dynamic binary +# consumer was rootfs assembly, which works identically with the dynamic binary # (verified: same 24 packages, same rootfs). # # Building it cost 10.6MB versus 2.0MB and, worse, required libcrypto.a, diff --git a/mk/build-all.sh b/mk/build-all.sh @@ -17,16 +17,22 @@ ROOT=$(cd "${HERE}/.." && pwd) DRY=0 SKIP=0 +ARCH="x86_64" PKGS="" for a in "$@"; do case "$a" in --dry-run) DRY=1 ;; --skip-built) SKIP=1 ;; + --arch) ARCH="$2"; shift 2; continue ;; + --arch=*) ARCH="${a#--arch=}"; continue ;; -h|--help) sed -n '2,10p' "$0"; exit 0 ;; -*) echo "build-all.sh: unknown option: $a" >&2; exit 1 ;; *) PKGS="${PKGS} $a" ;; esac done +case "${ARCH}" in arm64) ARCH=aarch64 ;; esac +case "${ARCH}" in x86_64|aarch64) ;; *) die "unsupported ARCH: ${ARCH}" >&2; exit 1 ;; esac +export ARCH msg() { printf '==> %s\n' "$*"; } die() { printf 'build-all.sh: error: %s\n' "$*" >&2; exit 1; } @@ -50,15 +56,15 @@ skipped=0 for p in ${ORDER}; do n=$((n + 1)) - if [ "${SKIP}" = "1" ] && ls "${ROOT}/build/repo/x86_64/${p}"-*.apk >/dev/null 2>&1; then + if [ "${SKIP}" = "1" ] && ls "${ROOT}/build/repo/${ARCH}/${p}"-*.apk >/dev/null 2>&1; then msg "[${n}/${total}] ${p}: already built, skipping" skipped=$((skipped + 1)) continue fi - msg "[${n}/${total}] ${p}" - "${ROOT}/mk/build.sh" "${p}" || die "${p} failed" + msg "[${n}/${total}] ${p} (${ARCH})" + "${ROOT}/mk/build.sh" "${p}" "${ARCH}" || die "${p} failed" built=$((built + 1)) done -msg "built ${built}, skipped ${skipped}, of ${total}" -msg "now run ./mk/repo-index.sh to refresh APKINDEX" +msg "built ${built}, skipped ${skipped}, of ${total} (ARCH=${ARCH})" +msg "now run ./mk/repo-index.sh ${ARCH} to refresh APKINDEX" diff --git a/mk/build.sh b/mk/build.sh @@ -26,6 +26,13 @@ license= homepage= distfiles= checksum= +# srcfiles: first-party source fetched by immutable ref (tag) from our own +# forge. Deliberately NOT checksummed -- the tag is the pin and TLS is the +# transport. distfiles= stays reserved for third-party upstream releases, +# which must always carry a sha256, because there we are trusting a server +# nobody here controls. Keeping the two apart means "no checksum" can never +# silently apply to an upstream tarball. +srcfiles= depends= # makedepends: other UNOS packages whose headers/libs must be in build/sysroot # before this one compiles. Determines build order (see mk/deps.sh). @@ -74,8 +81,18 @@ vmkdir() { install -d "${DESTDIR}/$1"; } [ -n "${short_desc}" ] || die "template sets no short_desc" PKGVER="${version}-r${revision}" -ARCH="x86_64" +# ARCH: target arch, default x86_64. Conductor dispatches per-arch; local +# `ARCH=aarch64 ./mk/build.sh <pkg>` or `./mk/build.sh <pkg> aarch64` both work. +# Normalise arm64 -> aarch64. +if [ $# -ge 2 ]; then + case "$2" in x86_64|aarch64|arm64) ARCH="$2";; *) ARCH="${ARCH:-x86_64}";; esac +else + ARCH="${ARCH:-x86_64}" +fi +case "${ARCH}" in arm64) ARCH=aarch64 ;; esac +case "${ARCH}" in x86_64|aarch64) ;; *) die "unsupported ARCH: ${ARCH} (want x86_64 or aarch64)" ;; esac OUTDIR="${ROOT}/build/repo/${ARCH}" +export ARCH mkdir -p "${SRCDEST}" "${WORK}" "${OUTDIR}" "${SYSROOT}" @@ -100,18 +117,48 @@ for _m in ${makedepends}; do # Presence of a built .apk is the signal that its sysroot seed has been # installed; templates that other packages build against install twice # (DESTDIR + SYSROOT), so one implies the other. + # When sysroot is seeded via mk/sysroot.sh (CI), the .apk may not be + # present locally but its headers are in SYSROOT; check there as fallback. if ! ls "${OUTDIR}/${_m}"-*.apk >/dev/null 2>&1; then - die "makedepends '${_m}' has not been built. + # Fallback: if sysroot already has the dep's headers/.pc, allow it. + # This is the CI incremental path (sysroot seeded from published repo). + if [ -d "${SYSROOT}/usr/include" ] && [ -f "${SYSROOT}/usr/lib/pkgconfig/${_m}.pc" ]; then + msg "makedepends '${_m}': not built locally, but found in sysroot (CI seed)" + elif ls "${SYSROOT}/usr/include/${_m}" >/dev/null 2>&1; then + msg "makedepends '${_m}': not built locally, but found in sysroot" + else + die "makedepends '${_m}' has not been built. Build it first: ./mk/build.sh ${_m} - Or build in order: ./mk/build-all.sh ${pkgname}" + Or build in order: ./mk/build-all.sh ${pkgname} + Or seed sysroot: ./mk/sysroot.sh ${pkgname}" + fi fi done +# --- revision-bump guard --------------------------------------------------- +# Enforce "revision bump required" policy: if this exact version-rrev already +# exists in the repo, refuse unless UNOS_ALLOW_OVERWRITE=1 (local iteration). +if [ "${UNOS_ALLOW_OVERWRITE:-0}" != "1" ]; then + if [ -f "${OUTDIR}/${pkgname}-${PKGVER}.apk" ]; then + die "${pkgname}-${PKGVER}.apk already exists in ${OUTDIR}/ + bump revision in packages/${PKG}/template before rebuilding + (or UNOS_ALLOW_OVERWRITE=1 ./mk/build.sh ${PKG} to overwrite locally)" + fi +fi + # fresh staging every build: templates must be idempotent AND ghost-free rm -rf "${DESTDIR}" mkdir -p "${DESTDIR}" # --- fetch + verify --- +# A template uses one or the other, never both: whichever is set provides the +# primary tarball that WRKSRC is derived from, and having two sources would +# make that choice silent and arbitrary. +if [ -n "${distfiles}" ] && [ -n "${srcfiles}" ]; then + die "template sets both distfiles and srcfiles; use srcfiles for our own + tag-pinned source, distfiles for checksummed third-party releases" +fi + if [ -n "${distfiles}" ]; then # checksums are space-separated, in the same order as distfiles set -- ${checksum} @@ -127,11 +174,33 @@ if [ -n "${distfiles}" ]; then done fi +if [ -n "${srcfiles}" ]; then + [ -z "${checksum}" ] || die "srcfiles is tag-pinned and must not carry a checksum + (a tag archive is regenerated by the forge and is not byte-stable)" + for url in ${srcfiles}; do + # Namespaced by package on purpose. These URLs end in the tag, so the + # basename is typically just 'v0.1.0.tar.gz' -- two first-party packages + # at the same version would otherwise silently share one cache entry and + # build each other's source. + f=${SRCDEST}/${PKG}-$(basename "${url}") + if [ ! -f "${f}" ]; then + msg "fetching $(basename "${url}") for ${PKG} (first-party, tag-pinned)" + curl -fL -o "${f}" "${url}" || wget -O "${f}" "${url}" + fi + done +fi + # --- extract (first tarball) + patches --- -if [ -n "${distfiles}" ]; then - set -- ${distfiles} +if [ -n "${distfiles}" ] || [ -n "${srcfiles}" ]; then + if [ -n "${srcfiles}" ]; then + set -- ${srcfiles} + _tarball=${SRCDEST}/${PKG}-$(basename "$1") + else + set -- ${distfiles} + _tarball=${SRCDEST}/$(basename "$1") + fi mkdir -p "${WORK}/src" - tar -xf "${SRCDEST}/$(basename "$1")" -C "${WORK}/src" + tar -xf "${_tarball}" -C "${WORK}/src" # WRKSRC: single top-level dir if the tarball has exactly one, else src/ n=$(ls -A "${WORK}/src" | wc -l) if [ "${n}" = "1" ] && [ -d "${WORK}/src/$(ls -A "${WORK}/src")" ]; then @@ -163,17 +232,19 @@ msg "${pkgname}: do_install" do_install # --- sysroot .pc fixup (central convention; templates stop hand-rolling it) -# A sysroot .pc must point at the sysroot, not /. Rewriting only prefix= is -# not enough: many upstream .pc files spell libdir=/includedir= literally -# (efivar does; the build of efibootmgr then picked host headers). Fix -# every level, every .pc, every build. Idempotent. -for _pc in "${SYSROOT}"/usr/lib/pkgconfig/*.pc; do +# Delegates to mk/pc-fixup.sh so the same logic is reused by mk/sysroot.sh. +"${HERE}/pc-fixup.sh" "${SYSROOT}" + +# Verify it took, for every .pc rather than the two packages that used to +# assert it themselves. Those assertions lived in do_install, which runs +# *before* this fixup, so they could only ever pass on a sysroot left dirty by +# an earlier build -- they passed locally for months and failed on the first +# clean tree. A consumer that picks up an unfixed .pc silently compiles +# against host headers, which is the failure this guards. +for _pc in "${SYSROOT}"/usr/lib/pkgconfig/*.pc "${SYSROOT}"/usr/lib64/pkgconfig/*.pc; do [ -e "${_pc}" ] || continue - sed -i \ - -e "s|^prefix=.*|prefix=${SYSROOT}/usr|" \ - -e "s|^libdir=.*|libdir=${SYSROOT}/usr/lib|" \ - -e "s|^includedir=.*|includedir=${SYSROOT}/usr/include|" \ - "${_pc}" + grep -q "^prefix=${SYSROOT}/usr\$" "${_pc}" \ + || die "sysroot .pc not fixed: ${_pc} (mk/pc-fixup.sh did not rewrite prefix=)" done # --- signing key: UNOS_SIGN_KEY > .sign-key > single key in ~/.unos-keys --- @@ -224,7 +295,7 @@ emit_apk() { printf 'builddate = %s\n' "${SOURCE_DATE_EPOCH}" printf 'packager = %s\n' "UNOS build driver (mk/build.sh)" printf 'size = %s\n' "$(find "${_stage}" -type f -printf '%s\n' | awk '{s+=$1} END {print s+0}')" - printf 'arch = %s\n' "x86_64" + printf 'arch = %s\n' "${ARCH}" printf 'origin = %s\n' "${pkgname}" printf 'maintainer = %s\n' "${maintainer}" printf 'license = %s\n' "${license}" diff --git a/mk/check-keysize.sh b/mk/check-keysize.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# mk/check-keysize.sh - enforce 4096-bit minimum for RSA signing keys. +# +# ./mk/check-keysize.sh <pubkey-or-privkey> [minbits] +# ./mk/check-keysize.sh --check <pubkey> # for conductor allowlist insert +# +# Used by the conductor admin API and by mk/keymgmt.sh new (defence in depth). +# Rejects keys < 4096 bits; accepts >= 4096. +set -eu + +MINBITS="${2:-4096}" +KEY="${1:-}" + +if [ "${KEY}" = "--check" ]; then + KEY="${2:-}" + MINBITS="${3:-4096}" +fi + +[ -n "${KEY}" ] || { echo "usage: check-keysize.sh <keyfile> [minbits]" >&2; exit 1; } +[ -f "${KEY}" ] || { echo "check-keysize.sh: not found: ${KEY}" >&2; exit 1; } + +# Detect whether this is a private or public key by trying both. +bits="" +if openssl rsa -in "${KEY}" -text -noout 2>/dev/null | grep -q "Private-Key"; then + bits=$(openssl rsa -in "${KEY}" -text -noout 2>/dev/null | sed -n 's/.*Private-Key: (\([0-9]*\) bit.*/\1/p' | head -1) +elif openssl rsa -pubin -in "${KEY}" -text -noout 2>/dev/null | grep -q "Public-Key"; then + bits=$(openssl rsa -pubin -in "${KEY}" -text -noout 2>/dev/null | sed -n 's/.*Public-Key: (\([0-9]*\) bit.*/\1/p' | head -1) +else + # Try pkey (handles both) + bits=$(openssl pkey -in "${KEY}" -text -noout 2>/dev/null | sed -n 's/.*Private-Key: (\([0-9]*\) bit.*/\1/p; s/.*Public-Key: (\([0-9]*\) bit.*/\1/p' | head -1) +fi + +if [ -z "${bits}" ]; then + echo "check-keysize.sh: could not determine key size for ${KEY}" >&2 + exit 1 +fi + +if [ "${bits}" -lt "${MINBITS}" ]; then + echo "check-keysize.sh: key too small: ${bits} bits < ${MINBITS} minimum: ${KEY}" >&2 + exit 1 +fi + +echo "check-keysize.sh: OK ${bits} bits >= ${MINBITS}: ${KEY}" diff --git a/mk/check-revision.sh b/mk/check-revision.sh @@ -0,0 +1,89 @@ +#!/bin/sh +# mk/check-revision.sh - refuse to build if version-rrev already published. +# +# ./mk/check-revision.sh <pkg> [arch] [--repo <repo-url>] +# +# Checks whether <pkg>-<version>-r<revision>.apk already exists in the +# published repo. If it does, the template must bump revision before it can +# be built/published. This enforces the "revision bump required" policy. +# +# Repo resolution: --repo flag, else local build/repo/<arch>, else +# https://repo.unos.finwo.net/<arch> (if reachable). +# +# Used by mk/build.sh (gate) and by the conductor (pre-dispatch check). +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/.." && pwd) + +PKG="" +ARCH="x86_64" +REPO="" + +while [ $# -gt 0 ]; do + case "$1" in + --arch) ARCH="$2"; shift 2 ;; + --arch=*) ARCH="${1#--arch=}"; shift ;; + --repo) REPO="$2"; shift 2 ;; + --repo=*) REPO="${1#--repo=}"; shift ;; + -h|--help) sed -n '1,25p' "$0"; exit 0 ;; + -*) echo "check-revision.sh: unknown option: $1" >&2; exit 1 ;; + *) if [ -z "${PKG}" ]; then PKG="$1"; else + case "$1" in x86_64|aarch64|arm64) ARCH="$1";; *) REPO="$1";; esac + fi; shift ;; + esac +done + +[ -n "${PKG}" ] || { echo "usage: check-revision.sh <pkg> [arch] [--repo <url>]" >&2; exit 1; } + +case "${ARCH}" in arm64) ARCH=aarch64 ;; esac +case "${ARCH}" in x86_64|aarch64) ;; *) echo "check-revision.sh: unsupported ARCH ${ARCH}" >&2; exit 1 ;; esac + +TEMPLATE="${ROOT}/packages/${PKG}/template" +[ -f "${TEMPLATE}" ] || { echo "check-revision.sh: no template: ${TEMPLATE}" >&2; exit 1; } + +# Read version/revision from template (same subshell pattern as deps.sh) +eval "$( ( + version=; revision=0 + SYSROOT=; DESTDIR=; WRKSRC=; FILESDIR=; WORK=; SRCDEST=; UNOS_MKDIR= + die() { :; } ; msg() { :; } ; vinstall() { :; } ; vmkdir() { :; } + . "${TEMPLATE}" >/dev/null 2>&1 || true + printf 'VERSION=%s\nREVISION=%s\n' "${version}" "${revision}" +) )" + +[ -n "${VERSION}" ] || { echo "check-revision.sh: template sets no version" >&2; exit 1; } + +PKGVER="${VERSION}-r${REVISION}" +APK_NAME="${PKG}-${PKGVER}.apk" + +# Check local repo first +if [ -z "${REPO}" ]; then + if [ -f "${ROOT}/build/repo/${ARCH}/${APK_NAME}" ]; then + echo "check-revision.sh: ${APK_NAME} already exists in build/repo/${ARCH}/" >&2 + echo " bump revision in packages/${PKG}/template before rebuilding" >&2 + exit 1 + fi + # Also check published repo via local path if it exists + REPO="${ROOT}/build/repo/${ARCH}" +fi + +# If REPO is a URL, use curl HEAD; if local dir, check file existence. +case "${REPO}" in + http://*|https://*) + url="${REPO%/}/${APK_NAME}" + if curl -fsI "${url}" >/dev/null 2>&1; then + echo "check-revision.sh: ${APK_NAME} already published at ${url}" >&2 + echo " bump revision in packages/${PKG}/template" >&2 + exit 1 + fi + ;; + *) + if [ -f "${REPO}/${APK_NAME}" ]; then + echo "check-revision.sh: ${APK_NAME} already exists in ${REPO}/" >&2 + echo " bump revision in packages/${PKG}/template before rebuilding" >&2 + exit 1 + fi + ;; +esac + +echo "check-revision.sh: OK ${APK_NAME} not yet published" diff --git a/mk/chroot.sh b/mk/chroot.sh @@ -1,25 +0,0 @@ -#!/bin/sh -# mk/chroot.sh - enter rootfs/ in a user+mount namespace and run a command. -# -# Usage: ./mk/chroot.sh [--] <cmd> [args...] (default: /bin/busybox sh) -# -# Unprivileged: unshare maps us to root inside, mounts are namespace-private -# and evaporate on exit. /proc /sys /dev come from the host via binds. -set -eu - -HERE=$(cd "$(dirname "$0")" && pwd) -ROOT=$(cd "${HERE}/.." && pwd) -ROOTFS="${ROOT}/rootfs" - -[ -d "${ROOTFS}/bin" ] || { echo "chroot.sh: no rootfs (run mk/rootfs.sh)" >&2; exit 1; } -if [ "${1:-}" = "--" ]; then shift; fi -if [ $# = 0 ]; then set -- /bin/busybox sh; fi - -exec unshare --user --map-root-user --mount --propagation private --pid --fork \ - sh -c ' - R="$1"; shift - mount -t proc proc "$R/proc" - mount --rbind /sys "$R/sys" - mount --rbind /dev "$R/dev" - exec chroot "$R" "$@" - ' chroot-sh "${ROOTFS}" "$@" diff --git a/mk/ci-setup.sh b/mk/ci-setup.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# mk/ci-setup.sh - install the build toolchain into an ephemeral CI container. +# +# ./mk/ci-setup.sh install, then verify the graph is satisfied +# ./mk/ci-setup.sh --check verify only, install nothing +# +# Pipelines run in a stock debian image with no mounts, so every task starts +# from nothing and installs what it needs. Keeping that here rather than +# inlining apt lines in .conductor.yml means one place to change when a +# template gains a hostmakedepends, and it can verify itself afterwards. +# +# Two groups, for different reasons: +# +# toolchain what packages/*/template declare as hostmakedepends. The +# authority is mk/deps.sh, and the check at the end proves this +# list still covers it. +# host libs what mk/bootstrap-host.sh links the host `apk` against +# (openssl, zlib, zstd). Not hostmakedepends of any package -- +# they are needed before any package builds at all. +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/.." && pwd) + +CHECK_ONLY=0 +[ "${1:-}" = "--check" ] && CHECK_ONLY=1 + +msg() { printf '==> %s\n' "$*"; } +die() { printf 'ci-setup.sh: error: %s\n' "$*" >&2; exit 1; } + +# `dep` (https://github.com/finwo/dep) is not packaged by any distribution; +# linkd uses it to resolve its own C dependencies. Pinned deliberately. +DEP_VERSION="${DEP_VERSION:-0.1.0}" + +if [ "${CHECK_ONLY}" = "0" ]; then + command -v apt-get >/dev/null 2>&1 \ + || die "no apt-get; this script targets the debian image the pipeline uses" + + msg "installing build toolchain" + apt-get update -qq + apt-get install -y -qq --no-install-recommends \ + build-essential \ + pkg-config \ + ca-certificates \ + curl \ + wget \ + git \ + xz-utils \ + bzip2 \ + file \ + bc \ + bison \ + flex \ + gawk \ + make \ + perl \ + python3 \ + sed \ + libssl-dev \ + zlib1g-dev \ + libzstd-dev \ + >/dev/null + rm -rf /var/lib/apt/lists/* + + if ! command -v dep >/dev/null 2>&1; then + msg "installing dep ${DEP_VERSION}" + curl -fsSL "https://github.com/finwo/dep/releases/download/v${DEP_VERSION}/dep-linux-x64" \ + -o /usr/local/bin/dep || die "could not fetch dep ${DEP_VERSION}" + chmod 0755 /usr/local/bin/dep + fi +fi + +# Verify rather than assume: every hostmakedepends in the graph must now +# resolve. This is what stops the list above silently falling behind a +# template that gained a new build tool. +msg "verifying hostmakedepends are satisfied" +missing="" +for p in $("${ROOT}/mk/deps.sh" list); do + for h in $("${ROOT}/mk/deps.sh" vars "$p" | sed -n 's/^hostmakedepends: *//p'); do + # muon is built by bootstrap-host.sh, not installed from a distribution. + [ "${h}" = "muon" ] && continue + command -v "${h}" >/dev/null 2>&1 || missing="${missing} ${h}" + done +done +if [ -n "${missing}" ]; then + die "hostmakedepends not satisfied:${missing} + Add them to the apt list in mk/ci-setup.sh." +fi + +msg "toolchain ready" diff --git a/mk/deps.sh b/mk/deps.sh @@ -5,6 +5,8 @@ # ./mk/deps.sh vars <pkg> that package's declared dependencies # ./mk/deps.sh order [pkg...] topological build order (all if no args) # ./mk/deps.sh graph machine-readable "pkg<TAB>dep" edges +# ./mk/deps.sh rdeps <pkg> reverse makedepends (who needs <pkg>) +# ./mk/deps.sh changed <base> <head> packages changed between two refs # ./mk/deps.sh check validate the whole graph, exit 1 if bad # # WHY THIS EXISTS @@ -63,6 +65,8 @@ read_vars() { SYSROOT=; DESTDIR=; WRKSRC=; FILESDIR=; WORK=; SRCDEST=; UNOS_MKDIR= die() { :; } msg() { :; } + vinstall() { :; } + vmkdir() { :; } # shellcheck disable=SC1090 . "${t}" >/dev/null 2>&1 || true printf '%s|%s|%s\n' "${depends}" "${makedepends}" "${hostmakedepends}" @@ -88,6 +92,69 @@ cmd_graph() { done } +cmd_rdeps() { + # reverse dependencies: which packages have makedepends on <pkg> + [ $# -ge 1 ] || die "usage: deps.sh rdeps <pkg>" + target="$1" + for p in $(list_pkgs); do + for d in $(field "${p}" 2); do + if [ "${d}" = "${target}" ]; then + echo "${p}" + break + fi + done + done +} + +cmd_changed() { + # changed packages between two git refs: maps git diff paths to package names. + # ./mk/deps.sh changed <base> <head> + # + # Only packages/ and mk/ are considered, because that is all this repo + # holds. First-party source (linkd) now arrives as a tag-pinned tarball, so + # editing it produces no diff here at all: bumping the template's version + # is what marks it changed, and that is a packages/ path like any other. + # Compares <base>...<head> (three-dot, like GitHub PR diff). Falls back to + # two-dot if base is not an ancestor. + base="${1:-}" + head_ref="${2:-}" + [ -n "${base}" ] || die "usage: deps.sh changed <base> <head>" + [ -n "${head_ref}" ] || die "usage: deps.sh changed <base> <head>" + + # git diff --name-only base...head, or base..head if ... yields nothing due + # to non-ancestor base. + changed_files=$(git -C "${ROOT}" diff --name-only "${base}...${head_ref}" 2>/dev/null || true) + if [ -z "${changed_files}" ]; then + changed_files=$(git -C "${ROOT}" diff --name-only "${base}..${head_ref}" 2>/dev/null || true) + fi + + pkgs="" + mk_changed=0 + + for f in ${changed_files}; do + case "${f}" in + packages/*) + # packages/<name>/template, patches/*, files/* + pkg=$(printf '%s' "${f}" | cut -d/ -f2) + if [ -f "${PKGDIR}/${pkg}/template" ]; then + case " ${pkgs} " in *" ${pkg} "*) ;; *) pkgs="${pkgs} ${pkg}" ;; esac + fi + ;; + mk/*) + mk_changed=1 + ;; + esac + done + + for p in ${pkgs}; do echo "${p}"; done + + # mk/ changes do not automatically trigger a full rebuild; they signal the + # conductor to warn. Emit a sentinel on stderr so the caller can decide. + if [ "${mk_changed}" = "1" ]; then + echo "note: mk/ changed - full rebuild not triggered automatically" >&2 + fi +} + # Depth-first topological sort. State lives in a temp dir because POSIX sh has # no associative arrays: <tmp>/seen.<pkg> marks done, <tmp>/path.<pkg> marks # "currently on the recursion stack", which is how cycles are detected. @@ -171,6 +238,8 @@ case "${1:-}" in vars) shift; cmd_vars "$@" ;; graph) cmd_graph ;; order) shift; cmd_order "$@" ;; + rdeps) shift; cmd_rdeps "$@" ;; + changed) shift; cmd_changed "$@" ;; check) cmd_check ;; - *) sed -n '2,10p' "$0"; exit 1 ;; + *) sed -n '2,12p' "$0"; exit 1 ;; esac diff --git a/mk/gen-pipeline.sh b/mk/gen-pipeline.sh @@ -0,0 +1,160 @@ +#!/bin/sh +# mk/gen-pipeline.sh - emit .conductor.yml from the dependency graph. +# +# ./mk/gen-pipeline.sh print the pipeline +# ./mk/gen-pipeline.sh --write write it to .conductor.yml +# ./mk/gen-pipeline.sh --check exit 1 if .conductor.yml is out of date +# +# WHY GENERATED +# ------------- +# The conductor does not run repository code while building its task graph -- +# the pipeline file is data, read at the pushed commit. So the per-package +# dependency edges have to be written out literally. Maintaining ~30 tasks and +# their `needs` by hand against packages/*/template guarantees they drift, and +# the failure mode is a package building before the library it links against. +# +# Generating instead means makedepends stays the single source of truth, and +# tests/unit/test_deps.sh fails if the committed file no longer matches. +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/.." && pwd) +OUT="${ROOT}/.conductor.yml" + +MODE=print +case "${1:-}" in + --write) MODE=write ;; + --check) MODE=check ;; + -h|--help) sed -n '2,8p' "$0"; exit 0 ;; + "") ;; + *) echo "gen-pipeline.sh: unknown argument: $1" >&2; exit 1 ;; +esac + +# Task names allow letters, digits, underscore, dot and hyphen, so `libstdc++` +# cannot be used directly. `+` becomes `x` (libstdc++ -> libstdcxx, the +# conventional spelling) rather than a hyphen, which would render it +# `libstdc--` and read like a typo. Anything else unexpected becomes a hyphen. +task_name() { + printf 'pkg-%s' "$(printf '%s' "$1" | sed 's/+/x/g' | tr -c 'A-Za-z0-9_.-' '-')" +} + +emit() { + cat <<'HEADER' +# GENERATED by mk/gen-pipeline.sh -- do not edit by hand. +# +# Regenerate with: ./mk/gen-pipeline.sh --write +# Verified by: tests/unit/test_deps.sh +# +# One task per package, with `needs` taken from each template's makedepends, +# so a package never builds before something it links against. The list is +# written out rather than discovered because the conductor treats this file as +# data and will not execute repository code to build its graph. +# +# No mounts are provided and artifacts are not shared between tasks. Each build +# therefore seeds its sysroot from the published S3 repo (mk/sysroot.sh) and +# publishes its own result back (mk/publish.sh). `needs` is what guarantees a +# dependency is already published by the time a dependent starts. +# +# Credentials arrive as environment: BUCKET_ACCESS_KEY, BUCKET_SECRET_KEY, +# BUCKET_NAME, BUCKET_ENDPOINT (URL), BUCKET_REGION (region name). +# +# Signing comes from the unos-sign-key worker feature, which mounts the key and +# exports UNOS_KEY_APK_RSA_PRI. Each worker holds a distinct key, so the signer +# varies with scheduling and packages/unos-keys must trust all of them. + +version: 1 + +defaults: + image: debian:bookworm-slim + timeout: 2h + env: + DEBIAN_FRONTEND: noninteractive + +tasks: + check: + timeout: 30m + script: + - ./mk/ci-setup.sh + # Before deps.sh check, not after: muon is a declared hostmakedepends of + # apk-tools but is built here rather than installed from a distribution, + # so the check reports it missing until bootstrap has produced it. + - ./mk/bootstrap-host.sh + - ./mk/deps.sh check + - ./mk/gen-pipeline.sh --check + - ./tests/run.sh --unit + +HEADER + + # One task per package, in dependency order so the file reads top-down. + for p in $(cd "${ROOT}" && ./mk/deps.sh order); do + deps=$(cd "${ROOT}" && ./mk/deps.sh vars "$p" | sed -n 's/^makedepends: *//p') + printf ' %s:\n' "$(task_name "$p")" + if [ -n "${deps}" ]; then + printf ' needs:\n' + for d in ${deps}; do + printf ' - %s\n' "$(task_name "$d")" + done + fi + # arch is declared so $ARCH exists in the script; it is only injected for + # tasks that declare it. One arch today, but adding aarch64 is then a + # single edit here rather than a rewrite. + printf ' arch: [x86_64]\n' + # The signing key lives on the worker and is never seen by the conductor; + # the task asks for it by capability name. Project-scoped on purpose: the + # feature namespace is global to the conductor, which hosts more than UNOS. + # The feature provides UNOS_KEY_APK_RSA_PRI, which mk/sign-key.inc reads. + printf ' requires: [unos-sign-key]\n' + printf ' script:\n' + printf ' - ./mk/ci-setup.sh\n' + printf ' - ./mk/bootstrap-host.sh\n' + printf ' - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" %s\n' "$p" + printf ' - ./mk/check-revision.sh %s\n' "$p" + printf ' - ./mk/build.sh %s\n' "$p" + printf ' - ./mk/publish.sh --arch "$ARCH" %s\n' "$p" + printf '\n' + done + + # The index is rewritten once, after every package has been published. + # Doing it per-package would race: two packages finishing together would + # each rebuild the index from their own partial view of the repo. + cat <<'FOOTER' + index: + needs: +FOOTER + for p in $(cd "${ROOT}" && ./mk/deps.sh order); do + printf ' - %s\n' "$(task_name "$p")" + done + cat <<'FOOTER' + arch: [x86_64] + requires: [unos-sign-key] + script: + - ./mk/ci-setup.sh + - ./mk/bootstrap-host.sh + # Pull every published package down so the index describes the whole + # repo, not just whatever this container happened to build. + - ./mk/sysroot.sh --arch "$ARCH" --repo "$BUCKET_ENDPOINT/$BUCKET_NAME/$ARCH" + - ./mk/repo-index.sh + - ./mk/publish.sh --arch "$ARCH" --index-only +FOOTER +} + +case "${MODE}" in + print) emit ;; + write) emit > "${OUT}"; echo "wrote ${OUT}" ;; + check) + tmp=$(mktemp) + trap 'rm -f "${tmp}"' EXIT + emit > "${tmp}" + if [ ! -f "${OUT}" ]; then + echo "gen-pipeline.sh: .conductor.yml is missing; run ./mk/gen-pipeline.sh --write" >&2 + exit 1 + fi + if ! diff -u "${OUT}" "${tmp}" >/dev/null 2>&1; then + echo "gen-pipeline.sh: .conductor.yml is out of date" >&2 + diff -u "${OUT}" "${tmp}" | head -40 >&2 + echo "gen-pipeline.sh: run ./mk/gen-pipeline.sh --write" >&2 + exit 1 + fi + echo "gen-pipeline.sh: .conductor.yml is current" + ;; +esac diff --git a/mk/kernel.inc b/mk/kernel.inc @@ -36,8 +36,12 @@ kernel_configure() { cd "${WRKSRC}" [ -f scripts/config ] || die "kernel: scripts/config missing" - msg "kernel: defconfig" - make ARCH=x86_64 defconfig >/dev/null || die "kernel: defconfig failed" + # ARCH defaults to x86_64 for the primary target; aarch64 when cross-building + # (the conductor dispatches per-arch, builder calls ARCH=aarch64 make). + : "${ARCH:=x86_64}" + case "${ARCH}" in arm64) ARCH=aarch64 ;; esac + msg "kernel: defconfig (ARCH=${ARCH})" + make ARCH="${ARCH}" defconfig >/dev/null || die "kernel: defconfig failed" for s in ${KDELTA_ENABLE:-}; do ./scripts/config --enable "CONFIG_${s}" || die "kernel: cannot enable CONFIG_${s}" done @@ -48,7 +52,7 @@ kernel_configure() { ./scripts/config --disable "CONFIG_${s}" || die "kernel: cannot disable CONFIG_${s}" done if [ -n "${KDELTA_ENABLE:-}${KDELTA_MODULE:-}${KDELTA_DISABLE:-}" ]; then - make ARCH=x86_64 olddefconfig >/dev/null || die "kernel: olddefconfig failed" + make ARCH="${ARCH}" olddefconfig >/dev/null || die "kernel: olddefconfig failed" fi for s in ${KASSERT_Y:-}; do grep -qx "CONFIG_${s}=y" .config || die "kernel: CONFIG_${s} is not =y" @@ -59,7 +63,7 @@ kernel_configure() { for s in ${KASSERT_N:-}; do grep -qx "# CONFIG_${s} is not set" .config || die "kernel: CONFIG_${s} is set" done - KREL=$(make -s ARCH=x86_64 kernelrelease) || die "kernel: kernelrelease failed" + KREL=$(make -s ARCH="${ARCH}" kernelrelease) || die "kernel: kernelrelease failed" [ -n "${KREL}" ] || die "kernel: empty KREL" export KREL msg "kernel: release ${KREL}" @@ -67,25 +71,36 @@ kernel_configure() { kernel_build() { cd "${WRKSRC}" - make ARCH=x86_64 -j"$(nproc)" \ + : "${ARCH:=x86_64}" + case "${ARCH}" in arm64) ARCH=aarch64 ;; esac + make ARCH="${ARCH}" -j"$(nproc)" \ KBUILD_BUILD_USER=unos \ KBUILD_BUILD_HOST=unos \ KBUILD_BUILD_TIMESTAMP="$(date -u -d "@${SOURCE_DATE_EPOCH}" '+%Y-%m-%d %H:%M:%S')" \ bzImage modules || die "kernel: build failed" - [ -f arch/x86/boot/bzImage ] || die "kernel: bzImage missing" + # bzImage path varies by arch + case "${ARCH}" in + x86_64) [ -f arch/x86/boot/bzImage ] || die "kernel: bzImage missing" ;; + aarch64) [ -f arch/arm64/boot/Image ] || die "kernel: Image missing (aarch64)" ;; + esac } kernel_install() { cd "${WRKSRC}" - install -D -m 644 arch/x86/boot/bzImage "${DESTDIR}/boot/vmlinuz-${KREL}" + : "${ARCH:=x86_64}" + case "${ARCH}" in arm64) ARCH=aarch64 ;; esac + case "${ARCH}" in + x86_64) install -D -m 644 arch/x86/boot/bzImage "${DESTDIR}/boot/vmlinuz-${KREL}" ;; + aarch64) install -D -m 644 arch/arm64/boot/Image "${DESTDIR}/boot/vmlinuz-${KREL}" ;; + esac ln -sf "vmlinuz-${KREL}" "${DESTDIR}/boot/vmlinuz" install -D -m 644 System.map "${DESTDIR}/boot/System.map-${KREL}" install -D -m 644 .config "${DESTDIR}/boot/config-${KREL}" - make ARCH=x86_64 INSTALL_MOD_PATH="${DESTDIR}" INSTALL_MOD_STRIP=1 \ + make ARCH="${ARCH}" INSTALL_MOD_PATH="${DESTDIR}" INSTALL_MOD_STRIP=1 \ modules_install >/dev/null || die "kernel: modules_install failed" # absolute build-tree symlinks must never ship rm -f "${DESTDIR}/lib/modules/${KREL}/build" "${DESTDIR}/lib/modules/${KREL}/source" - make ARCH=x86_64 INSTALL_HDR_PATH="${DESTDIR}/usr" \ + make ARCH="${ARCH}" INSTALL_HDR_PATH="${DESTDIR}/usr" \ headers_install >/dev/null || die "kernel: headers_install failed" # dependency maps at build time (explicit version: uname lies in userns) command -v depmod >/dev/null 2>&1 || die "kernel: host depmod missing" diff --git a/mk/keymgmt.sh b/mk/keymgmt.sh @@ -75,6 +75,8 @@ cmd_new() { rsa) openssl genrsa -out "${KEYDIR}/${priv}" 4096 2>/dev/null openssl rsa -in "${KEYDIR}/${priv}" -pubout -out "${KEYDIR}/${pub}" 2>/dev/null + # Enforce minimum (defence in depth, even though we just generated 4096) + "${HERE}/check-keysize.sh" "${KEYDIR}/${priv}" 4096 >/dev/null ;; supercop) supercop generate -k "${KEYDIR}/${priv}" @@ -107,6 +109,8 @@ cmd_use() { fi openssl rsa -in "${path}" -check -noout >/dev/null 2>&1 \ || die "not an RSA private key: ${path}" + "${HERE}/check-keysize.sh" "${path}" 4096 >/dev/null \ + || die "key too small (4096-bit minimum): ${path}" printf '%s\n' "${path}" > "${ROOT}/.sign-key" msg "signing key: ${path}" } diff --git a/mk/make-img.sh b/mk/make-img.sh @@ -1,144 +0,0 @@ -#!/bin/sh -# mk/make-img.sh - build a bootable UNOS image that carries its own installer. -# -# ./mk/make-img.sh # build with defaults -# ./mk/make-img.sh --version 0.1.0 --size 3072 -# ./mk/make-img.sh --authorized-keys ~/.ssh/id_ed25519.pub -# -# Output: build/work/unos-<version>-x86_64.img -# GPT + ESP (FAT, EFI/BOOT/BOOTX64.EFI) + ext4 root, bootable by firmware -# with no -kernel shortcut, carrying /opt/unos/unos-<version>-x86_64.bin. -# -# WHY A VM BUILDS THIS -# -------------------- -# Laying down a partitioned, bootable image needs loop devices, mount, and -# grub-install into a mounted ESP -- all of which need real root. We have none -# (user namespaces cannot set up loop devices). So instead of reimplementing -# partitioning on the host with mtools and grub-mkimage, this boots a UNOS live -# VM and runs installer/install.sh against a blank second disk. That path is -# already covered by tests/integration/test_qemu_install.sh, so the image -# builder and the installer cannot drift apart: fixing one fixes the other. -# -# WHY THE PAYLOAD IS COPIED IN AFTERWARDS -# --------------------------------------- -# The .bin contains a tarball of the rootfs. If the rootfs being packed already -# contained the .bin, the image would contain a copy of itself -- so the -# installer is built from the plain rootfs, installed, and only then copied -# onto the installed system as a separate step. -set -eu - -HERE=$(cd "$(dirname "$0")" && pwd) -ROOT=$(cd "${HERE}/.." && pwd) -BUILD="${ROOT}/build/work" - -VERSION="${UNOS_VERSION:-0.1.0}" -SIZE_MB=3072 -OUT="" -AUTH_KEYS="" -PORT="${MAKEIMG_SSH_PORT:-2295}" - -while [ $# -gt 0 ]; do - case "$1" in - --version) VERSION="$2"; shift ;; - --out) OUT="$2"; shift ;; - --size) SIZE_MB="$2"; shift ;; - --authorized-keys) AUTH_KEYS="$2"; shift ;; - -h|--help) sed -n '2,12p' "$0"; exit 0 ;; - *) echo "make-img.sh: unknown argument: $1" >&2; exit 1 ;; - esac - shift -done - -[ -n "${OUT}" ] || OUT="${BUILD}/unos-${VERSION}-x86_64.img" -BIN="${BUILD}/unos-${VERSION}-x86_64.bin" -LIVE_LOG="${BUILD}/make-img-live.log" - -msg() { printf '==> %s\n' "$*"; } -die() { printf 'make-img.sh: error: %s\n' "$*" >&2; exit 1; } - -cleanup() { - for p in $(pgrep -f 'qemu-sys[t]em-x86_64' 2>/dev/null); do kill "$p" 2>/dev/null || true; done -} -trap cleanup EXIT - -[ -d "${ROOT}/rootfs" ] || die "no rootfs/; run ./mk/rootfs.sh first" - -KEY=$(ls "${HOME}"/.ssh/id_ed25519 2>/dev/null | head -1 || true) -[ -n "${KEY}" ] || KEY=$(ls "${HOME}"/.ssh/*.pub 2>/dev/null | head -1 | sed 's/.pub$//' || true) -[ -n "${KEY}" ] && [ -f "${KEY}" ] || die "no usable ssh key in ~/.ssh (needed to drive the build VM)" - -ssh_vm() { - ssh -i "${KEY}" -p "${PORT}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ - -o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes root@localhost "$@" 2>&1 -} - -wait_ssh() { - i=1 - while [ "$i" -le 90 ]; do - if ssh_vm true >/dev/null 2>&1; then return 0; fi - sleep 1 - i=$((i + 1)) - done - return 1 -} - -# --- 1. the payload -------------------------------------------------------- -# Built from the plain rootfs. Optionally bake authorized_keys in, because an -# image nobody can log into is only useful over a serial console. -STAGE="${BUILD}/stage-img-root" -rm -rf "${STAGE}" -mkdir -p "${STAGE}" -msg "staging rootfs" -(cd "${ROOT}/rootfs" && tar -cf - .) | (cd "${STAGE}" && tar -xf -) - -if [ -n "${AUTH_KEYS}" ]; then - [ -f "${AUTH_KEYS}" ] || die "no such authorized_keys file: ${AUTH_KEYS}" - mkdir -p "${STAGE}/root/.ssh" - cat "${AUTH_KEYS}" > "${STAGE}/root/.ssh/authorized_keys" - chmod 700 "${STAGE}/root/.ssh" - chmod 600 "${STAGE}/root/.ssh/authorized_keys" - msg "baked authorized_keys from ${AUTH_KEYS}" -else - msg "no --authorized-keys given: image will be console-only" -fi - -msg "building installer payload" -"${ROOT}/installer/mkinstaller.sh" --version "${VERSION}" \ - --rootfs "${STAGE}" --out "${BIN}" >/dev/null || die "mkinstaller failed" -[ -s "${BIN}" ] || die "installer payload not produced" -msg "payload: $(basename "${BIN}") ($(du -h "${BIN}" | cut -f1))" - -# --- 2. build VM ----------------------------------------------------------- -rm -f "${OUT}" "${BUILD}/ovmf-vars.fd" -cleanup - -msg "booting build VM (target ${SIZE_MB}M)" -UEFI=1 IMG_SIZE_MB=1536 QEMU_MEM=1536 QEMU_SSH_PORT="${PORT}" \ - TARGET_DISK="${OUT}" TARGET_SIZE_MB="${SIZE_MB}" \ - EXTRA_FILES="${BIN}:/root/unos.bin" \ - "${ROOT}/mk/run-qemu.sh" run > "${LIVE_LOG}" 2>&1 & - -wait_ssh || { tail -20 "${LIVE_LOG}" >&2; die "build VM did not come up"; } - -# --- 3. install onto the blank disk --------------------------------------- -msg "installing onto /dev/vdb" -out=$(ssh_vm 'sh /root/unos.bin --mode generic --disk /dev/vdb --force; echo "RC=$?"') -case "${out}" in - *RC=0*) ;; - *) printf '%s\n' "${out}" >&2; die "installer failed inside the build VM" ;; -esac - -# The installer payload is deliberately NOT copied into the installed root. -# The rootfs must be byte-identical whether it was installed normally or as a -# rescue image; embedding an 85MB copy of the installer would bloat every -# installed switch with a copy of itself, and would mean the image contains its -# own payload which contains the image. -ssh_vm 'sync' >/dev/null 2>&1 || true -cleanup - -[ -s "${OUT}" ] || die "no image produced" -msg "done: ${OUT} ($(du -h "${OUT}" | cut -f1))" -msg "" -msg " boot it: ./mk/run-qemu.sh boot-installed ${OUT} (UEFI=1)" -msg " install: curl -O http://.../unos-installer.bin && sh unos-installer.bin --disk /dev/sdX --force" -msg " write it: dd if=${OUT} of=/dev/sdX bs=4M conv=fsync" diff --git a/mk/pc-fixup.sh b/mk/pc-fixup.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# mk/pc-fixup.sh - rewrite .pc prefix/libdir/includedir to point at SYSROOT. +# +# Usage: ./mk/pc-fixup.sh [sysroot] +# sysroot defaults to build/sysroot when run from repo root. +# +# This is the sole implementation; mk/build.sh and mk/sysroot.sh both call it. +# Rewriting only prefix= is insufficient: many upstream .pc files spell +# libdir=/includedir= literally (efivar does; efibootmgr then picks host +# headers). Fix every level, every .pc, every build. Idempotent. +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/.." && pwd) + +SYSROOT="${1:-${SYSROOT:-${ROOT}/build/sysroot}}" + +[ -d "${SYSROOT}" ] || exit 0 + +for _pc in "${SYSROOT}"/usr/lib/pkgconfig/*.pc "${SYSROOT}"/usr/lib64/pkgconfig/*.pc; do + [ -e "${_pc}" ] || continue + # Only fix if it looks like a sysroot pc (prefix=/usr or similar); leave + # host pkgconfigs alone when SYSROOT happens to be /. + sed -i \ + -e "s|^prefix=.*|prefix=${SYSROOT}/usr|" \ + -e "s|^libdir=.*|libdir=${SYSROOT}/usr/lib|" \ + -e "s|^includedir=.*|includedir=${SYSROOT}/usr/include|" \ + "${_pc}" +done diff --git a/mk/publish.sh b/mk/publish.sh @@ -0,0 +1,133 @@ +#!/bin/sh +# mk/publish.sh - upload built packages and the index to the S3 repo. +# +# ./mk/publish.sh publish every .apk plus APKINDEX for $ARCH +# ./mk/publish.sh zlib popt publish only those packages (plus index) +# ./mk/publish.sh --index-only re-upload APKINDEX.tar.gz and nothing else +# ./mk/publish.sh --dry-run print what would be uploaded +# +# Environment (supplied by the CI runner; no mounts are provided): +# +# BUCKET_ACCESS_KEY S3 access key id +# BUCKET_SECRET_KEY S3 secret access key +# BUCKET_NAME bucket to write into +# BUCKET_ENDPOINT base URL of the S3 service, e.g. https://s3.finwo.net +# BUCKET_REGION region name, e.g. eu-west1 +# +# Uses `curl --aws-sigv4` rather than awscli or rclone: curl is already needed +# to fetch sources, signs SigV4 natively since 7.75, and pulling in a Python or +# Go client for four PUTs is not worth the image size or the supply chain. +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/.." && pwd) + +ARCH="${ARCH:-x86_64}" +DRY=0 +INDEX_ONLY=0 +PKGS="" + +while [ $# -gt 0 ]; do + case "$1" in + --arch) ARCH="$2"; shift 2 ;; + --arch=*) ARCH="${1#--arch=}"; shift ;; + --dry-run) DRY=1; shift ;; + --index-only) INDEX_ONLY=1; shift ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + -*) echo "publish.sh: unknown option: $1" >&2; exit 1 ;; + *) PKGS="${PKGS} $1"; shift ;; + esac +done + +msg() { printf '==> %s\n' "$*"; } +die() { printf 'publish.sh: error: %s\n' "$*" >&2; exit 1; } + +REPODIR="${ROOT}/build/repo/${ARCH}" +[ -d "${REPODIR}" ] || die "no such repo dir: ${REPODIR}" + +if [ "${DRY}" != "1" ]; then + for v in BUCKET_ACCESS_KEY BUCKET_SECRET_KEY BUCKET_NAME BUCKET_ENDPOINT BUCKET_REGION; do + eval "val=\${${v}:-}" + [ -n "${val}" ] || die "${v} is not set" + done + + # These two are easy to transpose, and transposing them produces a signature + # mismatch rather than anything that names the real problem. Check the shape + # instead of trusting the order they were exported in. + case "${BUCKET_ENDPOINT}" in + http://*|https://*) ;; + *) die "BUCKET_ENDPOINT must be a URL (e.g. https://s3.finwo.net), got '${BUCKET_ENDPOINT}'. + Note BUCKET_REGION is the region name (e.g. eu-west1); the two look swapped." ;; + esac + case "${BUCKET_REGION}" in + http://*|https://*) die "BUCKET_REGION must be a region name (e.g. eu-west1), not a URL. + Note BUCKET_ENDPOINT is the URL; the two look swapped." ;; + esac + + command -v curl >/dev/null 2>&1 || die "curl is required" +fi + +# put <localfile> <key> +put() { + src="$1" + key="$2" + # Built after the dry-run check on purpose: --dry-run must work with no + # credentials in the environment at all, and set -u would abort here. + if [ "${DRY}" = "1" ]; then + printf ' would PUT %s -> %s\n' "${src}" "${key}" + return 0 + fi + url="${BUCKET_ENDPOINT%/}/${BUCKET_NAME}/${key}" + code=$(curl -sS -o /dev/null -w '%{http_code}' \ + --aws-sigv4 "aws:amz:${BUCKET_REGION}:s3" \ + --user "${BUCKET_ACCESS_KEY}:${BUCKET_SECRET_KEY}" \ + -X PUT --upload-file "${src}" \ + "${url}") || die "upload failed: ${key}" + case "${code}" in + 2*) printf ' %s (%s)\n' "${key}" "${code}" ;; + *) die "upload of ${key} returned HTTP ${code}" ;; + esac +} + +# Which .apk files to publish. +if [ "${INDEX_ONLY}" = "1" ]; then + FILES="" +elif [ -n "${PKGS}" ]; then + FILES="" + for p in ${PKGS}; do + found="" + for f in "${REPODIR}/${p}"-*.apk; do + [ -e "${f}" ] || continue + # `zlib-1.3.1-r0.apk` matches zlib, but `zlib-doc-...` must not, so + # require the character after the name to start a version field. + base=$(basename "${f}") + rest=${base#"${p}"-} + case "${rest}" in + [0-9]*) found="${found} ${f}" ;; + esac + done + [ -n "${found}" ] || die "no built .apk for '${p}' in ${REPODIR}" + FILES="${FILES}${found}" + done +else + FILES=$(ls "${REPODIR}"/*.apk 2>/dev/null || true) + [ -n "${FILES}" ] || die "no .apk files in ${REPODIR}" +fi + +msg "publishing to ${BUCKET_NAME:-<dry-run>}/${ARCH}" +for f in ${FILES}; do + put "${f}" "${ARCH}/$(basename "${f}")" +done + +# The index goes last, always. It is the file clients read to discover what +# exists, so publishing it before its packages would advertise packages that +# are not there yet; publishing it after is merely briefly stale. +IDX="${REPODIR}/APKINDEX.tar.gz" +if [ -f "${IDX}" ]; then + put "${IDX}" "${ARCH}/APKINDEX.tar.gz" +else + [ "${INDEX_ONLY}" = "1" ] && die "no APKINDEX.tar.gz in ${REPODIR}; run ./mk/repo-index.sh" + msg "note: no APKINDEX.tar.gz to publish (run ./mk/repo-index.sh first)" +fi + +msg "done" diff --git a/mk/repo-index.sh b/mk/repo-index.sh @@ -10,11 +10,25 @@ set -eu HERE=$(cd "$(dirname "$0")" && pwd) ROOT=$(cd "${HERE}/.." && pwd) -ARCH="${1:-x86_64}" +# ARCH from $ARCH env or first arg, normalise arm64 -> aarch64 +ARCH="${ARCH:-${1:-x86_64}}" +if [ $# -ge 1 ]; then case "$1" in x86_64|aarch64|arm64) ARCH="$1";; esac; fi +case "${ARCH}" in arm64) ARCH=aarch64 ;; esac +case "${ARCH}" in x86_64|aarch64) ;; *) echo "repo-index.sh: unsupported ARCH ${ARCH}" >&2; exit 1 ;; esac REPODIR="${ROOT}/build/repo/${ARCH}" [ -d "${REPODIR}" ] || { echo "repo-index.sh: no such dir: ${REPODIR}" >&2; exit 1; } +# The apk we built must win over any system one, exactly as mk/build.sh does. +# Without this the bare `apk index` below resolves against whatever the host +# happens to have -- which works on a developer box that installed apk-tools +# and fails in a clean CI container, where nothing provides it at all. +export PATH="${ROOT}/build/host/bin:${PATH}" +command -v apk >/dev/null 2>&1 || { + echo "repo-index.sh: no apk on PATH; run ./mk/bootstrap-host.sh first" >&2 + exit 1 +} + . "${HERE}/sign-key.inc" KEY=$(resolve_sign_key) || { echo "repo-index.sh: no signing key (mk/keymgmt.sh use)" >&2; exit 1; } KEYNAME=$(basename "${KEY}" .rsa) diff --git a/mk/rootfs.sh b/mk/rootfs.sh @@ -1,58 +0,0 @@ -#!/bin/sh -# mk/rootfs.sh - assemble rootfs/ for chroot testing. -# -# Usage: ./mk/rootfs.sh [--force] -# -# Pure apk: static apk installs unos-keys with one explicit -# --allow-untrusted (the trust seed), then everything else - base-files, -# glibc, busybox, unos-firstboot - installs fully trusted. No xbps, no -# foreign repo content anywhere in the loop. Refuses to clobber an -# existing rootfs/ unless --force. -set -eu - -HERE=$(cd "$(dirname "$0")" && pwd) -ROOT=$(cd "${HERE}/.." && pwd) -ROOTFS="${ROOT}/rootfs" -APK="${ROOT}/build/host/bin/apk" -CACHE="${ROOT}/build/work/apk-cache" -REPO="file://${ROOT}/build/repo" - -if [ -d "${ROOTFS}" ]; then - [ "${1:-}" = "--force" ] || { echo "rootfs.sh: ${ROOTFS} exists (use --force)" >&2; exit 1; } - rm -rf "${ROOTFS}" -fi -mkdir -p "${ROOTFS}" "${CACHE}" "${ROOTFS}/proc" - -# apk executes control scripts chrooted via memfd (/proc/self/fd), so the -# target needs /proc mounted or every post-install fails with ENOENT - -# even though install order/dep wiring is otherwise correct. Bind host -# /proc in a private namespace for the transactions (same unshare pattern -# as mk/chroot.sh; the --pid flag is what makes the proc mount permitted). -# Namespace evaporates on exit; repo/cache stay visible (same fs view). -apk_ns() { - unshare --user --map-root-user --mount --propagation private --pid --fork \ - sh -c 'mount -t proc proc "$1/proc" && shift && "$@"' \ - _ "${ROOTFS}" "$@" -} - -echo "==> trust seed: unos-keys via explicit --allow-untrusted" -# NOTE: no --usermode here (apk refuses it as root, and we are mapped root -# inside the namespace). Ownership maps back to the invoking user outside, -# and no shipped package carries device nodes or setuid bits that would -# behave differently - if one ever does, assembly needs a second look. -apk_ns "${APK}" --root "${ROOTFS}" --initdb \ - --cache-dir "${CACHE}" --repository "${REPO}" \ - --allow-untrusted add unos-keys - -echo "==> UNOS system (fully trusted from here on)" -apk_ns "${APK}" --root "${ROOTFS}" \ - --cache-dir "${CACHE}" --repository "${REPO}" \ - add base-files glibc busybox unos-firstboot tinyssh libmnl zlib openssl iproute2 apk-tools linux-longterm grub popt efivar efibootmgr unosd libstdc++ e2fsprogs gptfdisk smartmontools dosfstools curl - -# Deterministic loader cache: package post-installs refresh it during the -# transaction, but assembly must not depend on script-execution order. -# Runs OUR ldconfig inside the namespace (writes rootfs/etc/ld.so.cache). -echo "==> loader cache" -./mk/chroot.sh /sbin/ldconfig - -echo "==> done: ${ROOTFS}" diff --git a/mk/run-qemu.sh b/mk/run-qemu.sh @@ -1,195 +0,0 @@ -#!/bin/sh -# mk/run-qemu.sh - build a bootable UNOS image with injected SSH keys and run it in QEMU. -# No hardcoding of key names: discovers pubkeys at runtime. -set -eu - -HERE=$(cd "$(dirname "$0")" && pwd) -ROOT=$(cd "${HERE}/.." && pwd) -BUILD="${ROOT}/build/work" -STAGE="${BUILD}/stage-qemu-root" -IMG="${BUILD}/unos-qemu.img" -IMG_SIZE_MB="${IMG_SIZE_MB:-512}" -QEMU_SSH_PORT="${QEMU_SSH_PORT:-2222}" -QEMU_MEM="${QEMU_MEM:-512}" -KERNEL="${ROOT}/rootfs/boot/vmlinuz" - -# UEFI support. OVMF's QemuKernelLoaderFsDxe makes `-kernel` work under UEFI -# firmware, so a direct-kernel live VM still gets a real /sys/firmware/efi -- -# which the installer needs for firmware detection and for efibootmgr. -UEFI="${UEFI:-0}" -OVMF_CODE="${OVMF_CODE:-/usr/share/qemu/edk2-x86_64-code.fd}" -OVMF_VARS_SRC="${OVMF_VARS_SRC:-/usr/share/qemu/edk2-i386-vars.fd}" -OVMF_VARS="${BUILD}/ovmf-vars.fd" -# Optional second disk, presented as /dev/vdb (install target). -TARGET_DISK="${TARGET_DISK:-}" -TARGET_SIZE_MB="${TARGET_SIZE_MB:-2048}" -# Extra files to copy into the staged rootfs: "src:dstpath" pairs. -EXTRA_FILES="${EXTRA_FILES:-}" - -uefi_args() { - [ "${UEFI}" = "1" ] || return 0 - [ -f "${OVMF_CODE}" ] || { echo "run-qemu.sh: OVMF code not found: ${OVMF_CODE}" >&2; exit 1; } - # vars must be writable and per-run, otherwise boot entries persist between tests - [ -f "${OVMF_VARS}" ] || cp "${OVMF_VARS_SRC}" "${OVMF_VARS}" - printf '%s' "-drive if=pflash,format=raw,readonly=on,file=${OVMF_CODE} -drive if=pflash,format=raw,file=${OVMF_VARS}" -} - -reset_uefi_vars() { - rm -f "${OVMF_VARS}" -} - -# --- collect authorized_keys (automated, no hardcoding) --- -collect_keys() { - tmp=$(mktemp) - # all .pub files - for f in "${HOME}"/.ssh/*.pub; do - [ -e "$f" ] || continue - cat "$f" >> "$tmp" 2>/dev/null || true - done - # authorized_keys if present (may contain certs, deduplicated via sort -u) - if [ -f "${HOME}/.ssh/authorized_keys" ]; then - cat "${HOME}/.ssh/authorized_keys" >> "$tmp" 2>/dev/null || true - fi - # keep only non-empty, non-comment lines? Keep as-is but deduplicate - # filter blank lines, let sort handle dedupe - if [ -s "$tmp" ]; then - sort -u "$tmp" -o "$tmp" - # drop empty lines - grep -v '^[[:space:]]*$' "$tmp" > "$tmp.filtered" 2>/dev/null || true - mv "$tmp.filtered" "$tmp" - fi - # require at least one key - if [ ! -s "$tmp" ]; then - echo "run-qemu.sh: no pubkeys found in ~/.ssh/*.pub or ~/.ssh/authorized_keys" >&2 - rm -f "$tmp" - return 1 - fi - echo "$tmp" -} - -# --- stage rootfs with injected keys --- -stage_rootfs() { - keys_file="$1" - echo "==> staging rootfs" - rm -rf "${STAGE}" - mkdir -p "${STAGE}" - # copy via tar to preserve perms; rootfs is unprivileged dir tree - (cd "${ROOT}/rootfs" && tar -cf - .) | (cd "${STAGE}" && tar -xf -) - - # inject root's authorized_keys (automated) - mkdir -p "${STAGE}/root/.ssh" - cat "$keys_file" > "${STAGE}/root/.ssh/authorized_keys" - chmod 700 "${STAGE}/root/.ssh" - chmod 600 "${STAGE}/root/.ssh/authorized_keys" - - # Extra payloads, "src:dst" pairs separated by whitespace. Used by the - # installer test to drop unos-*.bin into the live VM. - for pair in ${EXTRA_FILES}; do - src="${pair%%:*}" - dst="${pair#*:}" - [ -e "${src}" ] || { echo "run-qemu.sh: EXTRA_FILES source missing: ${src}" >&2; exit 1; } - mkdir -p "${STAGE}$(dirname "${dst}")" - cp -a "${src}" "${STAGE}${dst}" - echo "==> staged ${src} -> ${dst}" - done - chown -R 0:0 "${STAGE}/root/.ssh" 2>/dev/null || true - echo "==> injected $(wc -l < "$keys_file") key(s) into /root/.ssh/authorized_keys" -} - -build_image() { - echo "==> building ${IMG} (${IMG_SIZE_MB}M)" - rm -f "${IMG}" - # Injected authorized_keys is created as $USER, fix ownership via fake-root ns - # before mkfs (which copies ownership verbatim). - if command -v unshare >/dev/null 2>&1; then - unshare --user --map-root-user --mount -- sh -c ' - chown -R 0:0 "$1/root/.ssh" 2>/dev/null || true - mkfs.ext4 -L UNOS -m 0 -d "$1" "$2" "$3" >/dev/null - ' sh "${STAGE}" "${IMG}" "${IMG_SIZE_MB}M" 2>/dev/null || mkfs.ext4 -L UNOS -m 0 -d "${STAGE}" "${IMG}" "${IMG_SIZE_MB}M" >/dev/null - else - mkfs.ext4 -L UNOS -m 0 -d "${STAGE}" "${IMG}" "${IMG_SIZE_MB}M" >/dev/null - fi - echo "==> image ready: ${IMG} ($(du -h "${IMG}" | cut -f1))" - # sanity: label - tune2fs -l "${IMG}" 2>/dev/null | grep -E "Filesystem volume name|Block count" | head -2 -} - -run_qemu() { - [ -f "${KERNEL}" ] || { echo "run-qemu.sh: kernel not found: ${KERNEL} (run mk/rootfs.sh first)" >&2; exit 1; } - [ -f "${IMG}" ] || { echo "run-qemu.sh: image not found: ${IMG}" >&2; exit 1; } - tgt="" - if [ -n "${TARGET_DISK}" ]; then - [ -f "${TARGET_DISK}" ] || truncate -s "${TARGET_SIZE_MB}M" "${TARGET_DISK}" - tgt="-drive file=${TARGET_DISK},format=raw,if=virtio" - echo " target: ${TARGET_DISK} (/dev/vdb, ${TARGET_SIZE_MB}M)" - fi - echo "==> launching QEMU (ssh -p ${QEMU_SSH_PORT} root@localhost)" - echo " kernel: ${KERNEL}" - echo " drive: ${IMG}" - echo " mem: ${QEMU_MEM}M" - echo " uefi: ${UEFI}" - echo " press Ctrl-a c then 'quit' to exit QEMU" - echo "" - # shellcheck disable=SC2086 - exec qemu-system-x86_64 -enable-kvm -m "${QEMU_MEM}" \ - $(uefi_args) \ - -kernel "${KERNEL}" \ - -drive file="${IMG}",format=raw,if=virtio \ - ${tgt} \ - -append "console=ttyS0 root=/dev/vda rw" \ - -nographic \ - -netdev user,id=net0,hostfwd=tcp::${QEMU_SSH_PORT}-:22 \ - -device e1000,netdev=net0 -} - -# Boot a disk that was written by the installer: no -kernel, no -append. The -# firmware must find the ESP, run GRUB, and GRUB must find the kernel. This is -# the only mode that actually proves the install worked. -boot_installed() { - disk="$1" - [ -f "${disk}" ] || { echo "run-qemu.sh: no such image: ${disk}" >&2; exit 1; } - tgt="" - if [ -n "${TARGET_DISK}" ]; then - [ -f "${TARGET_DISK}" ] || truncate -s "${TARGET_SIZE_MB}M" "${TARGET_DISK}" - tgt="-drive file=${TARGET_DISK},format=raw,if=virtio" - echo " target: ${TARGET_DISK} (/dev/vdb, ${TARGET_SIZE_MB}M)" - fi - echo "==> booting installed disk ${disk} (no -kernel; firmware -> GRUB -> UNOS)" - echo " uefi: ${UEFI}" - # shellcheck disable=SC2086 - exec qemu-system-x86_64 -enable-kvm -m "${QEMU_MEM}" \ - $(uefi_args) \ - -drive file="${disk}",format=raw,if=virtio \ - ${tgt} \ - -nographic \ - -netdev user,id=net0,hostfwd=tcp::${QEMU_SSH_PORT}-:22 \ - -device e1000,netdev=net0 -} - -# --- main --- -mode="${1:-run}" -case "$mode" in - build) - kf=$(collect_keys) - stage_rootfs "$kf" - build_image - rm -f "$kf" - echo "==> done (build only). Run '$0 run' to start QEMU." - ;; - run|"") - kf=$(collect_keys) - stage_rootfs "$kf" - build_image - rm -f "$kf" - run_qemu - ;; - boot-installed) - reset_uefi_vars - boot_installed "${2:-${BUILD}/unos-target.img}" - ;; - *) - echo "usage: $0 [build|run|boot-installed [img]]" >&2 - echo " env: UEFI=1 TARGET_DISK=<path> TARGET_SIZE_MB=<n> QEMU_SSH_PORT=<n>" >&2 - exit 1 - ;; -esac diff --git a/mk/sign-key.inc b/mk/sign-key.inc @@ -1,9 +1,24 @@ # mk/sign-key.inc - resolve_sign_key(): print the RSA package-signing key. # -# Precedence: $UNOS_SIGN_KEY > $ROOT/.sign-key > single key in ~/.unos-keys/. +# Precedence: +# $UNOS_KEY_APK_RSA_PRI the CI worker's unos-sign-key feature sets this +# $UNOS_SIGN_KEY manual override +# $ROOT/.sign-key local developer selection (mk/keymgmt.sh use) +# single *.rsa in ~/.unos-keys/ +# +# CI comes first because a worker that mounted a key intends that key to be +# used; a stale .sign-key checked into a runner image should not be able to +# quietly outrank it. +# +# Note each worker carries its own distinct keypair, so KEYNAME (and therefore +# the .SIGN.RSA.<name>.rsa.pub record in every .apk) varies with scheduling. +# packages/unos-keys must ship every worker's public half or verification +# fails for whichever packages happened to land elsewhere. +# # Source this file ($ROOT must be set), then: KEY=$(resolve_sign_key). resolve_sign_key() { - _k="${UNOS_SIGN_KEY:-}" + _k="${UNOS_KEY_APK_RSA_PRI:-}" + [ -n "${_k}" ] || _k="${UNOS_SIGN_KEY:-}" if [ -z "${_k}" ] && [ -n "${ROOT:-}" ] && [ -f "${ROOT}/.sign-key" ]; then _k=$(cat "${ROOT}/.sign-key") fi @@ -12,7 +27,9 @@ resolve_sign_key() { if [ $# = 1 ] && [ -f "$1" ]; then _k=$1 else - echo "sign-key: UNOS_SIGN_KEY unset, no ${ROOT}/.sign-key, no single key in ~/.unos-keys (see mk/keymgmt.sh)" >&2 + echo "sign-key: no key. Set UNOS_KEY_APK_RSA_PRI (CI: the unos-sign-key + worker feature provides it) or UNOS_SIGN_KEY, or write one to + ${ROOT}/.sign-key, or keep a single *.rsa in ~/.unos-keys (mk/keymgmt.sh)" >&2 return 1 fi fi diff --git a/mk/sysroot.sh b/mk/sysroot.sh @@ -0,0 +1,208 @@ +#!/bin/sh +# mk/sysroot.sh - seed build/sysroot from the published apk repo. +# +# ./mk/sysroot.sh seed for all packages (full graph) +# ./mk/sysroot.sh zlib seed only transitive makedepends of zlib +# ./mk/sysroot.sh --arch aarch64 zlib specific arch (default x86_64) +# ./mk/sysroot.sh --from repo+staging use repo + staging repos (for CI level-1 overlay) +# +# Why this exists: mk/build.sh:100 treats "a built .apk exists" as proof the +# sysroot was seeded. An ephemeral CI container building only zlib therefore +# had to rebuild all 38 packages. This script populates the sysroot by +# `apk --root build/sysroot --initdb add` from the published repo, resolving +# transitive makedepends via mk/deps.sh. It is the gate on per-package +# incremental builds. +# +# Requires: apk (host via build/host/bin/apk) and a repo to pull from. +# Repo URLs may be local dirs (build/repo/x86_64) or http(s) (Garage via Caddy). +# Supports overlay: staging/<run> layered over repo/x86_64 so a level-1 build +# compiles against the freshly built library from the same push. +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/.." && pwd) + +ARCH="x86_64" +REPOS="" +PKGS="" +# default repos: local build/repo/<arch> if it exists, else nothing (caller +# must supply --repo). CI supplies --repo https://repo.unos.finwo.net/x86_64 +# and optionally --repo <staging-url>. +while [ $# -gt 0 ]; do + case "$1" in + --arch) ARCH="$2"; shift 2 ;; + --arch=*) ARCH="${1#--arch=}"; shift ;; + --repo) REPOS="${REPOS} $2"; shift 2 ;; + --repo=*) REPOS="${REPOS} ${1#--repo=}"; shift ;; + -h|--help) + sed -n '1,45p' "$0" + exit 0 + ;; + --) shift; break ;; + -*) echo "sysroot.sh: unknown option: $1" >&2; exit 1 ;; + *) PKGS="${PKGS} $1"; shift ;; + esac +done +# remaining args are also pkgs +for a in "$@"; do PKGS="${PKGS} $a"; done + +case "${ARCH}" in arm64) ARCH=aarch64 ;; esac +case "${ARCH}" in x86_64|aarch64) ;; *) echo "sysroot.sh: unsupported ARCH ${ARCH}" >&2; exit 1 ;; esac + +SYSROOT="${ROOT}/build/sysroot" +# When seeding for a specific ARCH, keep arch-specific sysroots separate so +# x86_64 and aarch64 headers don't clobber each other. For x86_64 the +# historical path `build/sysroot` is kept as the default (symlink or same). +if [ "${ARCH}" != "x86_64" ]; then + SYSROOT="${SYSROOT}-${ARCH}" +fi + +APK="${ROOT}/build/host/bin/apk" +if [ ! -x "${APK}" ]; then + APK="apk" + command -v apk >/dev/null 2>&1 || { + echo "sysroot.sh: apk not found (run mk/bootstrap-host.sh)" >&2 + exit 1 + } +fi + +# Default repo: local build/repo/<arch> if it exists +if [ -z "${REPOS}" ]; then + if [ -d "${ROOT}/build/repo/${ARCH}" ] && ls "${ROOT}/build/repo/${ARCH}"/*.apk >/dev/null 2>&1; then + REPOS="${ROOT}/build/repo/${ARCH}" + else + echo "sysroot.sh: no --repo given and no local build/repo/${ARCH} with .apk files" >&2 + echo " hint: --repo https://repo.unos.finwo.net/${ARCH} or --repo /path/to/repo" >&2 + exit 1 + fi +fi + +# Resolve transitive makedepends. +# deps.sh order <pkgs> already gives the topological closure including the +# requested packages themselves; filter to just the dependencies. +if [ -z "${PKGS}" ] || [ "${PKGS}" = " " ]; then + # all packages: seed everything (used for full sysroot) + ORDER=$("${ROOT}/mk/deps.sh" order) || exit 1 +else + # shellcheck disable=SC2086 + ORDER=$("${ROOT}/mk/deps.sh" order ${PKGS}) || exit 1 +fi + +# Build set of needed packages: transitive makedepends of requested pkgs. +# If PKGS is empty, we seeded all, but for sysroot we want only libs that +# other packages build against -- still safe to install all. +# For incremental, we install the closure minus the targets themselves if +# targets are leaves? Simpler: install the whole order - apk will handle +# already-installed. Filter to keep it small: if PKGS given, use order +# but the conductor will call with the full level's closure anyway. +PKGS_TO_INSTALL="" +for p in ${ORDER}; do + # Skip if this is a leaf requested package that hasn't been built yet and + # would not be in the repo. But if it's already in the repo, installing it + # is fine (provides headers). So we try to install everything in order; + # apk will skip what's not in the repo with an error - filter via repo. + PKGS_TO_INSTALL="${PKGS_TO_INSTALL} $p" +done + +# When PKGS is given, the ORDER includes deps first then targets. Installing +# all is correct for level-1 overlay: level-1 needs the level-0 libs that are +# already in the repo plus the level-0 staging apks. +# If the target itself is not yet in any repo, apk add will fail; so we +# attempt and allow missing targets. +# Strategy: try to install all, but if apk fails due to missing pkg, retry +# without the leaf targets. + +REPO_ARGS="" +for r in ${REPOS}; do + case "${r}" in + http://*|https://*|file://*) REPO_ARGS="${REPO_ARGS} --repository ${r}" ;; + /*) REPO_ARGS="${REPO_ARGS} --repository file://${r}" ;; + *) # relative local path like build/repo/x86_64 -> use base + # If it already ends with /x86_64 or /aarch64, strip to base + case "${r}" in */x86_64|*/aarch64|*/arm64) + base=$(dirname "${r}") + REPO_ARGS="${REPO_ARGS} --repository file://${ROOT}/${base}" + ;; + *) REPO_ARGS="${REPO_ARGS} --repository file://${ROOT}/${r}" ;; + esac + ;; + esac +done +# If REPOS was a single arch-specific local path, also allow base form +# e.g. build/repo/x86_64 -> file://.../build/repo + +# Allow apk to fetch from multiple repos; --allow-untrusted for local unsigned +# staging during dev. Published repo is signed; apk will verify via keys in +# the sysroot if present, but --allow-untrusted is harmless for seeding. +# Use --initdb if sysroot is empty. --no-scripts avoids ldconfig etc in sysroot. + +# Non-root builds (local dev) need --usermode; CI runs as root in Docker. +USERMODE="" +if [ "$(id -u)" != "0" ]; then + USERMODE="--usermode" +fi + +SYSROOT_EXISTS=0 +[ -f "${SYSROOT}/lib/apk/db/installed" ] && SYSROOT_EXISTS=1 +[ -f "${SYSROOT}/etc/apk/world" ] && SYSROOT_EXISTS=1 +# Fallback: legacy build/sysroot via DESTDIR has no apk db at all -- treat as +# not-exists so we init it. Content already there will be preserved by apk +# (it merges). + +echo "==> sysroot: ARCH=${ARCH} SYSROOT=${SYSROOT}" +echo "==> sysroot: repos:${REPOS}" +echo "==> sysroot: installing:${PKGS_TO_INSTALL}" + +# shellcheck disable=SC2086 +if [ "${SYSROOT_EXISTS}" = "0" ]; then + # shellcheck disable=SC2086 + "${APK}" ${USERMODE} --root "${SYSROOT}" --initdb ${REPO_ARGS} --allow-untrusted --no-scripts add ${PKGS_TO_INSTALL} || { + rc=$? + # Retry without the requested leaf packages (they may not be in repo yet) + if [ -n "${PKGS}" ]; then + echo "==> sysroot: retry without leaf targets" + FILTERED="" + for p in ${PKGS_TO_INSTALL}; do + skip=0 + for t in ${PKGS}; do [ "${p}" = "${t}" ] && skip=1 && break; done + [ "${skip}" = "0" ] && FILTERED="${FILTERED} $p" + done + if [ -z "${FILTERED}" ]; then + echo "==> sysroot: nothing to install after filtering (target not in repo yet)" + mkdir -p "${SYSROOT}/etc/apk" "${SYSROOT}/usr/lib/pkgconfig" + else + # shellcheck disable=SC2086 + "${APK}" ${USERMODE} --root "${SYSROOT}" --initdb ${REPO_ARGS} --allow-untrusted --no-scripts add ${FILTERED} || exit $rc + fi + else + exit $rc + fi + } +else + # shellcheck disable=SC2086 + "${APK}" ${USERMODE} --root "${SYSROOT}" ${REPO_ARGS} --allow-untrusted --no-scripts add ${PKGS_TO_INSTALL} || { + rc=$? + if [ -n "${PKGS}" ]; then + echo "==> sysroot: retry without leaf targets" + FILTERED="" + for p in ${PKGS_TO_INSTALL}; do + skip=0 + for t in ${PKGS}; do [ "${p}" = "${t}" ] && skip=1 && break; done + [ "${skip}" = "0" ] && FILTERED="${FILTERED} $p" + done + if [ -z "${FILTERED}" ]; then + echo "==> sysroot: nothing to install after filtering" + else + # shellcheck disable=SC2086 + "${APK}" ${USERMODE} --root "${SYSROOT}" ${REPO_ARGS} --allow-untrusted --no-scripts add ${FILTERED} || exit $rc + fi + else + exit $rc + fi + } +fi + +# Fix up .pc files (same logic as mk/pc-fixup.sh, but arch-aware) +"${HERE}/pc-fixup.sh" "${SYSROOT}" + +echo "==> sysroot: done ${SYSROOT} ($(find "${SYSROOT}" -type f | wc -l | tr -d ' ') files)" diff --git a/packages/README.md b/packages/README.md @@ -46,6 +46,17 @@ directory is ever built by hand on a target switch. (see `gptfdisk`). - **Pin everything.** Explicit versions and recorded checksums. No moving targets, no floating branches. +- **Third-party source uses `distfiles=` and must carry `checksum=`.** The + driver refuses to build a template that sets `distfiles` without a matching + sha256, because there we are trusting a server nobody here controls. +- **Our own source uses `srcfiles=` and carries no checksum.** It is fetched by + tag from `git.finwo.net`; the tag is the pin and TLS is the transport. A + forge regenerates tag archives on demand, so they are not byte-stable and a + recorded sha256 would eventually fail for reasons indistinguishable from + tampering. Setting `checksum=` alongside `srcfiles=` is an error, as is + setting both `srcfiles=` and `distfiles=` -- whichever is present supplies + the tarball `WRKSRC` is derived from, and two sources would make that choice + silent and arbitrary. Only `linkd` uses this today. - **Never vendor upstream source into this repository.** Sources are fetched at build time from a pinned revision. This keeps history small and keeps our licensing story unambiguous. diff --git a/packages/base-files/files/linkd.cnf b/packages/base-files/files/linkd.cnf @@ -0,0 +1,20 @@ +# linkd configuration. +# +# The socket path differs from linkd's own default (/var/run/linkd.sock): +# UNOS mounts a tmpfs on /run and ships no /var/run, so the daemon and every +# client are pointed at the path that exists here. + +config_ports /etc/network/ports /etc/network/ports.d/*.cnf +config_iface /etc/network/interfaces /etc/network/interfaces.d/*.cnf + +listen unix:///run/linkd.sock + +# TCP listeners need `authfile` set, and linkd refuses to bind one without it. +# Generate entries with: linkctl hash <user> +# +# authfile /etc/linkd.passwd +# listen tcp://127.0.0.1:6789 + +# Hardware dataplanes are declared here; the openbcm package drops one in. +# +# plugin /usr/lib/linkd/bcm diff --git a/packages/base-files/files/network/ports b/packages/base-files/files/network/ports @@ -15,7 +15,7 @@ # admin state .......................... auto / allow-hotplug / ifdown # vlans ................................ iface swp1.100 # -# unosd learns all of that from netlink and mirrors it into the ASIC, so +# linkd learns all of that from netlink and mirrors it into the ASIC, so # configuring it here as well would create two sources of truth for one value. # Directives belonging to ifupdown are rejected rather than ignored. # diff --git a/packages/base-files/template b/packages/base-files/template @@ -1,7 +1,7 @@ # Template file for 'base-files' pkgname=base-files version=0.1.0 -revision=5 +revision=6 short_desc="UNOS filesystem skeleton - passwd, profile, nsswitch, fstab" maintainer="finwo <finwo@pm.me>" license="GPL-2.0-only" @@ -29,6 +29,7 @@ do_install() { vinstall ${FILESDIR}/rcS 755 etc/init.d rcS vinstall ${FILESDIR}/network/ports 644 etc/network ports vinstall ${FILESDIR}/network/interfaces 644 etc/network interfaces + vinstall ${FILESDIR}/linkd.cnf 644 etc linkd.cnf install -d "${DESTDIR}/etc/network/ports.d" install -d "${DESTDIR}/etc/network/interfaces.d" install -d -m700 "${DESTDIR}/root" diff --git a/packages/busybox/template b/packages/busybox/template @@ -26,7 +26,7 @@ depends= # otherwise; six symbols gate them in bbunzip.c, all six go; kernel # initramfs xz stays independent via CONFIG_RD_XZ), # udhcpd (dnsmasq covers the server side), httpd/inetd (tcpsvd is our -# super-server), IFUP (unosd owns /etc/network/interfaces via unosc, busybox +# super-server), IFUP (linkd owns /etc/network/interfaces via linkctl, busybox # ifupdown would conflict and its `iface <name>` parser is for old # `iface <name> inet static` only -- cumulus `iface <name>` fails there), # MKFS_VFAT/MKDOSFS (dosfstools owns mkfs.vfat; two providers of one command diff --git a/packages/libmnl/template b/packages/libmnl/template @@ -38,5 +38,6 @@ do_install() { make install DESTDIR="${SYSROOT}" # .la files are build-time libtool metadata, never shipped rm -f "${DESTDIR}"/usr/lib/*.la "${SYSROOT}"/usr/lib/*.la - grep -q "^prefix=${SYSROOT}/usr$" "${SYSROOT}/usr/lib/pkgconfig/libmnl.pc" || die "libmnl: sysroot .pc not fixed" + # The sysroot .pc prefix is rewritten and verified centrally by mk/build.sh, + # which runs pc-fixup.sh after this function returns. } diff --git a/packages/linkd/files/linkd-check b/packages/linkd/files/linkd-check @@ -0,0 +1,3 @@ +#!/bin/sh +# runit check interface over the ready-file mechanism. +[ -e /run/linkd.ready ] && exit 0 || exit 1 diff --git a/packages/linkd/files/linkd-log-run b/packages/linkd/files/linkd-log-run @@ -0,0 +1,4 @@ +#!/bin/sh +# svlogd companion: runit captures stdout/stderr, so linkd needs no --log file +# when supervised. +exec svlogd -tt /var/log/linkd diff --git a/packages/linkd/files/linkd-run b/packages/linkd/files/linkd-run @@ -0,0 +1,9 @@ +#!/bin/sh +# +# linkd runit service. +# +# Readiness contract: linkd publishes /run/linkd.ready after its initial +# resync (see --ready-file). frr's run script blocks on `sv check linkd` +# so zebra never starts before the swpN netdevs exist. The daemon itself +# defaults to no ready-file; the supervised package opts in here. +exec linkd --ready-file /run/linkd.ready diff --git a/packages/linkd/template b/packages/linkd/template @@ -0,0 +1,36 @@ +# Template file for 'linkd' +pkgname=linkd +version=0.1.0 +revision=1 +short_desc="Network interface manager - ifupdown replacement with RESP plugins" +maintainer="finwo <finwo@pm.me>" +license="GPL-2.0-only" +homepage="https://git.finwo.net/app/linkd" +depends="busybox iproute2" +hostmakedepends="dep" +# First-party source, pinned by tag rather than sha256: see srcfiles in +# mk/build.sh. Until git.finwo.net serves tag archives, seed the cache by +# dropping the tarball at build/work/sources/linkd-v0.1.0.tar.gz -- the fetch +# is skipped whenever that file already exists. +srcfiles="https://git.finwo.net/app/linkd/archives/tags/v${version}.tar.gz" + +do_build() { + # linkd's Makefile assembles build/<triple>/ and runs `dep install` there, + # which needs network access to git.finwo.net for its vendored libraries. + # They are branch tips, so this build is not reproducible across upstream + # changes; the tag above pins linkd's own code only. + make -C "${WRKSRC}" -j"$(nproc)" + [ -x "${WRKSRC}/build/linux-glibc-amd64/linkd" ] || die "linkd binary missing after build" +} + +do_install() { + vinstall "${WRKSRC}/build/linux-glibc-amd64/linkd" 755 usr/bin linkd + # multicall: linkd dispatches on basename(argv[0]) + for link in linkctl ifup ifdown ifquery ifreload; do + ln -sfn linkd "${DESTDIR}/usr/bin/${link}" + done + vinstall ${FILESDIR}/linkd-run 755 etc/sv/linkd run + vinstall ${FILESDIR}/linkd-check 755 etc/sv/linkd check + vinstall ${FILESDIR}/linkd-log-run 755 etc/sv/linkd/log run + install -d "${DESTDIR}/var/log/linkd" +} diff --git a/packages/openssl/template b/packages/openssl/template @@ -28,7 +28,12 @@ hostmakedepends="perl" do_configure() { cd "${WRKSRC}" - perl ./Configure --prefix=/usr --libdir=lib no-docs linux-x86_64 + case "${ARCH:-x86_64}" in + x86_64) _ossl_target=linux-x86_64 ;; + aarch64|arm64) _ossl_target=linux-aarch64 ;; + *) die "openssl: unsupported ARCH ${ARCH}" ;; + esac + perl ./Configure --prefix=/usr --libdir=lib no-docs "${_ossl_target}" } do_build() { diff --git a/packages/unos-firstboot/files/unos-firstboot b/packages/unos-firstboot/files/unos-firstboot @@ -59,7 +59,7 @@ detect_bcm_switch() { install_pkg() { log "installing $1" - if ! xbps-install -y "$1"; then + if ! apk add "$1"; then log "failed to install $1" return 1 fi @@ -72,7 +72,7 @@ if detect_bcm_switch; then install_pkg openbcm || exit 1 else log "platform: generic, no switching ASIC detected" - log "unosd will use the built-in kernel dataplane" + log "linkd will use the built-in kernel dataplane" fi date -u +%Y-%m-%dT%H:%M:%SZ > "${MARKER}" diff --git a/packages/unos-firstboot/template b/packages/unos-firstboot/template @@ -1,7 +1,7 @@ # Template file for 'unos-firstboot' pkgname=unos-firstboot version=0.1.0 -revision=1 +revision=2 short_desc="One-time platform provisioning for UNOS" maintainer="finwo <finwo@pm.me>" license="GPL-2.0-only" diff --git a/packages/unos-keys/files/apk/unos-ci-62a2ab39@rtr.rsa.pub b/packages/unos-keys/files/apk/unos-ci-62a2ab39@rtr.rsa.pub @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3mlzqXdhdBGqT5USlAsS +U9innXqoOp1mwmGRQ6355httKrVEdF0C7pQn/i1TBci72Dl3PaE3dHGcBSpW7GFT +FfuDwEQTx5jszs3EcQVvejgUkukDIh8Jvm8NgZ7ZM+ZnYjFeiop1KNpKzGkM0f43 +HL7pxwVeRN3Tmc0kJTmbOcNEdo7nWqA9xkM0foHTkNqDqeQ6BTWZ20hfEgV4M1G5 +xLXPukIZGFqggm+qNELuf8lx+khYquJdLoc4+h9xBU7+TR769APXScXH+s3y6Xkl +eRYZE8EGEQ6Y3spCDUd8r14GR/l+lOItWZmGp/CvOvNi3r/N5LSzQ3ugvfEYpuNJ +yP7gI/xfjvx5+q+clyT/YIvwtoSTnc7FHDUrOtstd7F68I2DI8KKY494qH4ky7Td +nvk2/7VRszTiiaHWmeEq8zjxI+6zC7Lk5O9uovxeL8aaWxBSWtAnJsu8HFOMzxnA +5X2yVwCFLL2PUCmF3VdXudF73Ifuaez4tpSCknVej5S5uCKEd+szLxNYCTVnGeJT +BWTzN3fY5WLjoY8FMXNd4mGoCXmo8KwtNqS2NL0WzTFT6kmOZBXEqBmhmkPhVL6J +REk7J27Ma8/sR0TQfP3LRqqAwzr8KrHT5QS0SLshEEuuohx7PMuKCkTHij288E32 +C/VjonHVUbU7nk7nhShEI48CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat06.rsa.pub b/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat06.rsa.pub @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA5u30fHsCzzaYe6b984V3 +Baf2qRyVBemxeqFx1swEbWNEtumol3wiGlcUoGHqMvmcCicOD5YyYE+i015JciA7 +Vl8Fd1EVt6MQBFhXMHz9dOt9d8inMzbe3qdiolCfiIAhO5Roj3kJ6fNnSRUaTPWM +J4ym/pNiUtju8yVb+m7WUoSaN+U4o7BG/AvvzllIw1usORY2OrGCAmTXN56ebzSI +GYYchBPI7g4pwlNxgWwvxgXN51LEG6sw8MGkwdJp7ltKx2nrjC/GZRmWuZEIN9uL +1R5g+REb9jjF4PC0sCpCJRwr9JtE+b3BqB4E9freNAOu/AsG6HMQoAac8DNyZRZW +wAI6Bw47ZsfYC94oI2qAEkHV7BoS0r+mDQxo8TIkLNYGP3p3sSec0//WYAXZpITk ++TCGjrhDDbfjPz9WF4e11dQqRl+Om3JxB2b6GL7luIMlnDwU5LUmvDFGSywRthER +NAWCnTKnOP17/duvWTIUt7svOejZIUz5lP/5Gb9LVb8qhIrbratS8rhQ3+z/Dh3D +Qjr8H3wNgpC6FaY7AuIKvCAj+VjnEAxc8oz/8Qs06lu55pFcyYGfe0lxuKmeHEG8 +PDyijgZ7UYiJd5xauA7oP1MLbczTOHcHYkNRigV7uRrH1O5VtRp0Z3U173H2NI6b +cBpWNEqMpvA2G9TpFafTiL8CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat07.rsa.pub b/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat07.rsa.pub @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAo7yaczj88sui/Jns0t5/ +1APsaJF61seXYvssIeIDYjxUVZB2UEgLt3bze/QsUIXUfmdqcWMq1B0cfhfLKy6I +5jdG63BLwcPhWSyExr80BsOMpVlvqjUGWNoMw5oBVNojhGCJocTFOfn2pZfTt+CS +AK9M4VslEfwNT7/Um6QHrhosueI+DdakUsV6cN+jdvqx+KIO5cRyQkxqpQLWXjZb +0Ycn6dZtNXoLFsWCD05MzXI52zniXcIe+N8x5gPfTVAQgQylDhAmC+2K013GfjoL +uiiCdeoRzSJR2DgmmXQNKI9x+zvZTRSo/kyD/l2Y0VTJCHshemQEZouK8oxBDb+V +MHapKr66l8w7M0hOehNpPnpbYcXv65pRrwq0QA88aUkR8B/Y7h+I+n26529txBcp +kQKGjhioWoSqFJv8LIMO3PIvLkB9b1lYLmwcDjIr/o4SOyKv37Fy2fryOTOkYhIm +O59jXmDLldJhuEH5LXeY8cbrQ+oCBU/xfr4+45ZvH+9Qcmwui7MaYfHEMvjFisDb +oQ8YrdqHHoOTDOPAebk5wBOych846Qg0+967oIz1mxXMzJu6aAPLDW7BIFRsuMoB +f3InqMymRCH7+o6qxIdThQy9zMiPSjMGHEU0wNzow6je2NFvfFixRwJOcfRz0yF6 +o31O38aSgPuMH6IhrNhAOlUCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat13.rsa.pub b/packages/unos-keys/files/apk/unos-ci-62a2ab39@sat13.rsa.pub @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApNbSt/8Dm1Kx4CoaygXh +Q3DpdBfQIh16dcPx2P+UCUImMYIWShymDNv1QBgfG80XoKNzKPAPOnA7pMIb/MHu +VwTtX3Ns3I8cWgcFYrm+q0K/hTMkreDgFtevC55npF4eywBUGAuLMYhDEqso5gT5 +3FSFkHBgQh6drAV1NqzMwYMwo/QhJTKaD7CJsOVRBfEA2rUN/YftOXUkieFxF3DB +Vaztsv5dEi3nCcvOgE/F6yJ46sVHoH5xjphzdUsYOU3J7L7UV9sOUtoPdO4jT+cG +N1Ig71B/J7Qzf9IRJCXmj6YcNXjx5aZZpvLr6oCTKGzEMReufXLVAuWmP4qmdEN0 +rhjYxV6fr3KtMsFIE3SFRM+E2jbgMmbRaYqcq6gWBksT4K8jpzhcSuKzN0BfyMiu +2hl5P4PBoPIb/dwViqWGAQQxjcJXXo5hY9oFOkYH2f+bnKVgtDqSSr9CNZ1DVdQd +enJl6ANACVj9BS/kJndSljsBt+OMQVxMly9LhLaa6v6AAZnQdsPk3L3hADq9kKkL +Thrw7gFkxECLlRhuyPctUFTaRYwaSq+Zy+q4eIuuCqIWKr3UOJK7H7NPxG+75HLI +NrEp0yf1KCwwRnyHPVDs1oGuxT8XeWI3ZjztZRfXdpPpx+exWXlSNuvaaVKJdJ0T +UZdRCLs6OXOD2w42CMzQiK0CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/packages/unos-keys/files/unos/unos-ci-62a2ab39@rtr.ed25519.pub b/packages/unos-keys/files/unos/unos-ci-62a2ab39@rtr.ed25519.pub @@ -0,0 +1,3 @@ +-----BEGIN SUPERCOP PUBLIC KEY----- +tJAx/bL45SsPn+GJIVSsetIMpI3K3MhZVH7IwY/yk6c= +-----END SUPERCOP PUBLIC KEY----- diff --git a/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat06.ed25519.pub b/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat06.ed25519.pub @@ -0,0 +1,3 @@ +-----BEGIN SUPERCOP PUBLIC KEY----- +IUPZQyCdN8bEIHcqVnlAls2pYnfabCz70Pg7QGdd2mE= +-----END SUPERCOP PUBLIC KEY----- diff --git a/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat07.ed25519.pub b/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat07.ed25519.pub @@ -0,0 +1,3 @@ +-----BEGIN SUPERCOP PUBLIC KEY----- +Zya5i0FVG8iQGl3RSn8GAFKVXuq9OHiSc3r7zuLD7AY= +-----END SUPERCOP PUBLIC KEY----- diff --git a/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat13.ed25519.pub b/packages/unos-keys/files/unos/unos-ci-62a2ab39@sat13.ed25519.pub @@ -0,0 +1,3 @@ +-----BEGIN SUPERCOP PUBLIC KEY----- +OoYlU5+0fTMZNUxHh6dg+YXQdDiMjT1rPncLxvkjKZY= +-----END SUPERCOP PUBLIC KEY----- diff --git a/packages/unos-keys/template b/packages/unos-keys/template @@ -1,15 +1,34 @@ # Template file for 'unos-keys' pkgname=unos-keys version=0.1.0 -revision=1 +revision=2 short_desc="UNOS trust anchors - apk and installer verification keys" maintainer="finwo <finwo@pm.me>" license="GPL-2.0-only" homepage="https://unos.finwo.dev" -# Key ceremony: private halves live in ~/.unos-keys/ and never enter git. -# Public halves are committed here by mk/keymgmt.sh (public by design): +# Key ceremony: private halves live in ~/.unos-keys/ (developer) or on the CI +# workers under the unos-sign-key feature, and never enter git. Public halves +# are committed here (public by design): # files/apk/* -> /etc/apk/keys/, files/unos/* -> /etc/unos/keys/. -# Rotation = add the new .pub files and bump version. +# Rotation = add the new .pub files and bump revision. +# +# EVERY CI worker's public key must be here. Each of the four workers holds a +# distinct keypair, and mk/build.sh names the signature record after whichever +# key signed it (.SIGN.RSA.<keyname>.rsa.pub), so the signer is decided by +# wherever the conductor happened to schedule the build. Ship fewer than all of +# them and verification fails for an arbitrary, run-to-run-varying subset of +# packages -- the failure looks like corruption, not like a missing key. +# +# Consequences worth knowing before adding a fifth worker: +# - publish this package BEFORE that worker builds anything, or existing +# installs cannot verify its output; +# - any one worker can sign packages every client trusts, so these four are +# jointly as trusted as the repo itself. The intended end state is a +# release step that re-signs with a single key and merely *accepts* these +# as inputs; that does not exist yet. +# +# unos-dev@finwo.dev-096b7b41 is the developer key, kept so locally built +# packages still install. It should be dropped once CI is publishing. do_install() { for k in "${FILESDIR}"/apk/*; do [ -e "${k}" ] || break diff --git a/packages/unosd/files/unosd-check b/packages/unosd/files/unosd-check @@ -1,3 +0,0 @@ -#!/bin/sh -# runit check interface over the ready-file mechanism. -[ -e /run/unosd.ready ] && exit 0 || exit 1 diff --git a/packages/unosd/files/unosd-log-run b/packages/unosd/files/unosd-log-run @@ -1,4 +0,0 @@ -#!/bin/sh -# svlogd companion: runit captures stdout/stderr, so unosd needs no --log file -# when supervised. -exec svlogd -tt /var/log/unosd diff --git a/packages/unosd/files/unosd-run b/packages/unosd/files/unosd-run @@ -1,9 +0,0 @@ -#!/bin/sh -# -# unosd runit service. -# -# Readiness contract: unosd publishes /run/unosd.ready after its initial -# resync (see --ready-file). frr's run script blocks on `sv check unosd` -# so zebra never starts before the swpN netdevs exist. The daemon itself -# defaults to no ready-file; the supervised package opts in here. -exec unosd --ready-file /run/unosd.ready diff --git a/packages/unosd/template b/packages/unosd/template @@ -1,28 +0,0 @@ -# Template file for 'unosd' -pkgname=unosd -version=0.1.0 -revision=5 -short_desc="UNOS control plane daemon - kernel netlink mirror into the dataplane" -maintainer="finwo <finwo@pm.me>" -license="GPL-2.0-only" -homepage="https://unos.finwo.dev" -depends="busybox iproute2" -hostmakedepends="dep" - -do_build() { - # Single multicall binary (src/main.c dispatch + src/cli/*.c constructors) - make -C "${UNOS_MKDIR}/../src/unosd" -j"$(nproc)" - [ -x "${UNOS_MKDIR}/../src/unosd/build/linux-glibc-amd64/unosd" ] || die "unosd binary missing after build" -} - -do_install() { - vinstall "${UNOS_MKDIR}/../src/unosd/build/linux-glibc-amd64/unosd" 755 usr/bin unosd - # multicall: unosc and if* are symlinks to the same binary (argv[0] dispatch in src/main.c) - for link in unosc ifup ifdown ifquery ifreload; do - ln -sfn unosd "${DESTDIR}/usr/bin/${link}" - done - vinstall ${FILESDIR}/unosd-run 755 etc/sv/unosd run - vinstall ${FILESDIR}/unosd-check 755 etc/sv/unosd check - vinstall ${FILESDIR}/unosd-log-run 755 etc/sv/unosd/log run - install -d "${DESTDIR}/var/log/unosd" -} diff --git a/packages/zlib/template b/packages/zlib/template @@ -37,5 +37,6 @@ do_install() { make install DESTDIR="${DESTDIR}" make install DESTDIR="${SYSROOT}" rm -f "${DESTDIR}/usr/lib/libz.a" "${SYSROOT}/usr/lib/libz.a" - grep -q "^prefix=${SYSROOT}/usr$" "${SYSROOT}/usr/lib/pkgconfig/zlib.pc" || die "zlib: sysroot .pc not fixed" + # The sysroot .pc prefix is rewritten and verified centrally by mk/build.sh, + # which runs pc-fixup.sh after this function returns. } diff --git a/tests/helpers.sh b/tests/helpers.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# tests/helpers.sh - shared assertions, source with `. tests/helpers.sh` +# +# Assertions only. This repo builds and signs packages; it never boots a +# machine, so the QEMU and SSH helpers that used to live alongside these +# belong to the unos OS-assembly repo instead. +set -eu + +assert_eq() { + # assert_eq <got> <expected> <msg> + if [ "$1" != "$2" ]; then + echo "FAIL: $3: expected '$2', got '$1'" >&2 + return 1 + fi + echo "PASS: $3" +} + +assert_contains() { + # assert_contains <haystack> <needle> <msg> + case "$1" in + *"$2"*) echo "PASS: $3" ;; + *) echo "FAIL: $3: expected to contain '$2', got '$1'" >&2; return 1 ;; + esac +} + +assert_file_contains() { + # assert_file_contains <file> <needle> <msg> + # `--` matters: without it a needle starting with '-' (any command-line flag, + # e.g. -Dmeson-docs=disabled) is parsed by grep as options and never matches. + if grep -qF -- "$2" "$1" 2>/dev/null; then + echo "PASS: $3" + else + echo "FAIL: $3: $1 does not contain '$2'" >&2 + return 1 + fi +} + +assert_path_exists() { + # assert_path_exists <path> <msg> + # Use before grepping a file. A grep that matches nothing because the file + # moved looks exactly like one that matches nothing because the code is + # correct, and only the latter should pass. + if [ -e "$1" ]; then + echo "PASS: $2" + else + echo "FAIL: $2: no such path: $1" >&2 + return 1 + fi +} + +# Newest built .apk for a package, or empty if none. Tests must never hardcode +# a revision: doing so silently tests a stale artifact and then silently skips +# once that artifact is pruned. +newest_apk() { + # newest_apk <pkg> [arch] + _p=$1; _a=${2:-x86_64}; _root=${ROOT:?ROOT must be set} + _found="" + for f in "${_root}/build/repo/${_a}/${_p}"-*.apk; do + [ -e "${f}" ] || continue + # `zlib-1.3.1-r0.apk` belongs to zlib; `zlib-doc-...` does not, so require + # the character after the name to begin a version field. + case "$(basename "${f}")" in + "${_p}"-[0-9]*) _found=${f} ;; + esac + done + printf '%s\n' "${_found}" +} + +# Revision a template currently declares, so tests follow the template rather +# than a copy of it that rots. +template_pkgver() { + # template_pkgver <pkg> + _p=$1; _root=${ROOT:?ROOT must be set} + _t="${_root}/packages/${_p}/template" + [ -f "${_t}" ] || return 1 + _v=$(sed -n 's/^version=//p' "${_t}" | head -1) + _r=$(sed -n 's/^revision=//p' "${_t}" | head -1) + printf '%s-r%s\n' "${_v}" "${_r}" +} diff --git a/tests/run.sh b/tests/run.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# tests/run.sh - run the package-repository test suite. +# +# ./tests/run.sh everything +# ./tests/run.sh --unit same (only unit tests exist here) +# +# These tests validate the dependency graph, the generated pipeline, applet +# policy and packaging layout. None of them boots a machine: anything that +# needs a running system lives in the unos OS-assembly repo. +set -eu +HERE=$(cd "$(dirname "$0")" && pwd) + +fail=0 +run_one() { + echo + echo "=== $1 ===" + if sh "$1"; then + echo "--- PASS $1" + else + echo "--- FAIL $1" >&2 + fail=1 + fi +} + +for t in "${HERE}"/unit/*.sh; do + [ -e "$t" ] || continue + run_one "$t" +done + +echo +if [ "$fail" = "0" ]; then + echo "ALL TESTS PASSED" +else + echo "SOME TESTS FAILED" >&2 +fi +exit "$fail" diff --git a/tests/unit/test_ascii.sh b/tests/unit/test_ascii.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/../.." && pwd) + +echo "==> test_ascii: source and docs are pure ASCII" + +# Non-ASCII is forbidden in tracked source and markdown. No em-dashes (use --), +# no arrows (use ->), no box-drawing (use |-- and `--), no curly quotes. +# build/ is generated. Key material is excluded because it is base64 blobs +# whose content we neither wrote nor control. +cd "$ROOT" +hits=$(git ls-files -z \ + ':!:build/**' \ + ':!:packages/unos-keys/files/**' \ + | xargs -0 grep -nIP '[^\x00-\x7F]' 2>/dev/null || true) + +if [ -n "$hits" ]; then + echo "FAIL: non-ASCII bytes in tracked files:" >&2 + echo "$hits" >&2 + exit 1 +fi +echo "PASS: no non-ASCII in tracked sources" + +# Untracked working-tree sources count too, otherwise new files sneak past. +new=$(git ls-files -zo --exclude-standard \ + ':!:build/**' \ + ':!:packages/unos-keys/files/**' \ + | xargs -0 grep -nIP '[^\x00-\x7F]' 2>/dev/null || true) + +if [ -n "$new" ]; then + echo "FAIL: non-ASCII bytes in untracked files:" >&2 + echo "$new" >&2 + exit 1 +fi +echo "PASS: no non-ASCII in untracked sources" diff --git a/tests/unit/test_busybox.sh b/tests/unit/test_busybox.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/../.." && pwd) +. "${HERE}/../helpers.sh" + +echo "==> test_busybox: applet policy" + +# Locate the busybox we built. The version is globbed rather than hardcoded so +# a template version bump does not silently turn this test into a skip. +BUSYBOX="" +for c in "${ROOT}"/build/work/busybox/src/busybox-*/busybox; do + [ -x "${c}" ] && BUSYBOX=${c} +done + +# Deliberately no fallback to a host busybox. The previous version fell back to +# whatever `busybox` was on PATH, which tests a completely unrelated binary's +# applet policy: on a machine without busybox it reported our required applets +# missing, and on a machine with a full busybox it would have passed while +# telling us nothing about the package. +if [ -z "${BUSYBOX}" ]; then + echo "SKIP: busybox not built yet (run ./mk/build.sh busybox)" + echo "==> test_busybox done" + exit 0 +fi + +have=$("$BUSYBOX" --list 2>/dev/null || echo "") +[ -n "${have}" ] || { echo "FAIL: ${BUSYBOX} --list produced nothing" >&2; exit 1; } + +for a in init halt poweroff reboot getty login mount umount switch_root ash runsv runsvdir sv svlogd chpst setuidgid tcpsvd udhcpc; do + echo "$have" | grep -qx "$a" || { echo "FAIL: required $a missing" >&2; exit 1; } + echo "PASS: busybox has $a" +done + +for a in ip tc ldconfig unlzma lzcat lzma unxz xzcat xz udhcpd httpd inetd ifup ifdown; do + if echo "$have" | grep -qx "$a"; then + echo "FAIL: forbidden $a present" >&2; exit 1 + fi + echo "PASS: busybox no $a" +done + +# Exactly one package may provide `ip`. The old form of this check looked for +# rootfs/sbin/ip, but an assembled rootfs is the OS-assembly repo's output and +# does not exist here; asserting it at the package level tests the same +# invariant one layer earlier, and apk would refuse the overwrite anyway. +IPROUTE_APK=$(newest_apk iproute2) +if [ -n "${IPROUTE_APK}" ]; then + if tar -tf "${IPROUTE_APK}" 2>/dev/null | grep -qx "sbin/ip"; then + echo "PASS: iproute2 package provides sbin/ip" + else + echo "FAIL: iproute2 package does not provide sbin/ip" >&2; exit 1 + fi +else + echo "SKIP: iproute2 not built yet" +fi + +BUSYBOX_APK=$(newest_apk busybox) +if [ -n "${BUSYBOX_APK}" ]; then + if tar -tf "${BUSYBOX_APK}" 2>/dev/null | grep -qE '(^|/)(s?bin)/ip$'; then + echo "FAIL: busybox package also provides ip (collides with iproute2)" >&2; exit 1 + fi + echo "PASS: busybox package does not provide ip" +else + echo "SKIP: busybox not built yet" +fi + +# The applet list above proves ifup is absent from this build; the .config +# check proves it was turned off deliberately rather than dropped by accident. +# Derived from $BUSYBOX so it cannot point at a different version than the +# binary we just interrogated. +BBCONFIG="$(dirname "${BUSYBOX}")/.config" +assert_path_exists "${BBCONFIG}" "busybox .config present" +if grep -q "^# CONFIG_IFUP is not set" "${BBCONFIG}"; then + echo "PASS: busybox .config IFUP off" +else + echo "FAIL: busybox .config IFUP not off" >&2; exit 1 +fi + +echo "==> test_busybox done" diff --git a/tests/unit/test_deps.sh b/tests/unit/test_deps.sh @@ -0,0 +1,142 @@ +#!/bin/sh +# test_deps.sh - the build-dependency graph and host bootstrap stay honest. +# +# Both of these were gaps that only bite in CI or on a fresh checkout, which is +# exactly when nobody is around to debug them: +# - build/host/bin/{apk,muon} were built by hand from prose in mk/README.md +# - hostmakedepends/makedepends were documented but read by nothing +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/../.." && pwd) +. "${ROOT}/tests/helpers.sh" + +echo "==> test_deps: build graph + host bootstrap" + +# --- the graph itself ------------------------------------------------------ +out=$("${ROOT}/mk/deps.sh" check 2>&1) || { echo "FAIL: deps.sh check: $out" >&2; exit 1; } +assert_contains "$out" "graph OK" "dependency graph validates (no cycles, no dangling makedepends)" + +# Ordering must be a real topological sort, not just a list. Every package must +# appear exactly once, and every makedepends strictly before its dependent. +order=$("${ROOT}/mk/deps.sh" order) +n_all=$("${ROOT}/mk/deps.sh" list | wc -l | tr -d ' ') +n_ord=$(printf '%s\n' "$order" | wc -l | tr -d ' ') +n_uniq=$(printf '%s\n' "$order" | sort -u | wc -l | tr -d ' ') +assert_eq "$n_ord" "$n_all" "every package appears in the build order" +assert_eq "$n_uniq" "$n_all" "no package appears twice in the build order" + +# This is the property that actually matters, and the one a naive recursive +# shell function gets wrong (no function-local variables in POSIX sh). +fail=0 +"${ROOT}/mk/deps.sh" graph | while IFS="$(printf '\t')" read -r pkg dep; do + [ -n "$pkg" ] || continue + pos_pkg=$(printf '%s\n' "$order" | grep -nxF "$pkg" | cut -d: -f1) + pos_dep=$(printf '%s\n' "$order" | grep -nxF "$dep" | cut -d: -f1) + if [ -z "$pos_dep" ] || [ -z "$pos_pkg" ] || [ "$pos_dep" -ge "$pos_pkg" ]; then + echo "FAIL: $dep must be built before $pkg (positions $pos_dep vs $pos_pkg)" >&2 + exit 1 + fi +done || fail=1 +[ "$fail" = "0" ] || exit 1 +echo "PASS: every makedepends is ordered before its dependent" + +# The graph must not be vacuously empty -- that was the original bug report. +edges=$("${ROOT}/mk/deps.sh" graph | wc -l | tr -d ' ') +if [ "$edges" -lt 5 ]; then + echo "FAIL: dependency graph has only ${edges} edges; templates are not declaring makedepends" >&2 + exit 1 +fi +echo "PASS: graph is populated (${edges} edges)" + +# --- host bootstrap -------------------------------------------------------- +[ -x "${ROOT}/mk/bootstrap-host.sh" ] || { echo "FAIL: mk/bootstrap-host.sh missing" >&2; exit 1; } +out=$("${ROOT}/mk/bootstrap-host.sh" --check 2>&1) || { echo "FAIL: host tools not bootstrapped: $out" >&2; exit 1; } +assert_contains "$out" "host tools present and pinned" "bootstrap-host.sh --check passes" + +# --check must verify VERSION, not just existence: a stale binary from an older +# pin is worse than a missing one because everything downstream still "works". +assert_file_contains "${ROOT}/mk/bootstrap-host.sh" "MUON_SHA256" "muon is pinned by sha256" +assert_file_contains "${ROOT}/mk/bootstrap-host.sh" "APK_SHA256" "apk-tools is pinned by sha256" + +# The bootstrap must not reach the network beyond its pinned tarballs. muon's +# setup git-clones meson-docs/meson-tests unless they are disabled. +assert_file_contains "${ROOT}/mk/bootstrap-host.sh" "-Dmeson-docs=disabled" "muon docs subproject disabled (no build-time git fetch)" +assert_file_contains "${ROOT}/mk/bootstrap-host.sh" "-Dmeson-tests=disabled" "muon tests subproject disabled (no build-time git fetch)" + +# --- the CI pipeline is generated from that same graph --------------------- +# .conductor.yml carries ~30 tasks and their `needs` edges. Hand-maintaining +# those against packages/*/template drifts, and the failure mode is a package +# building before the library it links against -- which usually still succeeds, +# quietly, against a stale copy. +[ -f "${ROOT}/.conductor.yml" ] || { echo "FAIL: no .conductor.yml" >&2; exit 1; } +out=$("${ROOT}/mk/gen-pipeline.sh" --check 2>&1) || { + echo "FAIL: .conductor.yml is out of date; run ./mk/gen-pipeline.sh --write" >&2 + echo "$out" | head -20 >&2 + exit 1 +} +echo "PASS: .conductor.yml matches the dependency graph" + +# Every package must have a task, or a push would silently not build it. +npkg=$("${ROOT}/mk/deps.sh" list | wc -l | tr -d ' ') +ntask=$(grep -c '^ pkg-' "${ROOT}/.conductor.yml") +assert_eq "$ntask" "$npkg" "every package has a pipeline task" + +# The credentials the runner provides, and nothing invented alongside them. +assert_file_contains "${ROOT}/.conductor.yml" "BUCKET_ENDPOINT" "pipeline uses BUCKET_ENDPOINT" +assert_file_contains "${ROOT}/.conductor.yml" "BUCKET_NAME" "pipeline uses BUCKET_NAME" +if grep -q 'REPO_URL' "${ROOT}/.conductor.yml"; then + echo "FAIL: pipeline references REPO_URL, which the runner does not provide" >&2 + exit 1 +fi +echo "PASS: pipeline references only provided environment" + +# $ARCH is only injected into tasks that declare arch; using it without is an +# empty string and a silently wrong repo URL. +if grep -q '\$ARCH' "${ROOT}/.conductor.yml"; then + nuse=$(grep -c '\$ARCH' "${ROOT}/.conductor.yml") + narch=$(grep -c '^ arch: \[' "${ROOT}/.conductor.yml") + [ "$narch" -gt 0 ] || { echo "FAIL: pipeline uses \$ARCH but no task declares arch" >&2; exit 1; } + echo "PASS: \$ARCH used in ${nuse} places, ${narch} tasks declare arch" +fi + +# Feature names are a global namespace on the conductor, which hosts more than +# UNOS, so ours are project-scoped. A task asking for a feature no worker +# advertises is not scheduled at all -- it does not fail loudly, it simply +# never runs, which is far harder to diagnose than a build error. +if grep -q 'requires:.*[][ ]sign-key[],]' "${ROOT}/.conductor.yml"; then + echo "FAIL: pipeline requires bare 'sign-key'; the feature is 'unos-sign-key'" >&2 + grep -n 'sign-key' "${ROOT}/.conductor.yml" | head -5 >&2 + exit 1 +fi +nsign=$(grep -c 'requires: \[unos-sign-key\]' "${ROOT}/.conductor.yml") +[ "${nsign}" -gt 0 ] || { echo "FAIL: no task requires unos-sign-key" >&2; exit 1; } +echo "PASS: ${nsign} tasks require the project-scoped unos-sign-key feature" + +# Every task that signs must ask for the key. Building without it fails late, +# after the whole package has compiled. +nbuild=$(grep -c 'mk/build.sh' "${ROOT}/.conductor.yml") +[ "${nsign}" -ge "${nbuild}" ] || { + echo "FAIL: ${nbuild} tasks build but only ${nsign} require the signing key" >&2 + exit 1 +} +echo "PASS: every building task requires the signing key" + +# --- build.sh actually reads the declarations ------------------------------ +assert_file_contains "${ROOT}/mk/build.sh" "hostmakedepends" "build.sh reads hostmakedepends" +assert_file_contains "${ROOT}/mk/build.sh" "makedepends" "build.sh reads makedepends" + +# PATH ordering trap: hostmakedepends is checked before the build phases, so +# build/host/bin must already be on PATH or a package declaring `muon` fails +# its own check despite muon being present. +# Anchor on the executable check (`command -v "${_h}"`), not on the word +# hostmakedepends, which also appears in comments above it. +pathline=$(grep -n 'export PATH=.*build/host/bin' "${ROOT}/mk/build.sh" | head -1 | cut -d: -f1) +checkline=$(grep -n 'command -v "\${_h}"' "${ROOT}/mk/build.sh" | head -1 | cut -d: -f1) +if [ -z "$pathline" ] || [ -z "$checkline" ] || [ "$pathline" -ge "$checkline" ]; then + echo "FAIL: build/host/bin must be on PATH before hostmakedepends is checked (PATH line $pathline, check line $checkline)" >&2 + exit 1 +fi +echo "PASS: host tools are on PATH before hostmakedepends is checked" + +echo "==> test_deps done" diff --git a/tests/unit/test_packaging.sh b/tests/unit/test_packaging.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# Packaging layout assertions: what ends up inside the .apk. +# +# The daemon's own behaviour is tested in the linkd repo. What matters here is +# that the package presents it correctly -- above all the argv[0] symlinks, +# since linkd dispatches on basename(argv[0]) and a plain file instead of a +# symlink turns `ifup` into a daemon launch. +set -eu +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "${HERE}/../.." && pwd) +. "${HERE}/../helpers.sh" + +echo "==> test_packaging: apk contents and symlinks" + +APK=$(newest_apk linkd) +if [ -z "${APK}" ]; then + echo "SKIP: no linkd .apk built yet (run ./mk/build.sh linkd)" + echo "==> test_packaging done" + exit 0 +fi + +echo "using $(basename "${APK}")" + +# The built artifact must correspond to what the template currently declares. +# The previous version of this test hardcoded linkd-0.1.0-r4.apk while the +# template had moved to r5: it kept passing against a stale package, and would +# have started silently skipping once r4 was pruned. +want=$(template_pkgver linkd) +case "$(basename "${APK}")" in + "linkd-${want}.apk") echo "PASS: newest .apk matches template revision (${want})" ;; + *) + echo "FAIL: newest .apk is $(basename "${APK}") but template declares ${want}" >&2 + echo " rebuild with ./mk/build.sh linkd" >&2 + exit 1 ;; +esac + +listing=$(tar -tvf "${APK}" 2>&1) + +for link in linkctl ifup ifdown ifquery ifreload; do + if printf '%s\n' "${listing}" | grep -q "usr/bin/${link} -> linkd"; then + echo "PASS: ${link} -> linkd" + else + echo "FAIL: usr/bin/${link} is not a symlink to linkd" >&2 + printf '%s\n' "${listing}" | grep -E "usr/bin/(${link}|linkd)" | head -5 >&2 || true + exit 1 + fi +done + +# The runit service dir has to be present or the daemon never starts on boot. +for f in etc/sv/linkd/run etc/sv/linkd/check etc/sv/linkd/log/run; do + if printf '%s\n' "${listing}" | grep -q "${f}"; then + echo "PASS: ships ${f}" + else + echo "FAIL: missing ${f}" >&2 + exit 1 + fi +done + +echo "==> test_packaging done"