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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Build & Test

on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:

jobs:
x86_64:
name: ExampleOS x86_64
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false

- name: Install Build Dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
bc binutils build-essential bzip2 cpio \
diffutils file findutils git gzip \
libncurses-dev libssl-dev perl patch \
python3 rsync sed tar unzip wget \
autopoint bison flex autoconf automake \
mtools

- name: Install asciidoctor-pdf
run: sudo gem install --no-document asciidoctor-pdf

# Full history, the OS version comes from `git describe` here.
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: recursive

# Keeps codeload.github.com from rate limiting the build.
- name: Restore Downloads
uses: actions/cache/restore@v5
with:
path: dl/
key: dl-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: dl-

- name: Restore ccache
uses: actions/cache/restore@v5
with:
path: .ccache/
key: ccache-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: ccache-

- name: Configure x86_64
run: make x86_64_defconfig

- name: Build x86_64
run: make

- name: Test x86_64
run: make test

- name: Publish Test Result
if: always()
run: cat infix/test/.log/last/result-gh.md >> $GITHUB_STEP_SUMMARY

- name: Test Report x86_64
if: always()
run: make test-report

- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v7
with:
name: test-report
path: output/images/test-report.pdf
retention-days: 7

# Also on failure, so a retry keeps what did get fetched.
- name: Save Downloads
if: always()
uses: actions/cache/save@v5
with:
path: dl/
key: dl-${{ github.run_id }}-${{ github.run_attempt }}

- name: Save ccache
if: always()
uses: actions/cache/save@v5
with:
path: .ccache/
key: ccache-${{ github.run_id }}-${{ github.run_attempt }}

