Axis / Build System / Recipe / Lua declaration adapter / Worked Examples
Worked Examples
Purpose
This informative document illustrates Lua registration forms. A declaration is a named table; a handler is a function granted explicit configuration or execution capabilities. Each example states its own source and tool assumptions. Several examples describe proposed extensions, not implemented behavior or verified hardware support.
Code blocks are independent modules unless an example explicitly includes another file. Paths such as ./hello refer to source supplied by that example project. Axis-supplied declarations shown as implementation examples are not redeclared in a user’s project.
Ordinary Software
A local Go command requires no userspace composition, common userspace contract, kernel, or boot image. This example assumes the Go source convention’s single-executable contract, with options.entry naming the main package and an output named executable.
target {
name = "linux-amd64",
machine = { arch = "amd64", os = "linux", abi = "musl" },
environment = "axis:linux-build",
toolchains = { go = "axis:go" },
}
package {
name = "hello",
source = "./hello",
builder = "go",
options = { entry = ".", cgo = false },
install = function(ctx, options)
ctx:copy(ctx:artifact("executable"), "/usr/bin/hello", {
mode = "0755",
})
end,
}
axis build hello --target linux-amd64
axis install hello --target linux-amd64 --install-dir ./root
The build output is an executable. Installation constructs a tree containing /usr/bin/hello, then applies it under ./root. The destination directory does not enter the executable’s build configuration.
Cargo, Go, and Zig
These declarations assume the named adapters produce one executable each. System libraries and code generators are explicit dependencies.
package {
name = "rust-service",
source = "./rust-service",
builder = "cargo",
requires = { "openssl" },
toolchains = { rust = "axis:rust", c = "axis:clang-musl" },
options = { binary = "service", features = { "tls" } },
}
package {
name = "go-service",
source = "./go-service",
builder = "go",
requires = { "sqlite" },
toolchains = { go = "axis:go", c = "axis:clang-musl" },
options = { entry = "./cmd/service", cgo = true },
}
package {
name = "zig-service",
source = "./zig-service",
builder = "zig",
toolchains = { zig = "axis:zig" },
options = { binary = "service", optimize = "ReleaseSafe" },
}
Cargo handles its crate graph, including native build scripts and procedural macros. Go handles its module graph. Zig handles its build graph. Axis prepares each tool’s declared inputs and contexts, then records its resulting immutable outputs. An OpenSSL dependency built by Axis must be explicitly connected through the Cargo adapter’s external-library convention; merely listing it does not rewrite arbitrary build.rs code.
A new language follows the same pattern: register its tool contract and source convention, then select that source convention by name. Cross-compilation still requires the language’s actual compiler to support the requested destination.
A Generator and a Destination Library
package {
name = "protocol-generator",
source = "./tools/protocol-generator",
builder = "go",
toolchains = { go = "axis:go" },
options = { entry = ".", cgo = false },
}
package {
name = "viewer",
source = "./viewer",
tools = { "protocol-generator" },
toolchains = { c = "axis:clang-musl" },
requires = { "libpng" },
inputs = {
generator = input("protocol-generator", "executable"),
png = input("libpng", "sdk"),
},
build = function(ctx, options)
local output = ctx:output("executable", "viewer")
local c = ctx:toolchain("c")
ctx:exec {
args = { "/bin/sh", ctx:path(ctx.source, "build.sh") },
env = {
GENERATOR = ctx:input("generator"),
PNG_SDK = ctx:input("png"),
CC = c.cc,
OUTPUT = output,
},
}
end,
}
This example’s source includes a build script that reads these four environment variables. Its shell is a declared part of the immutable execution image; the other paths are resolved inputs and outputs. On a Linux aarch64 execution machine building for amd64, the generator runs as aarch64, while libpng and viewer are built for amd64. Both dependency contexts are visible in the resolved graph.
A source-built compiler
This provider assumes three ordinary declarations: LLVM tools built for execution, a musl SDK built for the destination, and its installable runtime. Their output names form their explicit package contracts.
toolchain {
name = "clang-musl",
kind = "c",
tools = { "llvm-tools" },
requires = { "musl-sdk" },
runtime = { "musl-runtime" },
bind = function(ctx)
return {
schema = 1,
family = "clang",
cc = ctx:artifact("llvm-tools", "clang"),
cxx = ctx:artifact("llvm-tools", "clang++"),
ar = ctx:artifact("llvm-tools", "llvm-ar"),
sysroot = ctx:artifact("musl-sdk", "sdk"),
execution = ctx.execution,
destination = ctx.machine,
}
end,
}
The standard C/Clang adapter validates this provider descriptor and materializes commands with the destination, SDK, and dependency-search policy bound into them. Consumers receive that normalized descriptor through ctx:toolchain("c"); they do not reconstruct the cross flags individually.
The bootstrap chain is explicit: the environment compiler builds LLVM; a freestanding LLVM binding builds the destination SDK; LLVM plus that SDK supplies the final userspace tool contract. SDK headers must come from independent declared header inputs, not depend cyclically on a finished destination kernel that itself requires the tool contract.
Desktop and Rescue
This project supplies package declarations in packages.lua and target declarations in targets.lua. Those declarations may instead be written in the same file. The supplied baseline named axis:base-os provides the common userspace files.
include("packages.lua")
include("targets.lua")
os {
name = "desktop",
requires = { "shell", "desktop-services" },
targets = {
"pc-amd64", "qemu-amd64", "qemu-aarch64", "j314",
},
configure = function(ctx, requests)
if #requests ~= 0 then
ctx:fail("desktop accepts no incoming configuration")
end
ctx:add_config("linux", { CONFIG_NET = "y" })
return {}
end,
}
os {
name = "rescue",
requires = { "shell", "recovery-tools" },
targets = { "pc-amd64", "qemu-amd64", "qemu-aarch64" },
configure = function(ctx, requests)
if #requests ~= 0 then
ctx:fail("rescue accepts no incoming configuration")
end
ctx:add_config("linux", { CONFIG_LOCALVERSION = "-rescue" })
return {}
end,
}
Each userspace composition implicitly requires axis:base-os. Each receives a separate configuration scope. Their selected Linux declarations may therefore have different configurations without a global mutable kernel. The shared packages may reuse identical results after their effective inputs are compared.
The selected destination declaration declares the linux dependency, making it addressable in the userspace composition’s combined scope. If a destination declaration does not select Linux and the userspace composition emits this contribution, resolution fails rather than silently introducing a kernel.
How axisd Requests Kernel Functionality
This is an illustrative implementation of the embedded axis:base-os.axisd declaration. The actual set of required kernel options belongs to that declaration and must track its code.
package {
name = "axis:base-os.axisd",
version = "0.1.0",
source = "./axisd",
builder = "zig",
toolchains = { zig = "axis:zig" },
requires = { require("linux >=6.8") },
options = { binary = "axisd", optimize = "ReleaseSafe" },
configure = function(ctx, requests)
if #requests ~= 0 then
ctx:fail("axisd accepts no incoming configuration")
end
ctx:add_config("linux", {
CONFIG_SIGNALFD = "y",
CONFIG_PROC_FS = "y",
CONFIG_SYSFS = "y",
})
return ctx.options
end,
install = function(ctx, options)
ctx:copy(ctx:artifact("executable"), "/sbin/axisd", {
mode = "0755",
})
end,
}
The dependency is declared before configuration. The declaration emits its requirements exactly once. Its compilation does not read a compiled kernel output, so changing the destination declaration’s kernel image alone does not invalidate the axisd executable or force it into the OS image.
The receiving configuration handler
This is the scalar merge portion of a Linux source convention’s configuration handler. It is called once with every request. The actual source convention additionally defines the interpretation of strings and its baseline Kconfig policy.
local function configure_linux(ctx, requests)
local symbols = {}
local origins = {}
for _, request in ipairs(requests) do
for key, value in pairs(request.value) do
if not key:match("^CONFIG_[%w_]+$") then
ctx:fail("unknown Linux configuration key: " .. key)
end
if type(value) == "boolean" then
value = value and "y" or "n"
elseif type(value) == "number" then
if value ~= math.floor(value) then
ctx:fail("Kconfig requires an integer for " .. key)
end
elseif type(value) ~= "string" then
ctx:fail("unsupported Kconfig value for " .. key)
end
if symbols[key] ~= nil and symbols[key] ~= value then
ctx:fail(string.format(
"%s: %s from %s:%d conflicts with %s from %s:%d",
key, tostring(symbols[key]),
origins[key].file, origins[key].line,
tostring(value), request.origin.file, request.origin.line
))
end
symbols[key] = value
origins[key] = request.origin
end
end
return { symbols = symbols }
end
Only normalized symbols are returned as build options. Source locations stay in diagnostics. The build handler subsequently runs Kconfig and checks the effective configuration; merging dictionaries alone is not proof that the kernel supports the requested result.
A U-Boot, OpenSSL, or future-language declaration can interpret a different dictionary vocabulary through the same handler contract. No generic capability namespace or mutable shared configuration object is involved.
A QEMU destination
The shipped aarch64-qemu declaration uses the implemented launch-command contract. The fragment below shows that part of an otherwise complete declaration with a configured linux dependency exporting kernel and config files. The caller binds the selected userspace image to os. QEMU must already be installed on the controlling host.
run = function(ctx)
local image = ctx:input("os"):gsub(",", ",,")
return {
args = {
"qemu-system-aarch64", "-machine", "virt", "-accel", "tcg",
"-cpu", "cortex-a72", "-m", "512", "-nographic",
"-kernel", ctx:artifact("linux", "kernel"),
"-drive", "if=none,file=" .. image .. ",format=raw,readonly=on,id=os",
"-device", "virtio-blk-device,drive=os",
"-append", "console=ttyAMA0 root=/dev/vda rootfstype=erofs ro rootwait init=/sbin/axisd",
},
}
end
TCG is explicit; no native acceleration is silently selected. The handler only describes the command. The caller prepares successful inputs, supplies terminal streams and keeps private files alive through execution. This example does not claim boot acceptance.
A machine-specific disk image
This project owns an image-tool Go program and a pc-boot declaration supplied by pc-boot.lua. The tool’s explicit contract accepts an unchanged EROFS file, kernel, boot tree, and output path; creates the PC’s partition and boot layout; and verifies the stored OS bytes. The construction engine does not prescribe its partition implementation.
include("pc-boot.lua")
package {
name = "image-tool",
source = "./tools/image-tool",
builder = "go",
toolchains = { go = "axis:go" },
options = { entry = ".", cgo = false },
}
target {
name = "pc-amd64",
machine = { arch = "amd64", os = "linux", abi = "musl" },
environment = "axis:linux-build",
toolchains = { c = "axis:clang-musl", zig = "axis:zig" },
requires = { "linux", "pc-boot" },
tools = { "image-tool" },
inputs = { "os" },
default_output = "disk",
packer = function(ctx, options)
local disk = ctx:output("disk", "system.img")
ctx:exec {
args = {
ctx:artifact("image-tool", "executable"),
"--os", ctx:input("os"),
"--kernel", ctx:artifact("linux", "kernel"),
"--boot-tree", ctx:artifact("pc-boot", "support"),
"--output", disk,
},
}
end,
}
axis build desktop --target pc-amd64
axis build desktop --target pc-amd64 --artifact os
The first command produces the default disk distribution. The second requests only the userspace composition’s os output. A change to pc-boot or the image tool rebuilds the disk while reusing an unchanged compatible os.
The same packing handler pattern accommodates blobs, device trees, scripts, multiple filesystems, and recovery OS inputs. They must be declared and bound explicitly. The image tool is a regular execution-context dependency, so it can run as aarch64 while constructing an amd64 disk.
J314 and Asahi Components
A project may provide Asahi declarations in one module. This example assumes that module declares linux with the Asahi kernel source URL, asahi-m1n1, asahi-u-boot, and j314-image-tool. Their versions and source revisions are coordinated and locked. The image tool has the same responsibility for exact OS preservation as the PC tool, plus the documented J314 layout.
include("asahi.lua")
target {
name = "j314",
machine = { arch = "aarch64", os = "linux", abi = "musl" },
environment = "axis:linux-build",
toolchains = { c = "axis:clang-musl", zig = "axis:zig" },
requires = { "linux", "asahi-m1n1", "asahi-u-boot" },
tools = { "j314-image-tool" },
inputs = { "os" },
default_output = "disk",
packer = function(ctx, options)
local disk = ctx:output("disk", "j314.img")
ctx:exec {
args = {
ctx:artifact("j314-image-tool", "executable"),
"--os", ctx:input("os"),
"--kernel", ctx:artifact("linux", "kernel"),
"--dtbs", ctx:artifact("linux", "dtbs"),
"--m1n1", ctx:artifact("asahi-m1n1", "stage2"),
"--u-boot", ctx:artifact("asahi-u-boot", "bootloader"),
"--output", disk,
},
}
end,
}
Linux requirements from axisd and the userspace composition resolve directly to the declared linux software declaration, whose source is the Asahi fork. Neither has to know its repository path or import its declaration as a Lua variable. Configuring U-Boot follows exactly the same rule: declare it as a dependency, then send a dictionary interpreted by its declaration.
This demonstrates graph and packaging semantics. It does not specify a new Asahi installer or claim that a raw image alone can prepare an unprovisioned Mac. Device-specific firmware must be supplied as a declared input or obtained during a separate installation step. Asahi’s boot preparation and coordinated component requirements remain applicable.
Future acceptance scenarios
These scenarios describe acceptance work for the proposed extensions. They are not reports of completed implementation or testing:
- Build and install an ordinary executable without selecting an operating-system baseline.
- Build an amd64 product from Linux aarch64 using an aarch64 generator and an amd64 C library; verify both contexts.
- Compose an OS containing
/sbin/axisd, validate EROFS, and boot it on the aarch64 and amd64 QEMU profiles. - Build two userspace compositions with different Linux requests; verify isolation and useful conflict provenance within one userspace composition.
- Change only boot assets; reuse the same OS bytes and rebuild only affected destination results.
- Build a machine-specific disk containing the unchanged OS image and verify the image’s boot layout separately.
- Exercise cancellation, missing output recovery, and cache isolation under concurrent consumers.
- Validate J314 provisioning assumptions and actual hardware boot as a separate integration milestone.