# Just what `make run` boots, on demand, to stay inside the free
# artifact quota.
- name: Upload Image
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v7
with:
name: example-os-x86_64
path: |
output/images/*.qcow2
output/images/OVMF.fd
retention-days: 7

# Artifacts expire after 90 days at most, release assets never do.
- name: Release
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" --generate-notes \
output/images/*.qcow2 \
output/images/OVMF.fd \
output/images/test-report.pdf
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dl/
output/
.ccache/
__pycache__/
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "infix"]
path = infix
url = https://github.com/kernelkit/infix.git
1 change: 1 addition & 0 deletions Config.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
source "$BR2_EXTERNAL_EXAMPLE_PATH/package/Config.in"
58 changes: 58 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Build the spin by handing over to Buildroot with two br2-externals
# stacked: Infix first, then this repo (branding, overlay, packages).
export BR2_EXTERNAL := $(CURDIR)/infix:$(CURDIR)

# Run our tests instead of Infix's, using Infix's test harness, see test/
export TESTS := $(CURDIR)/test/case/all.yaml
export NINEPM_PROJ_CONFIG := $(CURDIR)/test/9pm-proj.yaml

# Brand the generated test report with our logo instead of the Infix one.
# The bracket suffix is an Asciidoctor image spec, tweak to taste.
export LOGO := $(CURDIR)/doc/logo.png[top=40%, align=right, pdfwidth=10cm]

O ?= $(CURDIR)/output
config := $(O)/.config
bmake = $(MAKE) -C infix/buildroot O=$(O) $1
imake = $(MAKE) -C infix O=$(O) $1

all: $(config) infix/buildroot/Makefile
@+$(call bmake,$@)

$(config):
@+$(call bmake,list-defconfigs)
@echo "ERROR: No configuration selected."
@echo "Please choose a configuration from the list above by running"
@echo "'make <board>_defconfig' before building an image."
@exit 1

%_defconfig: configs/%_defconfig
@+$(call bmake,$@)

# Boot the image in QEMU and run $(TESTS) against it. Needs Docker or
# Podman; the harness pulls a container with QEMU and the test tooling.
# Explicit rule, since the catch-all below would find the test/ directory
# and think there is nothing to do.
test: | infix/buildroot/Makefile
@+$(call imake,$@)

# Render the result of the last `make test` as output/images/test-report.pdf.
# Needs asciidoctor-pdf: gem install asciidoctor-pdf
test-report: | infix/buildroot/Makefile
@+$(call imake,$@)

# Any other target (menuconfig, clean, migrate-configs, ...) is passed to
# Infix, which handles its own targets and forwards the rest to Buildroot.
%: | infix/buildroot/Makefile
@+$(call imake,$@)

infix/buildroot/Makefile:
@git submodule update --init --recursive

# configs/*.base. YES=1 follows Infix on everything
sync = ./infix/utils/update-defconfig.sh $(if $(YES),-y)

# Regenerate configs/
defconfigs-update:
@$(sync) infix/configs/x86_64_defconfig configs/x86_64_defconfig

.PHONY: all test test-report defconfigs-generate
147 changes: 146 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,146 @@
# infix-spin-example
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="doc/logo-dark.png">
<img src="doc/logo.png" alt="ExampleOS -- an Infix spin" width="420">
</picture>
</p>

This is a *spin* of [Infix OS](https://github.com/kernelkit/infix) a
tiny skeleton repo showing how to make Infix your own without forking
it. Infix is included as a submodule; this repo only adds a thin layer
on top:

* **Branding** — OS name, vendor, hostname, and boot tagline, all in
`config-snippets/`
* **Packages** — an example `hello-world` daemon, built from source in
this repo and supervised by finit (`package/`, `src/`)
* **Models** — a YANG model for that daemon, which is all it takes to
get it into the CLI, NETCONF, and RESTCONF (`yang/`)
* **Files** — a rootfs overlay for service settings, a CLI snippet, and
a factory default (`board/common/rootfs/`)
* **Tests** — a regression test for the daemon, run in QEMU by Infix's
test harness (`test/`)

When a new Infix release comes out, you bump the submodule and rebuild.
No fork, no drift, no merge conflicts. Clone it, rename a few things,
and you have your own OS.

## Get Started

Install the build dependencies listed in the [Infix Developer's
Guide][building], for the Infix release this spin is based on, then
clone the repository along with its submodules:

```bash
git clone --recurse-submodules https://github.com/kernelkit/infix-spin-example.git
cd infix-spin-example
```

Configure and build. The first build takes a while, since it compiles
the cross toolchain and the Linux kernel too:

```bash
make x86_64_defconfig
make
```

Then boot your new OS in QEMU:

```bash
sudo apt install qemu-system-x86
make run
```

Log in as `admin` with password `admin`, and have a look at the example
daemon, which greets you in the system log once a minute:

```
example-os:~$ initctl status hello-world
example-os:~$ sudo grep hello-world /var/log/syslog
```

That's it. Have a look around and make it your own.

## A Model of Your Own

The daemon is configured the way everything in Infix is: with YANG. The
model lives in `yang/example-hello@2026-08-26.yang` and is a container
with two leaves — `audience`, which you set, and `greetings`, which the
daemon reports. It is loaded into sysrepo when the image is built, see
`package/hello-world/hello-world.mk`.

That is the entire integration. There is no CLI code for it, because
configure mode is generated from whatever models sysrepo has loaded:

```
example-os:~$ configure
example-os:/config/> set hello audience there
example-os:/config/> leave
example-os:~$ show hello
audience: there
greetings: 4
```

Tab completion, help texts, validation, `show`, `diff`, saving to
startup-config — all of it follows from the model. The same data is
reachable over NETCONF and RESTCONF, again for free.

Only the last command needed writing: `show` in operational mode is an
ordinary CLI command, so the spin drops a klish snippet into
`board/common/rootfs/etc/klish/hello.xml` to add it.

A `default` in the model is not the same as a starting configuration.
The daemon reads `world` whether or not anything is set, but a leaf that
still holds its default is left out when the datastore is serialized, so
`show hello` prints nothing for it. To make it visible, ship it as real
configuration: Infix collates the factory config from
`/usr/share/confd/factory.d/`, so the overlay drops a `50-hello.json`
there, and symlinks it into `test.d/` beside it, which is where the
image boots from under the test harness. Anything a spin wants in a
factory fresh configuration goes the same way.

On the daemon side, two sysrepo subscriptions are all that is needed —
one to hear about `audience` changing, one to answer for `greetings`.
See `src/hello-world/hello-world.c`, it is about thirty lines of it.

## Test

Infix comes with a test harness that boots the image in QEMU and pokes
at it over SSH and NETCONF. A spin can reuse it for its own tests; this
one has a single case that checks the `hello-world` daemon, see
`test/case/hello-world/`:

```bash
make test
```

Docker or Podman is required, the harness runs inside a container that
brings its own QEMU and Python tooling. Tests describe the *logical*
topology they need (`topology.dot`) and the harness maps it onto the
virtual network it starts, so nothing here depends on real hardware.

The result of the last run can be rendered as a PDF, branded with
`doc/logo.png` (swap it for your own, and see `LOGO` in the Makefile if
you want it placed differently):

```bash
gem install asciidoctor-pdf
make test-report # -> output/images/test-report.pdf
```

The same commands run in CI, on a stock GitHub runner, see
`.github/workflows/build.yml`. Every run uploads the report; a bootable
image is a few hundred MB, more than a free account's artifact storage
will take on every push, so that one is built on demand with the "Run
workflow" button. Push a `v*` tag and both are attached to a GitHub
release instead, where they are kept indefinitely.

## Learn More

* [Infix documentation](https://kernelkit.org/)
* [RFC 7950](https://www.rfc-editor.org/rfc/rfc7950), the YANG 1.1
language
* [Buildroot br2-external manual](https://buildroot.org/downloads/manual/manual.html#outside-br-custom)
* [finit](https://github.com/troglobit/finit)

[building]: https://www.kernelkit.org/infix/26.06/developers-guide/#building
7 changes: 7 additions & 0 deletions board/common/rootfs/etc/finit.d/available/hello-world.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Hello World example daemon. Runs in runlevels 2-5. The conditions
# delay start until syslogd is up, since the daemon logs there, and until
# confd has populated sysrepo, since that is where it reads its
# configuration from. Finit supervises it and restarts it if it crashes.
# It is enabled by the symlink in ../enabled/ -- on a running system you
# can also use `initctl enable hello-world`.
service [2345] <pid/syslogd,pid/confd> /usr/sbin/hello-world -- Hello World example daemon
1 change: 1 addition & 0 deletions board/common/rootfs/etc/finit.d/enabled/hello-world.conf
34 changes: 34 additions & 0 deletions board/common/rootfs/etc/klish/hello.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Adds 'show hello' to the operational mode CLI.

klish reads every XML file in /etc/klish and merges them, so
re-declaring VIEW main and the show command here extends Infix's CLI
rather than replacing it. Infix does the same thing internally, see
infix/src/klish-plugin-infix/xml/containers.xml.

Note what is *not* here: nothing for 'set hello audience ...'.
Configure mode is generated from the YANG models sysrepo has loaded,
so our model turns up there, with help texts and tab completion, the
moment it is installed.
-->
<KLISH
xmlns="https://klish.libcode.org/klish3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://src.libcode.org/pkun/klish/src/master/klish.xsd">

<VIEW name="main">

<COMMAND name="show" help="Show system status and configuration files" mode="switch">

<COMMAND name="hello" help="Show hello-world daemon status">
<ACTION sym="script">
copy operational -x /example-hello:hello | jq -r '.["example-hello:hello"]
| "audience: \(.audience)", "greetings: \(.greetings)"'
</ACTION>
</COMMAND>

</COMMAND>

</VIEW>
</KLISH>
5 changes: 5 additions & 0 deletions board/common/rootfs/usr/share/confd/factory.d/50-hello.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"example-hello:hello": {
"audience": "world"
}
}
1 change: 1 addition & 0 deletions board/common/rootfs/usr/share/confd/test.d/50-hello.json
Loading
Loading