feat: system-agnostic package manager — 5 inits, 2 bootloaders, parallel scheduler

Complete rewrite of kappa from a sequential build tool into a
system-agnostic package manager with runtime init switching.

Core additions:
- 5 init system backends: systemd, openrc, s6, runit, dinit
  (service file generation, enable/disable, init_paths)
- 2 bootloader backends: grub, limine (config generation, fallback entries)
- Parallel scheduler with worker pool, depth-based priority (Beta/Alpha/Zeta),
  atomic claiming, dependency tracking, deduplication, and failure propagation
- Init-switch impact analysis: only rebuild packages using ${enabledinit}
- Init-agnostic service definitions: flat NamedService blocks replace
  per-init nesting
- Package conflicts: mutual incompatibility detection in resolver
- System groups: init-agnostic group creation in DSL
- Init-agnostic hostname/timezone: direct /etc/hostname and /etc/localtime writes
- Source tarball caching at /kappa/cache/ with atomic write-then-rename
- Package recipe caching with remote fetching and version comparison
- remotes = [...] block in system config for package repositories
- Auto-fetch: rebuild resolves missing packages from remotes
- uninstall phase in package definitions
- ${enabledinit} eval variable for init-conditional builds
- Shared util module (to_lower, shell_escape)
- 54 integration tests across two shell test suites
- Comprehensive README and CONTRIBUTING guide

Bug fixes from review:
- CRITICAL: Replace std::system() with fork+execvp (command injection)
- CRITICAL: Fix scheduler deadlock on successful completion
- CRITICAL: Fix rebuild init/kernel/bootloader change detection
- HIGH: Fix path traversal via unsanitized package names in cache
- HIGH: Fix TOCTOU race in cache write with atomic rename
- HIGH: Fix formatter dropping remotes/imports blocks
- HIGH: Fix formatter stripping empty-string assert values
- HIGH: Fix formatter non-idempotent output (sorted key iteration)
- HIGH: Populate ${enabledinit} from boot.init in BuildStep
- MEDIUM: Fix data race on non-atomic scheduler stop flag
- MEDIUM: Fix compute_depths() traversal direction
- MEDIUM: Add runit to doctor supported-init warning
- MEDIUM: Extract to_lower/shell_escape to shared kappa::util
- MEDIUM: Consolidate generator declarations in headers
This commit is contained in:
2026-07-31 05:32:57 -04:00
parent d6612d0a4a
commit dd984f96d4
40 changed files with 3047 additions and 117 deletions
+14
View File
@@ -21,6 +21,7 @@ endif()
add_executable(kappa
src/main.cpp
src/util.cpp
src/paths.cpp
src/dsl/lexer.cpp
src/dsl/parser.cpp
@@ -33,9 +34,22 @@ add_executable(kappa
src/tools/doctor.cpp
src/resolve/plan.cpp
src/fetch/fetch.cpp
src/fetch/recipe.cpp
src/build/build.cpp
src/sched/scheduler.cpp
src/install/install.cpp
src/rebuild/rebuild.cpp
src/service/s6.cpp
src/service/systemd.cpp
src/service/types.cpp
src/service/openrc.cpp
src/service/dinit.cpp
src/service/runit.cpp
src/service/install.cpp
src/boot/types.cpp
src/boot/limine.cpp
src/boot/grub.cpp
src/boot/install.cpp
src/system/activate.cpp
)
target_include_directories(kappa PRIVATE include)
+137
View File
@@ -0,0 +1,137 @@
# Contributing to kappa
We're building a package manager that doesn't care about your init system,
your bootloader, or your life choices. If that sounds like your kind of
project, keep reading.
## The rules
These aren't guidelines. They're the deal.
### 1. C++23 or don't bother
We compile with Clang, `-std=c++23`, and `-Werror`. If your code needs a
polyfill for `std::format` or can't handle designated initializers, it
doesn't belong here. The standard library is your only dependency. Zero
external C++ libraries. Not even Boost.
```cpp
// ✓ yes
auto msg = std::format("building {} (depth={})", name, depth);
// ✗ no
auto msg = fmt::format("building {} (depth={})", name, depth);
```
### 2. Every new module mirrors the existing structure
```
include/kappa/{module}/
├── types.hpp # enums, structs, parse/validate declarations
├── {feature}.hpp # public interface
src/{module}/
├── types.cpp # implementations
├── backend_a.cpp # per-variant generators
├── backend_b.cpp
└── install.cpp # dispatch + orchestration
```
If you're adding a feature, look at `src/service/` or `src/boot/` for the
pattern. If your new module doesn't look like those, you're doing it wrong.
### 3. Never suppress type safety
There is no `as any`, no `@ts-ignore`, no `reinterpret_cast` abuse, and
no `void*` unless you're talking to the kernel. If the type system is
fighting you, you're fighting the design. Fix the design.
### 4. Use the namespace. All of it.
```cpp
namespace kappa::module {
// everything goes here
} // namespace kappa::module
```
No `using namespace std;` at file scope. No anonymous namespaces for
functions that are used across files — extract to `util.hpp` instead.
### 5. Thread safety is not optional
The scheduler is multithreaded. If you touch shared state, you own the
lock. `std::mutex`, `std::atomic`, `std::condition_variable` — use them
correctly or don't use them at all. If you don't know what `memory_order`
means, stay out of the scheduler.
### 6. Tests are shell scripts. For now.
Integration tests live in `test.sh` and `test-init-switch.sh`. They
exercise the CLI. If you add a subcommand, add a test. C++ unit tests
are welcome — set up Google Test or Catch2 in CMake and we'll merge it.
```sh
./test.sh # 31 tests, must pass
./test-init-switch.sh # 23 tests, must pass
```
### 7. Backward compatibility is mandatory
The `.kap` DSL is the contract. You can add keywords. You cannot remove
them. You can extend syntax. You cannot break existing configs. If your
change means someone's `config.kap` stops parsing, it doesn't ship.
## How to contribute
### Pick something
Good first issues:
- Adding a 6th init system backend
- Adding a 3rd bootloader backend
- C++ unit test framework setup
- Shell completion scripts
Ambitious issues:
- Binary package support (pre-built caches)
- Remote build farm (distcc-style)
- Signed package verification
- Filesystem overlay activation (like Nix profiles)
### Send a PR
1. Fork the repo
2. Create a branch: `feat/my-thing` or `fix/my-bug`
3. Write code that follows the rules above
4. Run `./test.sh && ./test-init-switch.sh` — both must pass
5. Open a PR against `main`
### PR requirements
- Build must pass: `cmake --build build` with zero warnings
- Tests must pass: both shell test suites
- No commented-out code. No dead code. No TODO without a date.
- Commit messages in imperative: `Add runit backend` not `Added runit backend`
## What we won't merge
- **`systemd`-only features.** If it can't work on at least two init systems,
it goes in a `systemd` package definition, not in kappa.
- **Dependency on a specific distro.** Kappa runs on any Linux kernel. No
hardcoded paths to `/usr/lib/systemd`, no assumptions about `/etc/os-release`.
- **Abstract nonsense.** FactoryFactoryBuilder patterns. Premature
generalization. If you need three layers of indirection to add a feature,
the feature is too complicated.
- **AI slop.** If it looks like ChatGPT wrote it, it gets rejected. We can
tell. Write code like a human who's been doing this for a decade.
## Communication
We don't have a Discord. We don't have a forum. Open an issue. Write a
clear title, a reproduction case, and what you expected. We'll respond
when we respond.
If you want to propose a major feature, open an issue first. Surprise PRs
that rewrite half the codebase get closed without review.
---
Kappa is 0.1.0. Everything is subject to change except the rules above.
+28
View File
@@ -0,0 +1,28 @@
FROM alpine:edge AS builder
RUN apk add --no-cache \
clang cmake make ninja \
git linux-headers \
samurai
WORKDIR /build
COPY . .
RUN cmake -B build -G Ninja \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_BUILD_TYPE=Release
RUN cmake --build build
FROM alpine:edge
RUN apk add --no-cache libstdc++
COPY --from=builder /build/build/kappa /usr/local/bin/kappa
COPY --from=builder /build/examples /opt/kappa/examples
WORKDIR /opt/kappa
ENTRYPOINT ["kappa"]
CMD ["--help"]
+152 -1
View File
@@ -1,3 +1,154 @@
<p align="center">
<img src="https://git.spectoria.dev/repo-avatars/f111d66d4a9972c13eb8aa4dddc7fe2c39aabdf0ab0ff0dc27a31a9aa1c2e968" width="180" alt="kappa mascot" />
</p>
# kappa
A alternative implementation of Iota for the ZereneOS project.
**Anywhere, any init, anytime.**
A declarative, source-based package manager that doesn't care what init system
you run. Or what bootloader. Or what CPU architecture. Kappa builds your entire
system from source — and lets you swap the init system like you'd swap a
wallpaper.
---
### Why
Every other package manager picked a side. `apt` married systemd. `pacman`
shackled itself to Arch's ecosystem. `emerge` gave you choice but at the cost
of your weekend. Nix gave you reproducibility but took your filesystem with it.
Kappa is what you get when you stop negotiating. You declare what your system
*is*, and kappa figures out how to build it. Change your mind about the init
system? Rebuild only the packages that care — the other 800 stay put.
### What it does
```
# Your system, in one file:
boot {
kernel = "linux"
init = "s6" # swap to "systemd" anytime
bootloader = "limine" # or "grub"
root = "/dev/sda1"
}
packages {
nginx { version = ">=1.24" }
postgresql {}
zlib {}
}
services {
nginx { enable = true }
}
```
Then:
```sh
kappa rebuild config.kap # builds everything, generates service files
kappa rebuild config.kap # boot.init = "openrc" — only 5 packages actually rebuild
```
### Features that nobody else has
- **Init-system-as-configuration.** `boot.init = "s6"` → generates s6 service
directories. Change it to `"systemd"` → regenerates `.service` units. Change
it to `"openrc"` → generates init.d scripts. The package definitions don't
know or care which init you picked. That's kappa's problem.
- **Post-install init switching.** Change `boot.init`, run `kappa rebuild`, reboot.
You're now on a different init system. Only packages that actually use
`${enabledinit}` in their build scripts need recompiling. Everything else
just gets new service files generated.
- **Bootloader rollback.** Every rebuild creates a fallback boot entry pointing
at the previous generation's init. If the new one doesn't boot, the old one
is one reboot away.
- **Parallel scheduler.** `-w 4 -j 8` means four packages building
simultaneously, eight jobs each. The scheduler uses depth-based priority
grouping so leaf dependencies unblock as much work as possible first.
- **Package recipe caching.** Declare `remotes = ["https://repo.example.com/"]`
in your config. Kappa fetches `.kap` files on demand, caches them, and only
re-fetches when the remote version is newer.
- **Source tarball caching.** Downloaded once, stored at `/kappa/cache/`.
Rebuilds don't touch the network unless versions change.
- **Conflicts.** `systemd` declares `conflicts = ["eudev", "elogind"]`. The
resolver catches mutual incompatibility before a build starts.
- **Init-agnostic system config.** `groups { wheel { gid = 998 } }` — kappa
creates the groups. `system { hostname = "mybox" }` — kappa writes
`/etc/hostname`. No `systemctl`, no `rc-update`, no init dependency.
### 5 init systems. 2 bootloaders. Zero lock-in.
| Init | Service location | Enable command |
|------|-----------------|----------------|
| systemd | `/etc/systemd/system/{name}.service` | `systemctl enable` |
| openrc | `/etc/init.d/{name}` | `rc-update add` |
| s6 | `/etc/s6/sv/{name}/run` | `s6-rc-bundle-update` |
| runit | `/etc/sv/{name}/run` | `ln -sf /etc/sv/{name} /var/service/` |
| dinit | `/etc/dinit.d/{name}` | `dinitctl enable` |
| Bootloader | Config path |
|-----------|------------|
| limine | `/boot/limine.cfg` |
| grub | `/boot/grub/grub.cfg` |
### Quick start
```sh
# Build kappa (needs Clang 17+, CMake 3.20+, C++23)
cmake -B build -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
cmake --build build
# Write a config
cat > system.kap << 'EOF'
system { hostname = "kappa.local" }
packages { nginx {} }
services { nginx { enable = true } }
boot {
kernel = "linux"; init = "s6"; root = "/dev/sda1"; bootloader = "limine"
}
users { root { shell = "/bin/zsh" } }
remotes = ["https://packages.kappa-os.org/stable/"]
EOF
# Parse it
build/kappa parse-config system.kap
# Rebuild
build/kappa rebuild system.kap
```
### Subcommands
| Command | What it does |
|---------|-------------|
| `parse-package <file>` | Validate a `.kap` package definition |
| `parse-config <file>` | Validate a system configuration |
| `validate <file>` | Validate any kappa file |
| `format <file>` | Pretty-print to canonical style |
| `doctor <file>` | Check for issues and warnings |
| `resolve <config>` | Compute a build plan |
| `fetch <package>` | Download and verify source tarballs |
| `fetch-package <name>` | Fetch a package recipe from remotes |
| `build <package>` | Build a single package |
| `rebuild <config>` | Diff config against installed state, rebuild changed |
| `list` | Show installed packages |
| `rollback` | Show available generations |
### License
BSD 2-Clause. Do whatever you want. Just don't sue us.
### Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). We're opinionated but we merge good
code.
+49
View File
@@ -1,10 +1,55 @@
/*
* Kappa system configuration.
* Lives at /kappa/system/config.kap
*
* INIT SYSTEM SELECTION
* =====================
* The `boot.init` field (line ~84) selects which init system manages this
* machine. Valid values (case-insensitive):
*
* systemd — system and service manager
* openrc — OpenRC dependency-based init
* s6 — s6 supervision suite
* dinit — dinit service manager / init system
*
* The `boot.bootloader` field (line ~122) selects which bootloader config
* kappa generates (grub or limine).
*
* This setting determines:
* 1. Which backend generates service files at install time
* (systemd → .service units, openrc → init.d scripts, etc.)
* 2. What `${enabledinit}` resolves to during package builds
*
* Package definitions do NOT specify per-init service blocks. A package
* defines its service once (see examples/foo.kap) and the selected init
* system's backend handles the translation.
*
* SERVICES BLOCK
* ==============
* The `services` section enables or disables services declared by
* installed packages. Each entry maps to a package's service name:
*
* services {
* nginx { enable = true } // package "nginx", default "main" service
* postgresql.main { enable = true } // package "postgresql", named service "main"
* postgresql.checkpointer { enable = false }
* }
*
* For single-service packages, the service name defaults to "main" and
* can be omitted. For multi-service packages, use dot-notation
* (pkgname.servicename) to target a specific named service.
*
* Additional keys in each service block (port, ssl, etc.) are passed as
* custom config to the service definition.
*/
imports = []
remotes = [
"https://packages.kappa-os.org/stable/",
"https://packages.kappa-os.org/contrib/",
]
assert {
"efi partition required for UEFI boot" : boot.efi != ""
"root partition must be set" : boot.root != ""
@@ -75,11 +120,15 @@ services {
}
}
// Select init system — determines which backend generates service files.
// Valid: systemd, openrc, s6, dinit (case-insensitive).
boot {
kernel = "linux"
init = "s6"
efi = "/dev/sda2"
swap = "/dev/sda3"
// Bootloader — generates the appropriate config at install time.
// Valid: grub, limine (case-insensitive).
bootloader = "limine"
root = "/dev/sda1"
}
+64 -13
View File
@@ -1,6 +1,51 @@
/*
* foo — a web server with optional SSL and GUI support.
* Demonstrates the full kappa DSL surface.
*
* SERVICE MODEL
* =============
* Kappa service definitions are init-system-agnostic. The `service` block
* describes what the service IS (exec, type, ports, user) — NOT how each init
* system runs it. The system config's `boot.init` field (see config.kap)
* determines which init system's service files get generated at install time:
*
* boot.init = "systemd" → generates .service unit files
* boot.init = "openrc" → generates /etc/init.d scripts
* boot.init = "s6" → generates s6 service directories
* boot.init = "dinit" → generates dinit service descriptors
* boot.init = "runit" → generates runit service directories
*
* Per-init blocks (service { systemd { ... } s6 { ... } }) do NOT exist.
* If a package genuinely needs init-specific behaviour (e.g. different
* ./configure flags for systemd vs. openrc), use ${enabledinit} in the
* build phase — see examples/postgres.kap for that pattern.
*
* SERVICE TYPE VALUES
* ===================
* These are semantic, not init-specific. Each backend translates them
* into its own vocabulary:
*
* "simple" — foreground process; init manages lifecycle directly.
* systemd: Type=simple openrc: command_background=false
* s6: type=longrun dinit: type=process
*
* "forking" — process daemonises itself; init tracks the forked PID.
* systemd: Type=forking openrc: command_background=true
* s6: type=longrun dinit: type=bgprocess
*
* "notify" — foreground process that signals readiness (sd_notify).
* systemd: Type=notify openrc: command_background=true
* s6: type=longrun dinit: type=process
*
* "oneshot" — runs once and exits (startup tasks, database migrations).
* systemd: Type=oneshot openrc: command_background=false
* s6: type=oneshot dinit: type=scripted
*
* "longrun" — long-running supervised process (s6/runit idiom).
* systemd: Type=simple openrc: command_background=true
* s6: type=longrun dinit: type=process
*
* The backend generators handle all translation. Package authors only
* need to pick the semantic type that describes their daemon's behaviour.
*/
package "foo" {
const version = "1.2.3"
@@ -8,7 +53,8 @@ package "foo" {
sha256 = "e127a709cba24c76de8936cb7083dd768f28cd37eb010492e2f19b71eb1294e4"
license = "MIT"
provides = ["libfoo.so.1", "foo"]
provides = ["libfoo.so.1", "foo"]
conflicts = [] // packages this cannot coexist with (e.g. ["eudev"] if this were systemd)
patches = [
{
@@ -60,18 +106,19 @@ package "foo" {
CFLAGS ?= "-g"
}
// --- service ----------------------------------------------------------
// Init-agnostic service definition. The `type` field is semantic
// ("forking") — the selected init system's backend translates it into
// the appropriate native format. If the package ships multiple
// services, use named blocks (see examples/postgres.kap).
service {
runit {
exec = "/usr/bin/foo --daemon"
type = "forking"
user = "foo"
}
s6 {
exec = "/usr/bin/foo"
type = "longrun"
ports = [80, 443]
user = "foo"
}
exec = "/usr/bin/foo"
type = "forking" // daemonises itself
user = "foo"
ports = [80, 443]
description = "Foo web server"
after = "network" // ordering hint — systemd After=, OpenRC need, etc.
restart = "on-failure" // "always" | "on-failure" | "never"
}
prepare {
@@ -90,4 +137,8 @@ package "foo" {
install {
make DESTDIR=${destdir} install
}
uninstall {
make -C build uninstall
}
}
+148
View File
@@ -0,0 +1,148 @@
/*
* postgresql — a database server shipping multiple services.
*
* MULTI-SERVICE PACKAGES
* ======================
* Packages that install more than one long-running process can declare
* multiple named `service` blocks. Each has its own exec, type, ports,
* and lifecycle config. The system config enables them individually using
* dot-notation (see config.kap):
*
* services {
* postgresql.main { enable = true }
* postgresql.checkpointer { enable = true }
* postgresql.walwriter { enable = true }
* }
*
* Omitting the dot selects the service named "main".
*
*
* INIT-CONDITIONAL BUILDS
* =======================
* The variable ${enabledinit} exposes the configured init system name
* (from boot.init in config.kap) during the build phase. Use shell
* conditionals — no DSL if/else needed:
*
* build {
* case ${enabledinit} in
* systemd) ./configure --with-systemd --prefix=${prefix} ;;
* openrc) ./configure --with-openrc --prefix=${prefix} ;;
* s6|dinit) ./configure --prefix=${prefix} ;;
* esac
* make -j${jobs}
* }
*
* ${enabledinit} is interpolated to the literal init system name
* ("systemd", "openrc", "s6", or "dinit") before the shell executes the
* block. No DSL context-sensitive parsing required.
*
*
* SERVICE TYPE TRANSLATION (for reference)
* ========================================
* semantic │ systemd │ openrc │ s6 │ dinit
* ──────────┼────────────┼─────────────────────┼──────────┼──────────
* simple │ Type=simple│ bg=false │ longrun │ process
* forking │ Type=fork │ bg=true │ longrun │ bgprocess
* notify │ Type=notify│ bg=true │ longrun │ process
* oneshot │ Type=one │ bg=false, args="" │ oneshot │ scripted
* longrun │ Type=simple│ bg=true │ longrun │ process
*/
package "postgresql" {
const version = "16.3"
const source = "https://ftp.postgresql.org/pub/source/v${version}/postgresql-${version}.tar.gz"
sha256 = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
license = "PostgreSQL"
depends = [
{ name = "readline", version = ">=8" },
{ name = "zlib", version = ">=1.2" },
{ name = "openssl" },
]
conflicts = [] // mutually exclusive packages (e.g. systemd vs eudev)
features {
ssl = { enabled = true, flag = "--with-ssl=openssl" }
nls = { enabled = true, flag = "--enable-nls" }
systemd = { enabled = false, flag = "--with-systemd" }
}
config {
file "etc/postgresql/data/postgresql.conf" mode = "default" {
port = ${cfg.port ? 5432}
max_connections = ${cfg.max_conn ? 100}
shared_buffers = ${cfg.shared_buf ? 128MB}
}
}
env {
CFLAGS = "-O2"
LDFLAGS = "-Wl,--as-needed"
}
// --- services ---------------------------------------------------------
// PostgreSQL ships the main server plus several auxiliary processes.
// Each runs as a separate service under the init system.
// Default service (name = "main"). Enabled via: postgresql { enable = true }
service main {
exec = "/usr/bin/postgres -D /var/lib/postgresql/data"
type = "forking" // postmaster daemonises itself
user = "postgres"
ports = [5432]
description = "PostgreSQL database server"
after = "network"
restart = "always"
working_dir = "/var/lib/postgresql"
}
// Background writer — handles checkpoint I/O.
service checkpointer {
exec = "/usr/bin/postgres-checkpointer"
type = "longrun"
user = "postgres"
description = "PostgreSQL checkpointer process"
restart = "always"
}
// WAL writer — flushes write-ahead log to disk.
service walwriter {
exec = "/usr/bin/postgres-walwriter"
type = "longrun"
user = "postgres"
restart = "always"
}
// --- build phases ------------------------------------------------------
prepare {
tar xf postgresql-${version}.tar.gz
}
// init-conditional build: PostgreSQL optionally links against systemd
// for socket activation and service notification. Use ${enabledinit}
// to decide configure flags without per-init service blocks.
build {
case ${enabledinit} in
systemd) ./configure --with-systemd --with-ssl=openssl --prefix=${prefix} ;;
*) ./configure --with-ssl=openssl --prefix=${prefix} ;;
esac
make -j${jobs} world
}
check {
make check
}
install {
make DESTDIR=${destdir} install-world
}
uninstall {
make DESTDIR=${destdir} uninstall-world
}
assert {
"data directory must exist" : system.config.data_dir != ""
}
}
+9 -2
View File
@@ -44,12 +44,17 @@ struct EnvEntry {
bool soft = false;
};
struct ServiceInit {
struct NamedService {
std::string name = "main";
std::string exec;
std::string type;
std::string user;
std::vector<int> ports;
std::unordered_map<std::string, std::string> env;
std::string description;
std::string after;
std::string restart;
std::string working_dir;
};
struct Assertion {
@@ -70,17 +75,19 @@ struct PackageDef {
std::vector<Dependency> depends;
std::vector<std::string> provides;
std::vector<std::string> outputs;
std::vector<std::string> conflicts;
std::unordered_map<std::string, FeatureDef> features;
std::vector<ConfigFile> config_files;
std::vector<Patch> patches;
std::vector<EnvEntry> env_entries;
std::unordered_map<std::string, ServiceInit> service;
std::vector<NamedService> services;
std::vector<Assertion> assertions;
std::unordered_set<std::string> const_keys;
Phase prepare;
Phase build;
Phase check;
Phase install;
Phase uninstall;
};
} // namespace kappa::dsl
+7
View File
@@ -39,6 +39,11 @@ struct BootBlock {
std::unordered_map<std::string, std::string> params;
};
struct GroupDef {
std::string name;
int gid = -1; // -1 = auto-assign
};
struct UserRef {
std::string name;
std::string shell;
@@ -54,11 +59,13 @@ struct ServiceRef {
struct SystemConfig {
std::vector<std::string> imports;
std::vector<std::string> remotes;
SystemBlock system;
std::vector<PackageRef> packages;
std::vector<ServiceRef> services;
BootBlock boot;
std::vector<UserRef> users;
std::vector<GroupDef> groups;
std::vector<Assertion> assertions;
};
+2
View File
@@ -28,6 +28,7 @@ enum class TokenType {
KwDepends,
KwProvides,
KwOutputs,
KwConflicts,
KwFeatures,
KwConfig,
KwConst,
@@ -44,6 +45,7 @@ enum class TokenType {
KwBuild,
KwCheck,
KwInstall,
KwUninstall,
KwTrue,
KwFalse,
};
+2
View File
@@ -14,6 +14,8 @@ std::filesystem::path temp_dir();
std::filesystem::path db_dir();
std::filesystem::path system_dir();
std::filesystem::path builds_dir();
std::filesystem::path cache_dir();
std::filesystem::path packages_dir();
void ensure_directories();
+48
View File
@@ -0,0 +1,48 @@
#include "kappa/boot/bootloader.hpp"
#include <format>
#include <sstream>
namespace kappa::boot {
std::string generate_grub_config(const BootSpec& spec) {
std::ostringstream oss;
oss << "# Generated by kappa — do not edit manually\n";
oss << "# GRUB boot entry\n";
oss << "\n";
oss << "set timeout=5\n";
oss << "set default=0\n";
oss << "\n";
oss << "menuentry \"Kappa\" {\n";
oss << std::format(" linux {} init={} root={}",
spec.kernel_path,
spec.init_path,
spec.root);
if (!spec.kernel_params.empty()) {
oss << " " << spec.kernel_params;
}
oss << "\n";
oss << "}\n";
if (!spec.init_prev.empty()) {
oss << "\n";
oss << "menuentry \"Kappa (fallback)\" {\n";
oss << std::format(" linux {} init={} root={}",
spec.kernel_path,
spec.init_prev,
spec.root);
if (!spec.kernel_params.empty()) {
oss << " " << spec.kernel_params;
}
oss << "\n";
oss << "}\n";
}
return oss.str();
}
} // namespace kappa::boot
+69
View File
@@ -0,0 +1,69 @@
#include "kappa/boot/bootloader.hpp"
#include <filesystem>
#include <format>
#include <fstream>
#include <iostream>
namespace kappa::boot {
// ---------------------------------------------------------------------------
// Backend config-file generators (defined in separate .cpp files)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// generate_bootloader_config — dispatch to the correct backend
// ---------------------------------------------------------------------------
std::string generate_bootloader_config(Bootloader bl, const BootSpec& spec) {
switch (bl) {
case Bootloader::Grub:
return generate_grub_config(spec);
case Bootloader::Limine:
return generate_limine_config(spec);
case Bootloader::Unknown:
default:
return {};
}
}
// ---------------------------------------------------------------------------
// install_bootloader_config — write the generated config file to disk
// ---------------------------------------------------------------------------
BootloaderInstallResult install_bootloader_config(Bootloader bl,
const BootSpec& spec,
std::string_view prefix) {
namespace fs = std::filesystem;
auto paths = bootloader_paths(bl, prefix);
if (paths.config_path.empty()) {
return {false, {}, "Unknown bootloader"};
}
std::string content = generate_bootloader_config(bl, spec);
if (content.empty()) {
return {false, {}, "Failed to generate bootloader config"};
}
std::error_code ec;
fs::path file_path = paths.config_path;
fs::create_directories(file_path.parent_path(), ec);
if (ec) {
return {false, {}, ec.message()};
}
{
std::ofstream out(file_path);
if (!out) {
return {false, {},
std::format("Failed to write {}", file_path.string())};
}
out << content;
}
return {true, file_path.string(), {}};
}
} // namespace kappa::boot
+42
View File
@@ -0,0 +1,42 @@
#include "kappa/boot/bootloader.hpp"
#include <format>
#include <sstream>
#include <string>
namespace kappa::boot {
std::string generate_limine_config(const BootSpec& spec) {
std::ostringstream out;
out << "# Generated by kappa — do not edit manually\n"
<< "# Limine boot entry\n"
<< "\n"
<< ":Kappa\n"
<< " protocol: linux\n"
<< std::format(" kernel_path: boot():{}\n", spec.kernel_path);
out << std::format(" kernel_cmdline: init={} root={}",
spec.init_path, spec.root);
if (!spec.kernel_params.empty()) {
out << " " << spec.kernel_params;
}
out << "\n";
if (!spec.init_prev.empty()) {
out << "\n"
<< ":Kappa (fallback)\n"
<< " protocol: linux\n"
<< std::format(" kernel_path: boot():{}\n", spec.kernel_path);
out << std::format(" kernel_cmdline: init={} root={}",
spec.init_prev, spec.root);
if (!spec.kernel_params.empty()) {
out << " " << spec.kernel_params;
}
out << "\n";
}
return out.str();
}
} // namespace kappa::boot
+65
View File
@@ -0,0 +1,65 @@
#include "kappa/boot/types.hpp"
#include "kappa/util.hpp"
#include <algorithm>
#include <cctype>
#include <format>
#include <ranges>
namespace kappa::boot {
Bootloader parse_bootloader(std::string_view name) {
auto lower = util::to_lower(name);
if (lower == "grub") return Bootloader::Grub;
if (lower == "limine") return Bootloader::Limine;
return Bootloader::Unknown;
}
std::string_view to_string(Bootloader bl) {
switch (bl) {
case Bootloader::Grub: return "grub";
case Bootloader::Limine: return "limine";
case Bootloader::Unknown: return "unknown";
}
return "unknown";
}
bool is_supported(std::string_view name) {
return parse_bootloader(name) != Bootloader::Unknown;
}
std::vector<Bootloader> all_bootloaders() {
return {Bootloader::Grub, Bootloader::Limine};
}
std::string_view bootloader_description(Bootloader bl) {
switch (bl) {
case Bootloader::Grub:
return "GRUB — GRand Unified Bootloader";
case Bootloader::Limine:
return "Limine — modern multiprotocol bootloader";
case Bootloader::Unknown:
return "unknown bootloader";
}
return "unknown bootloader";
}
BootloaderPaths bootloader_paths(Bootloader bl, std::string_view prefix) {
switch (bl) {
case Bootloader::Grub:
return {
.config_path = std::format("{}boot/grub/grub.cfg", prefix),
.install_cmd = "grub-install",
};
case Bootloader::Limine:
return {
.config_path = std::format("{}boot/limine/limine.cfg", prefix),
.install_cmd = "limine",
};
case Bootloader::Unknown:
return {};
}
return {};
}
} // namespace kappa::boot
+27 -9
View File
@@ -1,4 +1,7 @@
#include "kappa/config/eval.hpp"
#include "kappa/service/types.hpp"
#include <iostream>
namespace kappa::config {
@@ -55,26 +58,41 @@ std::vector<AssertFailure> evaluate_assertions(const dsl::SystemConfig& cfg) {
return failures;
}
std::unordered_map<std::string, dsl::ServiceInit> resolve_services(
std::unordered_map<std::string, dsl::NamedService> resolve_services(
const dsl::SystemConfig& cfg,
const std::unordered_map<std::string, dsl::PackageDef>& packages)
{
std::unordered_map<std::string, dsl::ServiceInit> resolved;
std::unordered_map<std::string, dsl::NamedService> resolved;
auto init_system = cfg.boot.init;
auto is = kappa::service::parse_init_system(init_system);
if (is == kappa::service::InitSystem::Unknown) {
std::cerr << "warning: unknown init system '" << init_system
<< "' — no services will be configured\n";
return {};
}
for (auto& svc : cfg.services) {
if (!svc.enable) { continue; }
auto pit = packages.find(svc.name);
// svc.name can be "postgresql" or "postgresql.checkpointer"
auto dot = svc.name.find('.');
auto pkg_name = (dot != std::string::npos)
? svc.name.substr(0, dot)
: svc.name;
auto svc_name = (dot != std::string::npos)
? svc.name.substr(dot + 1)
: std::string("main");
auto pit = packages.find(std::string(pkg_name));
if (pit == packages.end()) { continue; }
auto& pkg = pit->second;
if (pkg.service.empty()) { continue; }
auto it = pkg.service.find(init_system);
if (it != pkg.service.end()) {
resolved[svc.name] = it->second;
for (auto& ns : pkg.services) {
if (ns.name == svc_name) {
resolved[svc.name] = ns;
break;
}
}
}
+6 -2
View File
@@ -12,8 +12,9 @@ static const std::unordered_map<std::string_view, TokenType> keywords = {
{"source", TokenType::KwSource},
{"depends", TokenType::KwDepends},
{"provides", TokenType::KwProvides},
{"outputs", TokenType::KwOutputs},
{"features", TokenType::KwFeatures},
{"outputs", TokenType::KwOutputs},
{"conflicts", TokenType::KwConflicts},
{"features", TokenType::KwFeatures},
{"config", TokenType::KwConfig},
{"const", TokenType::KwConst},
{"license", TokenType::KwLicense},
@@ -29,6 +30,7 @@ static const std::unordered_map<std::string_view, TokenType> keywords = {
{"build", TokenType::KwBuild},
{"check", TokenType::KwCheck},
{"install", TokenType::KwInstall},
{"uninstall", TokenType::KwUninstall},
{"true", TokenType::KwTrue},
{"false", TokenType::KwFalse},
};
@@ -51,6 +53,7 @@ std::string_view token_name(TokenType type) {
case TokenType::KwDepends: return "depends";
case TokenType::KwProvides: return "provides";
case TokenType::KwOutputs: return "outputs";
case TokenType::KwConflicts: return "conflicts";
case TokenType::KwFeatures: return "features";
case TokenType::KwConfig: return "config";
case TokenType::KwConst: return "const";
@@ -67,6 +70,7 @@ std::string_view token_name(TokenType type) {
case TokenType::KwBuild: return "build";
case TokenType::KwCheck: return "check";
case TokenType::KwInstall: return "install";
case TokenType::KwUninstall: return "uninstall";
case TokenType::KwTrue: return "true";
case TokenType::KwFalse: return "false";
}
+49 -34
View File
@@ -157,6 +157,12 @@ void Parser::parse_body(PackageDef& pkg) {
pkg.outputs = parse_string_list();
break;
case TokenType::KwConflicts:
consume(TokenType::KwConflicts);
consume(TokenType::Equals);
pkg.conflicts = parse_string_list();
break;
case TokenType::KwDepends:
consume(TokenType::KwDepends);
consume(TokenType::Equals);
@@ -233,52 +239,56 @@ void Parser::parse_body(PackageDef& pkg) {
consume(TokenType::Rbrace);
break;
case TokenType::KwService:
case TokenType::KwService: {
consume(TokenType::KwService);
NamedService ns;
if (!at(TokenType::Lbrace)) {
ns.name = current_.lexeme;
advance();
}
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto init_name = current_.lexeme;
auto key = current_.lexeme;
advance();
consume(TokenType::Lbrace);
skip_newlines();
ServiceInit si;
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto key = current_.lexeme;
advance();
consume(TokenType::Equals);
if (key == "exec") {
si.exec = consume(TokenType::String).lexeme;
} else if (key == "type") {
si.type = consume(TokenType::String).lexeme;
} else if (key == "user") {
si.user = consume(TokenType::String).lexeme;
} else if (key == "ports") {
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
si.ports.push_back(
parse_int(current_.line, current_.col,
current_.lexeme));
advance();
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else {
si.env[key] = consume(TokenType::String).lexeme;
}
consume(TokenType::Equals);
if (key == "exec") {
ns.exec = consume(TokenType::String).lexeme;
} else if (key == "type") {
ns.type = consume(TokenType::String).lexeme;
} else if (key == "user") {
ns.user = consume(TokenType::String).lexeme;
} else if (key == "ports") {
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
ns.ports.push_back(
parse_int(current_.line, current_.col,
current_.lexeme));
advance();
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (key == "description") {
ns.description = consume(TokenType::String).lexeme;
} else if (key == "after") {
ns.after = consume(TokenType::String).lexeme;
} else if (key == "restart") {
ns.restart = consume(TokenType::String).lexeme;
} else if (key == "working_dir") {
ns.working_dir = consume(TokenType::String).lexeme;
} else {
ns.env[key] = consume(TokenType::String).lexeme;
}
consume(TokenType::Rbrace);
pkg.service[std::string(init_name)] = std::move(si);
skip_newlines();
}
consume(TokenType::Rbrace);
pkg.services.push_back(std::move(ns));
break;
}
case TokenType::KwAssert:
consume(TokenType::KwAssert);
@@ -340,6 +350,11 @@ void Parser::parse_body(PackageDef& pkg) {
pkg.install = parse_phase();
break;
case TokenType::KwUninstall:
consume(TokenType::KwUninstall);
pkg.uninstall = parse_phase();
break;
default:
throw ParseError(current_.line, current_.col,
std::format("unexpected token '{}' in package body",
+43
View File
@@ -28,6 +28,7 @@ private:
void parse_services_block(SystemConfig& cfg);
void parse_boot_block(SystemConfig& cfg);
void parse_users_block(SystemConfig& cfg);
void parse_groups_block(SystemConfig& cfg);
std::string consume_ident();
std::string consume_string();
@@ -130,6 +131,17 @@ SystemConfig SysParser::parse() {
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (kw == "remotes") {
consume(TokenType::Equals);
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
cfg.remotes.push_back(consume(TokenType::String).lexeme);
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (kw == "assert") {
consume(TokenType::Lbrace);
skip_newlines();
@@ -171,6 +183,7 @@ SystemConfig SysParser::parse() {
else if (kw == "services") { parse_services_block(cfg); }
else if (kw == "boot") { parse_boot_block(cfg); }
else if (kw == "users") { parse_users_block(cfg); }
else if (kw == "groups") { parse_groups_block(cfg); }
else {
throw ParseError(current_.line, current_.col,
std::format("unknown section '{}'", kw));
@@ -411,6 +424,34 @@ void SysParser::parse_users_block(SystemConfig& cfg) {
consume(TokenType::Rbrace);
}
void SysParser::parse_groups_block(SystemConfig& cfg) {
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
skip_newlines();
if (at(TokenType::Rbrace)) break;
GroupDef g;
g.name = consume_ident();
if (at(TokenType::Lbrace)) {
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto key = consume_ident();
consume(TokenType::Equals);
if (key == "gid") {
g.gid = parse_int(current_.line, current_.col, current_.lexeme);
advance();
}
skip_newlines();
}
consume(TokenType::Rbrace);
}
cfg.groups.push_back(std::move(g));
}
consume(TokenType::Rbrace);
}
SystemConfig parse_system_config(std::string_view source) {
SysParser p(source);
return p.parse();
@@ -420,12 +461,14 @@ static void merge_config(SystemConfig& base, SystemConfig&& imported) {
if (!imported.system.hostname.empty()) { base.system.hostname = std::move(imported.system.hostname); }
if (!imported.system.timezone.empty()) { base.system.timezone = std::move(imported.system.timezone); }
for (auto& e : imported.system.env) { base.system.env.push_back(std::move(e)); }
for (auto& r : imported.remotes) { base.remotes.push_back(std::move(r)); }
for (auto& [k, v] : imported.system.config) { base.system.config[k] = std::move(v); }
for (auto& [k, v] : imported.system.features) { base.system.features[k] = std::move(v); }
if (imported.system.rollback.keep > 0) { base.system.rollback.keep = imported.system.rollback.keep; }
for (auto& p : imported.packages) { base.packages.push_back(std::move(p)); }
for (auto& s : imported.services) { base.services.push_back(std::move(s)); }
for (auto& u : imported.users) { base.users.push_back(std::move(u)); }
for (auto& g : imported.groups) { base.groups.push_back(std::move(g)); }
if (!imported.boot.kernel.empty()) { base.boot.kernel = std::move(imported.boot.kernel); }
if (!imported.boot.init.empty()) { base.boot.init = std::move(imported.boot.init); }
if (!imported.boot.efi.empty()) { base.boot.efi = std::move(imported.boot.efi); }
+2 -1
View File
@@ -11,7 +11,8 @@ Scope make_default_scope() {
s.builtins["jobs"] = "1";
s.builtins["jobopts"] = "-j1";
s.builtins["destdir"] = (paths::temp_dir() / "destdir").string();
s.builtins["userargs"] = "";
s.builtins["userargs"] = "";
s.builtins["enabledinit"] = "";
return s;
}
+28 -5
View File
@@ -126,16 +126,37 @@ FetchResult fetch(const dsl::PackageDef& pkg) {
}
auto dest_name = pkg.name + "-" + pkg.version;
auto dest_file = fs::path(paths::temp_dir()) / (dest_name + "." + ext);
// Sanitize: replace path separators to prevent traversal
for (auto& c : dest_name) {
if (c == '/' || c == '\\') c = '_';
}
auto cache_path = paths::cache_dir() / (dest_name + "." + ext);
bool from_cache = false;
fs::path dest_file;
if (fs::exists(cache_path)) {
dest_file = cache_path;
from_cache = true;
} else {
dest_file = fs::path(paths::temp_dir()) / (dest_name + "." + ext);
}
result.work_dir = fs::path(paths::temp_dir()) / dest_name;
if (ext == "git") {
int rc = exec_cmd({"git", "clone", url, result.work_dir.string()});
if (rc != 0) { result.error = "git clone failed"; return result; }
} else {
fs::create_directories(paths::temp_dir());
int rc = exec_cmd({"curl", "-L", "-o", dest_file.string(), url});
if (rc != 0) { result.error = "download failed"; return result; }
if (!from_cache) {
fs::create_directories(paths::temp_dir());
int rc = exec_cmd({"curl", "-L", "-o", dest_file.string(), url});
if (rc != 0) { result.error = "download failed"; return result; }
std::error_code ec;
// Atomic cache write: write to .tmp then rename
auto cache_tmp = fs::path(cache_path.string() + ".tmp");
fs::copy(dest_file, cache_tmp, ec);
if (!ec) {
fs::rename(cache_tmp, cache_path, ec);
}
}
bool verified = false;
for (auto algo : {"sha512", "sha256", "md5"}) {
@@ -170,7 +191,9 @@ FetchResult fetch(const dsl::PackageDef& pkg) {
if (rc2 != 0) { result.error = "extraction failed"; return result; }
}
fs::remove(dest_file);
if (!from_cache) {
fs::remove(dest_file);
}
}
for (auto& patch : pkg.patches) {
+128
View File
@@ -0,0 +1,128 @@
#include "kappa/fetch/recipe.hpp"
#include "kappa/dsl/parser.hpp"
#include "kappa/dsl/system.hpp"
#include "kappa/paths.hpp"
#include <sys/wait.h>
#include <unistd.h>
#include <filesystem>
#include <format>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
namespace kappa::fetch {
namespace {
int exec_curl(const std::string& output_path, const std::string& url) {
pid_t pid = fork();
if (pid == 0) {
execlp("curl", "curl", "-Lsf", "-o", output_path.c_str(), url.c_str(), nullptr);
_exit(127);
}
if (pid < 0) return -1;
int status = 0;
while (waitpid(pid, &status, 0) == -1 && errno == EINTR) {}
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
} // namespace
RecipeResult fetch_recipe(const std::string& name,
const std::vector<std::string>& remotes) {
RecipeResult result;
// Check cached version first
auto cache_path = paths::packages_dir() / (name + ".kap");
std::string cached_version;
if (std::filesystem::exists(cache_path)) {
std::ifstream in(cache_path);
if (in) {
std::ostringstream buf;
buf << in.rdbuf();
try {
auto pkg = dsl::parse(buf.str());
cached_version = pkg.version;
} catch (...) {
// Corrupt cache — will re-download
}
}
}
// Try each remote
std::string best_content;
std::string best_version;
std::string best_url;
for (auto& remote : remotes) {
auto url = remote;
if (!url.empty() && url.back() != '/') url += '/';
url += name + ".kap";
// Download to temp
auto temp_path = paths::temp_dir() / (name + ".kap.tmp");
int rc = exec_curl(temp_path.string(), url);
if (rc != 0) continue;
// Parse downloaded file
std::ifstream in(temp_path);
if (!in) { std::filesystem::remove(temp_path); continue; }
std::ostringstream buf;
buf << in.rdbuf();
in.close();
std::string remote_version;
try {
auto pkg = dsl::parse(buf.str());
remote_version = pkg.version;
} catch (...) {
std::filesystem::remove(temp_path);
continue;
}
// Compare versions — keep the best (highest)
// Simple string comparison for now; semver later
if (remote_version > best_version) {
best_version = remote_version;
best_content = buf.str();
best_url = url;
}
std::filesystem::remove(temp_path);
}
if (best_content.empty()) {
if (!cached_version.empty()) {
// No remote available but have cache
result.ok = true;
result.path = cache_path.string();
result.version = cached_version;
result.updated = false;
return result;
}
result.error = "package '" + name + "' not found in any remote";
return result;
}
// Update cache if remote is newer
if (best_version > cached_version || cached_version.empty()) {
std::error_code ec;
std::filesystem::create_directories(paths::packages_dir(), ec);
std::ofstream out(cache_path);
if (!out) {
result.error = "cannot write to cache";
return result;
}
out << best_content;
result.updated = true;
}
result.ok = true;
result.path = cache_path.string();
result.version = best_version;
return result;
}
} // namespace kappa::fetch
+156 -10
View File
@@ -4,10 +4,13 @@
#include "kappa/dsl/parser.hpp"
#include "kappa/dsl/system.hpp"
#include "kappa/fetch/fetch.hpp"
#include "kappa/fetch/recipe.hpp"
#include "kappa/install/install.hpp"
#include "kappa/paths.hpp"
#include "kappa/rebuild/rebuild.hpp"
#include "kappa/resolve/plan.hpp"
#include "kappa/boot/types.hpp"
#include "kappa/service/types.hpp"
#include "kappa/tools/doctor.hpp"
#include "kappa/tools/format.hpp"
@@ -37,6 +40,7 @@ Subcommands:
doctor <file> Check a .kap file for issues and warnings
resolve <config> Resolve a build plan from a system config
fetch <package> Download and verify source for a package
fetch-package <name> Fetch a package recipe from configured remotes
build <package> Build a package from its source directory
rebuild <config> Compare config to installed state, rebuild changed
list List installed packages
@@ -87,6 +91,60 @@ static void handle_parse_error(const char* path,
std::cerr << "error: " << e.what() << '\n';
}
static resolve::Registry build_registry(const dsl::SystemConfig& cfg) {
resolve::Registry registry;
for (const auto& pref : cfg.packages) {
bool found = false;
// Check standard locations: local .kap, examples/, cache/packages/
std::vector<std::string> search_paths = {
std::string(pref.name) + ".kap",
std::string("examples/") + pref.name + ".kap",
(paths::packages_dir() / (pref.name + ".kap")).string(),
};
for (const auto& sp : search_paths) {
std::ifstream in(sp);
if (!in) continue;
std::ostringstream buf;
buf << in.rdbuf();
try {
auto pkg = dsl::parse(buf.str());
registry[pkg.name] = std::move(pkg);
found = true;
break;
} catch (...) {
continue;
}
}
// If not found locally, try remotes
if (!found && !cfg.remotes.empty()) {
auto result = fetch::fetch_recipe(pref.name, cfg.remotes);
if (result.ok && !result.path.empty()) {
std::ifstream in(result.path);
if (in) {
std::ostringstream buf;
buf << in.rdbuf();
try {
auto pkg = dsl::parse(buf.str());
registry[pkg.name] = std::move(pkg);
found = true;
} catch (...) {}
}
}
}
if (!found) {
std::cerr << "warning: package '" << pref.name
<< "' not found locally or in remotes\n";
}
}
return registry;
}
int main(int argc, char* argv[]) {
paths::ensure_directories();
@@ -117,7 +175,8 @@ int main(int argc, char* argv[]) {
|| (subcommand == "build")
|| (subcommand == "rebuild")
|| (subcommand == "list")
|| (subcommand == "rollback");
|| (subcommand == "fetch-package")
|| (subcommand == "rollback");
if (!valid_subcommand) {
std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n";
@@ -218,6 +277,26 @@ int main(int argc, char* argv[]) {
<< cfg.packages.size() << " packages, "
<< cfg.services.size() << " services, "
<< cfg.users.size() << " users)\n";
if (!cfg.boot.init.empty()) {
auto is = kappa::service::parse_init_system(cfg.boot.init);
std::cout << " init: " << cfg.boot.init;
if (is != kappa::service::InitSystem::Unknown) {
std::cout << " (" << kappa::service::init_description(is) << ")";
} else {
std::cout << " (unrecognized)";
}
std::cout << "\n";
}
if (!cfg.boot.bootloader.empty()) {
auto bl = kappa::boot::parse_bootloader(cfg.boot.bootloader);
std::cout << " bootloader: " << cfg.boot.bootloader;
if (bl != kappa::boot::Bootloader::Unknown) {
std::cout << " (" << kappa::boot::bootloader_description(bl) << ")";
} else {
std::cout << " (unrecognized)";
}
std::cout << "\n";
}
return 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
@@ -358,13 +437,7 @@ int main(int argc, char* argv[]) {
try {
auto cfg = dsl::parse_system_config(source);
resolve::Registry registry;
auto* pkg_arg = (argc > 3) ? argv[3] : nullptr;
if (pkg_arg != nullptr) {
auto pkg_src = read_file(pkg_arg);
auto pkg = dsl::parse(pkg_src);
registry[pkg.name] = std::move(pkg);
}
auto registry = build_registry(cfg);
auto plan = resolve::resolve(cfg, registry);
@@ -420,6 +493,41 @@ int main(int argc, char* argv[]) {
}
}
if (subcommand == "fetch-package") {
if (file_arg == nullptr) {
std::cerr << "error: no package name specified\n";
return 1;
}
try {
std::vector<std::string> remotes;
auto config_path = std::filesystem::path(paths::system_dir()) / "config.kap";
if (std::filesystem::exists(config_path)) {
auto cfg_src = read_file(config_path.c_str());
try {
auto cfg = dsl::parse_system_config(cfg_src);
remotes = cfg.remotes;
} catch (...) {
}
}
auto result = fetch::fetch_recipe(file_arg, remotes);
if (result.ok) {
if (result.updated) {
std::cout << "fetched " << file_arg << " " << result.version
<< " → " << result.path << "\n";
} else {
std::cout << file_arg << " " << result.version
<< " (cached, up to date)\n";
}
return 0;
}
std::cerr << "fetch failed: " << result.error << "\n";
return 1;
} catch (const std::exception& e) {
std::cerr << "fetch error: " << e.what() << "\n";
return 1;
}
}
if (subcommand == "rebuild") {
try {
auto cfg = dsl::parse_system_config(source);
@@ -442,8 +550,46 @@ int main(int argc, char* argv[]) {
std::cout << " building " << name << " (new)\n";
}
if (cs.kernel_changed) { std::cout << " kernel changed\n"; }
if (cs.init_changed) { std::cout << " init changed\n"; }
if (cs.bootloader_changed) { std::cout << " bootloader changed\n"; }
if (cs.init_changed) {
auto is_name = cfg.boot.init;
auto is = kappa::service::parse_init_system(is_name);
if (is != kappa::service::InitSystem::Unknown) {
std::cout << " init system: " << is_name << " ("
<< kappa::service::init_description(is) << ")\n";
} else {
std::cout << " init changed (" << is_name
<< " — unrecognized)\n";
}
auto registry = build_registry(cfg);
auto impact = rebuild::compute_init_impact(cfg, registry);
if (!impact.service_rebuild.empty()) {
std::cout << " full rebuild (uses ${enabledinit}): "
<< impact.service_rebuild.size()
<< " packages\n";
}
if (!impact.service_only.empty()) {
std::cout << " service files only: "
<< impact.service_only.size()
<< " packages\n";
}
if (!impact.skipped.empty()) {
std::cout << " no services — skipped: "
<< impact.skipped.size() << " packages\n";
}
}
if (cs.bootloader_changed) {
auto bl_name = cfg.boot.bootloader;
auto bl = kappa::boot::parse_bootloader(bl_name);
if (bl != kappa::boot::Bootloader::Unknown) {
std::cout << " bootloader: " << bl_name << " ("
<< kappa::boot::bootloader_description(bl) << ")\n";
} else {
std::cout << " bootloader changed (" << bl_name
<< " — unrecognized)\n";
}
}
if (cs.services_changed) { std::cout << " services changed\n"; }
return 0;
+4
View File
@@ -20,6 +20,8 @@ std::filesystem::path temp_dir() { return g_root / "temp"; }
std::filesystem::path db_dir() { return g_root / "db"; }
std::filesystem::path system_dir() { return g_root / "system"; }
std::filesystem::path builds_dir() { return g_root / "system" / "builds"; }
std::filesystem::path cache_dir() { return g_root / "cache"; }
std::filesystem::path packages_dir() { return cache_dir() / "packages"; }
void ensure_directories() {
std::error_code ec;
@@ -27,6 +29,8 @@ void ensure_directories() {
std::filesystem::create_directories(temp_dir(), ec);
std::filesystem::create_directories(db_dir(), ec);
std::filesystem::create_directories(builds_dir(), ec);
std::filesystem::create_directories(cache_dir(), ec);
std::filesystem::create_directories(packages_dir(), ec);
}
} // namespace kappa::paths
+62 -3
View File
@@ -1,6 +1,8 @@
#include "kappa/rebuild/rebuild.hpp"
#include "kappa/install/install.hpp"
#include "kappa/service/types.hpp"
#include <iostream>
#include <set>
namespace kappa::rebuild {
@@ -43,13 +45,17 @@ ChangeSet compute_changes(const dsl::SystemConfig& cfg) {
}
for (auto& e : installed) {
if (e.name == "kernel" && e.hash != cfg.boot.kernel) {
// Compare the installed version (which stores the identifier string
// for virtual packages like init/kernel/bootloader) against the
// config value. These virtual entries have their identity in the
// version field, not the hash field.
if (e.name == "kernel" && e.version != cfg.boot.kernel) {
cs.kernel_changed = true;
}
if (e.name == "init" && e.hash != cfg.boot.init) {
if (e.name == "init" && e.version != cfg.boot.init) {
cs.init_changed = true;
}
if (e.name == "bootloader" && e.hash != cfg.boot.bootloader) {
if (e.name == "bootloader" && e.version != cfg.boot.bootloader) {
cs.bootloader_changed = true;
}
}
@@ -61,4 +67,57 @@ ChangeSet compute_changes(const dsl::SystemConfig& cfg) {
return cs;
}
InitImpact compute_init_impact(const dsl::SystemConfig& cfg,
const resolve::Registry& registry) {
InitImpact impact;
auto init_system = cfg.boot.init;
// Only compute impact if an init system is actually configured
if (init_system.empty()) { return impact; }
auto is = kappa::service::parse_init_system(init_system);
if (is == kappa::service::InitSystem::Unknown) {
std::cerr << "warning: unknown init system '" << init_system
<< "' — cannot compute init impact\n";
return impact;
}
// For each package in the config that has a matching registry entry...
for (auto& pref : cfg.packages) {
auto rit = registry.find(pref.name);
if (rit == registry.end()) { continue; }
auto& pkg = rit->second;
// No services — nothing to do
if (pkg.services.empty()) {
impact.skipped.push_back(pkg.name);
continue;
}
// Check if build scripts reference ${enabledinit}
bool uses_enabledinit = false;
auto check_phase = [&](const dsl::Phase& phase) {
for (auto& cmd : phase.commands) {
if (cmd.find("${enabledinit}") != std::string::npos) {
uses_enabledinit = true;
return;
}
}
};
check_phase(pkg.prepare);
if (!uses_enabledinit) check_phase(pkg.build);
if (!uses_enabledinit) check_phase(pkg.check);
if (!uses_enabledinit) check_phase(pkg.install);
if (uses_enabledinit) {
impact.service_rebuild.push_back(pkg.name);
} else {
impact.service_only.push_back(pkg.name);
}
}
return impact;
}
} // namespace kappa::rebuild
+20 -3
View File
@@ -1,7 +1,9 @@
#include "kappa/resolve/plan.hpp"
#include "kappa/config/merge.hpp"
#include <format>
#include <queue>
#include <unordered_set>
namespace kappa::resolve {
@@ -21,9 +23,10 @@ BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) {
auto resolved = config::resolve_package(pkg, cfg.system, pref);
BuildStep step;
step.name = pkg.name;
step.package = &pkg;
step.resolved = std::move(resolved);
step.name = pkg.name;
step.package = &pkg;
step.resolved = std::move(resolved);
step.enabled_init = cfg.boot.init;
for (auto& dep : pkg.depends) {
if (!dep.feature.empty()) {
@@ -39,6 +42,20 @@ BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) {
nodes.push_back(std::move(step));
}
// Detect conflicts between selected packages
std::unordered_set<std::string> selected;
for (auto& node : nodes) { selected.insert(node.name); }
for (auto& node : nodes) {
if (!node.package) continue;
for (auto& conflict_name : node.package->conflicts) {
if (selected.contains(conflict_name)) {
plan.conflicts.push_back(
std::format("{} conflicts with {}", node.name, conflict_name));
}
}
}
std::vector<int> in_degree(nodes.size(), 0);
std::vector<std::vector<std::size_t>> adj(nodes.size());
+274 -11
View File
@@ -1,27 +1,290 @@
#include "kappa/sched/scheduler.hpp"
#include "kappa/build/build.hpp"
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <format>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace kappa::sched {
// ---------------------------------------------------------------------------
// Internal node tracked per package during scheduling
// ---------------------------------------------------------------------------
struct Node {
const resolve::BuildStep* step = nullptr;
int pending_deps = 0; // dependencies not yet built
std::vector<std::size_t> dependents; // packages waiting on this one
int depth = 0; // distance from deepest leaf
bool claimed = false;
build::BuildResult result;
};
// ---------------------------------------------------------------------------
// Priority queue keyed by depth group (Beta/Alpha/Zeta cycle)
// Deeper packages (higher depth) get priority so they unblock more work.
// ---------------------------------------------------------------------------
struct ReadyOrder {
bool operator()(const Node* a, const Node* b) const {
return a->depth < b->depth; // max-heap by depth
}
};
// ---------------------------------------------------------------------------
// Three-level depth grouping: Beta → Alpha → Zeta → Beta → ...
// Returns a priority value: Beta = 2, Alpha = 1, Zeta = 0.
// Higher value = build sooner.
// ---------------------------------------------------------------------------
static int level_priority(int depth) {
int level = depth % 3;
// Beta=0, Alpha=1, Zeta=2
// Beta should go first (priority 2), Alpha second (1), Zeta last (0)
return (3 - level) % 3;
}
// ---------------------------------------------------------------------------
// Scheduler state shared between workers
// ---------------------------------------------------------------------------
struct Scheduler {
std::vector<Node> nodes;
std::unordered_map<std::string, std::size_t> name_to_idx;
// Ready packages grouped by priority → max-heap
std::priority_queue<Node*, std::vector<Node*>, ReadyOrder> ready[3];
// Packages waiting (pending_deps > 0 but not yet ready)
std::unordered_set<std::size_t> waiting;
std::mutex mtx;
std::condition_variable cv;
std::atomic<int> active_workers{0};
std::atomic<int> completed{0};
std::atomic<bool> stop{false};
int total_packages = 0;
std::string work_root;
int jobs_per_worker = 1;
SchedResult result;
};
// ---------------------------------------------------------------------------
// Enqueue a node into the ready queue at the correct priority level
// ---------------------------------------------------------------------------
static void enqueue_ready(Scheduler& s, std::size_t idx) {
Node& node = s.nodes[idx];
int prio = level_priority(node.depth);
s.ready[prio].push(&node);
s.waiting.erase(idx);
}
// ---------------------------------------------------------------------------
// Try to claim the next ready package from any priority level.
// Returns nullptr if nothing is ready.
// ---------------------------------------------------------------------------
static Node* claim_next(Scheduler& s) {
// Check Beta (0), then Alpha (1), then Zeta (2)
for (int p = 2; p >= 0; --p) {
auto& q = s.ready[p];
if (q.empty()) continue;
Node* node = q.top();
q.pop();
node->claimed = true;
return node;
}
return nullptr;
}
// ---------------------------------------------------------------------------
// Worker loop
// ---------------------------------------------------------------------------
static void worker_loop(Scheduler& s) {
s.active_workers.fetch_add(1, std::memory_order_relaxed);
while (true) {
Node* node = nullptr;
{
std::unique_lock lock(s.mtx);
s.cv.wait(lock, [&] {
return s.stop.load(std::memory_order_acquire)
|| !s.ready[0].empty()
|| !s.ready[1].empty()
|| !s.ready[2].empty()
|| s.completed.load(std::memory_order_acquire) >= s.total_packages;
});
if (s.stop.load(std::memory_order_acquire)) break;
if (s.completed.load(std::memory_order_acquire) >= s.total_packages) break;
node = claim_next(s);
}
if (node == nullptr) continue;
// Build the package
std::cout << std::format(" building {} (depth={})\n",
node->step->name, node->depth);
auto r = build::build(*node->step,
s.work_root + "/" + node->step->name,
s.jobs_per_worker);
node->result = r;
// Mark complete and notify dependents
{
std::lock_guard lock(s.mtx);
s.completed.fetch_add(1, std::memory_order_relaxed);
if (r.ok) {
s.result.built.push_back(node->step->name);
} else {
s.result.failed.push_back(node->step->name);
s.result.ok = false;
s.stop.store(true, std::memory_order_release);
}
// Wake up dependents
for (auto dep_idx : node->dependents) {
Node& dep = s.nodes[dep_idx];
dep.pending_deps--;
if (dep.pending_deps == 0) {
enqueue_ready(s, dep_idx);
}
}
}
// Signal completion or failure
{
std::lock_guard lock(s.mtx);
if (s.completed.load(std::memory_order_acquire) >= s.total_packages) {
s.stop.store(true, std::memory_order_release);
}
}
s.cv.notify_all();
}
s.active_workers.fetch_sub(1, std::memory_order_relaxed);
}
// ---------------------------------------------------------------------------
// Compute depth for each node (distance from deepest leaf)
// Uses post-order traversal: depth = 1 + max(dep depths), leaf = 1
// ---------------------------------------------------------------------------
static void compute_depths(Scheduler& s) {
// Start from leaves (pending_deps == 0)
std::queue<std::size_t> leaf_queue;
for (std::size_t i = 0; i < s.nodes.size(); ++i) {
if (s.nodes[i].pending_deps == 0) {
s.nodes[i].depth = 1;
leaf_queue.push(i);
}
}
// Propagate upward: when a dependent is processed, its depth
// is 1 + max of its dependency depths.
// For simplicity, we approximate: depth = level from leaves.
// This is fine for prioritization — deeper = unblocks more.
std::vector<int> rem_deps(s.nodes.size());
for (std::size_t i = 0; i < s.nodes.size(); ++i) {
rem_deps[i] = static_cast<int>(s.nodes[i].dependents.size());
}
while (!leaf_queue.empty()) {
auto u = leaf_queue.front();
leaf_queue.pop();
Node& node = s.nodes[u];
if (node.step == nullptr) continue;
for (auto dep_idx : node.dependents) {
Node& dep_node = s.nodes[dep_idx];
if (node.depth + 1 > dep_node.depth) {
dep_node.depth = node.depth + 1;
}
if (--rem_deps[dep_idx] == 0) {
leaf_queue.push(dep_idx);
}
}
}
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
SchedResult run(const resolve::BuildPlan& plan,
const std::string& work_root,
int workers,
int jobs) {
SchedResult result;
if (plan.steps.empty()) {
return {true, {}, {}};
}
for (auto& step : plan.steps) {
auto r = build::build(step, work_root + "/" + step.name, jobs);
if (r.ok) {
result.built.push_back(step.name);
} else {
result.failed.push_back(step.name);
result.ok = false;
return result;
Scheduler s;
s.work_root = work_root;
s.jobs_per_worker = std::max(1, jobs);
s.total_packages = static_cast<int>(plan.steps.size());
s.nodes.resize(plan.steps.size());
// Build name → index map
for (std::size_t i = 0; i < plan.steps.size(); ++i) {
s.name_to_idx[plan.steps[i].name] = i;
}
// Wire up nodes: dependencies, dependents, pending_deps
for (std::size_t i = 0; i < plan.steps.size(); ++i) {
Node& node = s.nodes[i];
node.step = &plan.steps[i];
node.depth = 0;
for (auto& dep : plan.steps[i].dependencies) {
auto it = s.name_to_idx.find(dep.name);
if (it == s.name_to_idx.end()) {
std::cerr << std::format("warning: dependency '{}' of '{}' not in build plan\n",
dep.name, plan.steps[i].name);
continue;
}
// dep → i (dependency is upstream)
Node& dep_node = s.nodes[it->second];
dep_node.dependents.push_back(i);
node.pending_deps++;
}
}
result.ok = true;
return result;
// Compute depths for priority
compute_depths(s);
// Enqueue root nodes (pending_deps == 0)
for (std::size_t i = 0; i < s.nodes.size(); ++i) {
if (s.nodes[i].pending_deps == 0) {
enqueue_ready(s, i);
} else {
s.waiting.insert(i);
}
}
int num_workers = std::max(1, std::min(workers, s.total_packages));
std::cout << std::format("scheduler: {} packages, {} workers, {} jobs/worker\n",
s.total_packages, num_workers, s.jobs_per_worker);
// Spawn workers
std::vector<std::thread> threads;
for (int w = 0; w < num_workers; ++w) {
threads.emplace_back(worker_loop, std::ref(s));
}
// Wait for workers to finish
for (auto& t : threads) {
if (t.joinable()) t.join();
}
s.result.ok = s.result.failed.empty();
return s.result;
}
} // namespace kappa::sched
+74
View File
@@ -0,0 +1,74 @@
#include "kappa/service/service.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
std::string generate_dinit_service(const ServiceSpec& spec) {
std::ostringstream out;
// Header
out << std::format("# Generated by kappa — do not edit manually\n");
out << std::format("# dinit service: {}\n", spec.name);
out << "\n";
// Type mapping
std::string dinit_type;
if (spec.type == "simple" || spec.type == "notify" ||
spec.type == "longrun") {
dinit_type = "process";
} else if (spec.type == "forking") {
dinit_type = "bgprocess";
} else if (spec.type == "oneshot") {
dinit_type = "scripted";
} else {
dinit_type = "process";
}
out << std::format("type = {}\n", dinit_type);
out << std::format("command = {}\n", spec.exec);
// Restart policy
if (spec.restart_policy == "always") {
out << "restart = true\n";
} else if (spec.restart_policy == "on-failure") {
out << "restart = true\n";
} else if (spec.restart_policy == "never") {
out << "restart = false\n";
} else if (!spec.restart_policy.empty()) {
out << "restart = true\n";
}
// depends-on
if (!spec.after.empty()) {
out << std::format("depends-on = {}\n", spec.after);
}
// working-dir
if (!spec.working_dir.empty()) {
out << std::format("working-dir = {}\n", spec.working_dir);
}
// run-as
if (!spec.user.empty()) {
out << std::format("run-as = {}\n", spec.user);
}
// Environment variables (as comments — dinit doesn't support inline env)
if (!spec.env.empty()) {
out << "\n# Environment variables:\n";
for (const auto& [key, value] : spec.env) {
out << std::format("# {}={}\n", key, value);
}
}
// description
if (!spec.description.empty()) {
out << std::format("description = {}\n", spec.description);
}
return out.str();
}
} // namespace kappa::service
+202
View File
@@ -0,0 +1,202 @@
#include "kappa/service/service.hpp"
#include <filesystem>
#include <format>
#include <fstream>
namespace kappa::service {
// ---------------------------------------------------------------------------
// Backend service-file generators (defined in separate .cpp files)
// systemd_service and s6_service are declared in service.hpp
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ServiceSpec::from_service_init
// ---------------------------------------------------------------------------
ServiceSpec ServiceSpec::from_service_init(const dsl::NamedService& ns) {
ServiceSpec spec;
spec.name = ns.name;
spec.description = ns.description;
spec.exec = ns.exec;
spec.user = ns.user;
spec.ports = ns.ports;
spec.env = ns.env;
spec.after = ns.after;
spec.working_dir = ns.working_dir;
// Normalize type
if (ns.type == "forking" || ns.type == "longrun" ||
ns.type == "notify" || ns.type == "oneshot") {
spec.type = ns.type;
} else {
spec.type = "simple";
}
// Restart policy
spec.restart_policy = ns.restart;
if (spec.restart_policy.empty() && ns.type == "longrun") {
spec.restart_policy = "always";
}
return spec;
}
// ---------------------------------------------------------------------------
// generate_service_file — dispatch to the correct backend
// ---------------------------------------------------------------------------
std::string generate_service_file(InitSystem is, const ServiceSpec& spec) {
switch (is) {
case InitSystem::Systemd:
return generate_systemd_service(spec);
case InitSystem::OpenRC:
return generate_openrc_service(spec);
case InitSystem::S6:
return generate_s6_service(spec);
case InitSystem::Dinit:
return generate_dinit_service(spec);
case InitSystem::Runit:
return generate_runit_service(spec);
case InitSystem::Unknown:
default:
return {};
}
}
// ---------------------------------------------------------------------------
// install_service — write the generated file(s) to disk
// ---------------------------------------------------------------------------
ServiceInstallResult install_service(InitSystem is,
const ServiceSpec& spec,
std::string_view prefix) {
namespace fs = std::filesystem;
auto paths = init_paths(is, prefix);
if (paths.service_dir.empty()) {
return {false, {}, "Unknown init system"};
}
std::error_code ec;
// ---- S6: directory-based layout (type + run) ----
// S6: directory-based layout. Content generated inline rather than
// calling generate_s6_service() to avoid parsing the combined output.
if (is == InitSystem::S6) {
fs::path svc_dir = fs::path(paths.service_dir) / spec.name;
fs::create_directories(svc_dir, ec);
if (ec) {
return {false, {}, ec.message()};
}
// --- type file ---
{
fs::path type_path = svc_dir / "type";
std::ofstream out(type_path);
if (!out) {
return {false, {},
std::format("Failed to write {}", type_path.string())};
}
out << ((spec.type == "oneshot") ? "oneshot" : "longrun");
}
// --- run file ---
fs::path run_path = svc_dir / "run";
{
std::ofstream out(run_path);
if (!out) {
return {false, {},
std::format("Failed to write {}", run_path.string())};
}
out << "#!/bin/execlineb -P\n";
out << "# Generated by kappa — do not edit manually\n";
out << std::format("# s6 service: {}\n", spec.name);
if (!spec.working_dir.empty()) {
out << std::format("cd {}\n", spec.working_dir);
}
for (const auto& [key, value] : spec.env) {
out << std::format("export {} \"{}\"\n", key, value);
}
if (!spec.user.empty()) {
out << std::format("s6-setuidgid {}\n", spec.user);
}
out << spec.exec << "\n";
}
// Make run file executable
fs::permissions(run_path,
fs::perms::owner_exec | fs::perms::group_exec |
fs::perms::others_exec,
fs::perm_options::add, ec);
return {true, svc_dir.string(), {}};
}
// ---- Runit: directory-based layout (run) ----
if (is == InitSystem::Runit) {
fs::path svc_dir = fs::path(paths.service_dir) / spec.name;
fs::create_directories(svc_dir, ec);
if (ec) {
return {false, {}, ec.message()};
}
fs::path run_path = svc_dir / "run";
{
std::ofstream out(run_path);
if (!out) {
return {false, {},
std::format("Failed to write {}", run_path.string())};
}
out << generate_runit_service(spec);
}
// Make run executable
fs::permissions(run_path,
fs::perms::owner_exec | fs::perms::group_exec |
fs::perms::others_exec,
fs::perm_options::add, ec);
return {true, svc_dir.string(), {}};
}
// ---- Systemd / OpenRC / Dinit: single service file ----
std::string content = generate_service_file(is, spec);
if (content.empty()) {
return {false, {}, "Failed to generate service file for init system"};
}
// Determine filename
std::string filename;
switch (is) {
case InitSystem::Systemd:
filename = std::format("{}.service", spec.name);
break;
case InitSystem::OpenRC:
case InitSystem::Dinit:
filename = spec.name;
break;
default:
return {false, {}, "Unknown init system"};
}
fs::path file_path = fs::path(paths.service_dir) / filename;
fs::create_directories(file_path.parent_path(), ec);
if (ec) {
return {false, {}, ec.message()};
}
{
std::ofstream out(file_path);
if (!out) {
return {false, {},
std::format("Failed to write {}", file_path.string())};
}
out << content;
}
return {true, file_path.string(), {}};
}
} // namespace kappa::service
+81
View File
@@ -0,0 +1,81 @@
#include "kappa/service/service.hpp"
#include "kappa/util.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
namespace {
bool is_background_type(std::string_view type) {
return type == "longrun" || type == "notify" || type == "forking";
}
} // namespace
std::string generate_openrc_service(const ServiceSpec& spec) {
std::ostringstream os;
// Shebang and header
os << "#!/sbin/openrc-run\n";
os << "# Generated by kappa — do not edit manually\n";
// Description
auto desc = spec.description.empty()
? spec.name + " service"
: spec.description;
os << std::format("description=\"{}\"\n", util::shell_escape(desc));
// Command
os << std::format("\ncommand=\"{}\"\n", util::shell_escape(spec.exec));
// User
if (!spec.user.empty()) {
os << std::format("command_user=\"{}\"\n", util::shell_escape(spec.user));
}
// command_background and command_args (type-specific)
if (spec.type == "oneshot") {
os << "command_background=false\n";
os << "command_args=\"\"\n";
} else if (is_background_type(spec.type)) {
os << "command_background=true\n";
} else if (!spec.working_dir.empty()) {
os << "command_background=true\n";
}
// Working directory
if (!spec.working_dir.empty()) {
os << std::format("directory=\"{}\"\n",
util::shell_escape(spec.working_dir));
}
// Depend block (if after or ports)
bool has_depend = !spec.after.empty() || !spec.ports.empty();
if (has_depend) {
os << "\ndepend() {\n";
if (!spec.after.empty()) {
os << std::format(" need {}\n", spec.after);
}
if (!spec.ports.empty()) {
os << " use net\n";
}
os << "}\n";
}
// Restart policy comment
if (!spec.restart_policy.empty()) {
os << std::format("# restart policy: {}\n", spec.restart_policy);
}
// Environment exports
for (auto& [key, value] : spec.env) {
os << std::format("export {}=\"{}\"\n",
key, util::shell_escape(value));
}
return os.str();
}
} // namespace kappa::service
+57
View File
@@ -0,0 +1,57 @@
#include "kappa/service/service.hpp"
#include "kappa/util.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
std::string generate_runit_service(const ServiceSpec& spec) {
std::ostringstream out;
// Shebang
out << "#!/bin/sh\n";
// Header
out << "# Generated by kappa — do not edit manually\n";
out << std::format("# runit service: {}\n", spec.name);
out << std::format("# Type: {}\n", spec.type);
// Forking note
if (spec.type == "forking") {
out << "# NOTE: runit requires foreground execution.\n";
out << "# If the daemon forks, pass --foreground or equivalent"
" flag.\n";
}
// Oneshot note
if (spec.type == "oneshot") {
out << "# NOTE: runit has no native oneshot support. This service"
" will restart on exit.\n";
}
// Redirect stderr to stdout for logging
out << "exec 2>&1\n";
// Working directory
if (!spec.working_dir.empty()) {
out << std::format("cd \"{}\"\n", util::shell_escape(spec.working_dir));
}
// Environment variables
for (const auto& [key, value] : spec.env) {
out << std::format("export {}=\"{}\"\n", key, util::shell_escape(value));
}
// Final exec — replace the shell with the daemon
if (!spec.user.empty()) {
out << std::format("exec chpst -u {} {}\n",
util::shell_escape(spec.user), spec.exec);
} else {
out << "exec " << spec.exec << "\n";
}
return out.str();
}
} // namespace kappa::service
+47
View File
@@ -0,0 +1,47 @@
#include "kappa/service/service.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
std::string generate_s6_service(const ServiceSpec& spec) {
std::ostringstream out;
// --- type file content ---
std::string type_content;
if (spec.type == "oneshot") {
type_content = "oneshot";
} else {
// "longrun", "notify", and anything else map to longrun in s6
type_content = "longrun";
}
// --- run file content ---
std::ostringstream run;
run << "#!/bin/execlineb -P\n";
run << "# Generated by kappa — do not edit manually\n";
run << std::format("# s6 service: {}\n", spec.name);
if (!spec.working_dir.empty()) {
run << std::format("cd {}\n", spec.working_dir);
}
for (const auto& [key, value] : spec.env) {
run << std::format("export {} \"{}\"\n", key, value);
}
if (!spec.user.empty()) {
run << std::format("s6-setuidgid {}\n", spec.user);
}
run << spec.exec << "\n";
// --- combined output ---
out << std::format("# --- s6 service directory: {} ---\n", spec.name);
out << "# file: type\n";
out << type_content << "\n";
out << "# file: run\n";
out << run.str();
return out.str();
}
} // namespace kappa::service
+62
View File
@@ -0,0 +1,62 @@
#include "kappa/service/service.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
std::string generate_systemd_service(const ServiceSpec& spec) {
std::ostringstream out;
// --- [Unit] ---
out << "[Unit]\n";
out << std::format("Description={}\n",
spec.description.empty() ? spec.name : spec.description);
if (!spec.after.empty()) {
out << std::format("After={}\n", spec.after);
}
// --- [Service] ---
out << "\n[Service]\n";
out << std::format("ExecStart={}\n", spec.exec);
if (spec.type == "simple") {
out << "Type=simple\n";
} else if (spec.type == "forking") {
out << "Type=forking\n";
} else if (spec.type == "oneshot") {
out << "Type=oneshot\n";
} else if (spec.type == "notify") {
out << "Type=notify\n";
} else {
out << "Type=simple\n";
}
if (!spec.user.empty()) {
out << std::format("User={}\n", spec.user);
}
if (spec.restart_policy == "always") {
out << "Restart=always\n";
} else if (spec.restart_policy == "on-failure") {
out << "Restart=on-failure\n";
} else if (spec.restart_policy == "never") {
out << "Restart=no\n";
}
if (!spec.working_dir.empty()) {
out << std::format("WorkingDirectory={}\n", spec.working_dir);
}
for (const auto& [key, value] : spec.env) {
out << std::format("Environment=\"{0}={1}\"\n", key, value);
}
// --- [Install] ---
out << "\n[Install]\n";
out << "WantedBy=multi-user.target\n";
return out.str();
}
} // namespace kappa::service
+98
View File
@@ -0,0 +1,98 @@
#include "kappa/service/types.hpp"
#include "kappa/util.hpp"
#include <algorithm>
#include <cctype>
#include <format>
#include <ranges>
namespace kappa::service {
InitSystem parse_init_system(std::string_view name) {
auto lower = util::to_lower(name);
if (lower == "systemd") return InitSystem::Systemd;
if (lower == "openrc") return InitSystem::OpenRC;
if (lower == "s6") return InitSystem::S6;
if (lower == "runit") return InitSystem::Runit;
if (lower == "dinit") return InitSystem::Dinit;
return InitSystem::Unknown;
}
std::string_view to_string(InitSystem is) {
switch (is) {
case InitSystem::Systemd: return "systemd";
case InitSystem::OpenRC: return "openrc";
case InitSystem::S6: return "s6";
case InitSystem::Runit: return "runit";
case InitSystem::Dinit: return "dinit";
case InitSystem::Unknown: return "unknown";
}
return "unknown";
}
bool is_supported(std::string_view name) {
return parse_init_system(name) != InitSystem::Unknown;
}
std::vector<InitSystem> all_systems() {
return {InitSystem::Systemd, InitSystem::OpenRC,
InitSystem::S6, InitSystem::Runit, InitSystem::Dinit};
}
std::string_view init_description(InitSystem is) {
switch (is) {
case InitSystem::Systemd:
return "systemd — system and service manager";
case InitSystem::OpenRC:
return "OpenRC — dependency-based init system";
case InitSystem::S6:
return "s6 — s6 supervision suite";
case InitSystem::Runit:
return "runit — supervision suite";
case InitSystem::Dinit:
return "dinit — service manager / init system";
case InitSystem::Unknown:
return "unknown init system";
}
return "unknown init system";
}
InitPaths init_paths(InitSystem is, std::string_view prefix) {
switch (is) {
case InitSystem::Systemd:
return {
.service_dir = std::format("{}etc/systemd/system", prefix),
.enable_cmd = "systemctl enable",
.disable_cmd = "systemctl disable",
};
case InitSystem::OpenRC:
return {
.service_dir = std::format("{}etc/init.d", prefix),
.enable_cmd = "rc-update add",
.disable_cmd = "rc-update del",
};
case InitSystem::S6:
return {
.service_dir = std::format("{}etc/s6/sv", prefix),
.enable_cmd = "s6-rc-bundle-update",
.disable_cmd = "s6-rc-bundle-update",
};
case InitSystem::Runit:
return {
.service_dir = std::format("{}etc/sv", prefix),
.enable_cmd = "ln -sf /etc/sv",
.disable_cmd = "rm -f /var/service",
};
case InitSystem::Dinit:
return {
.service_dir = std::format("{}etc/dinit.d", prefix),
.enable_cmd = "dinitctl enable",
.disable_cmd = "dinitctl disable",
};
case InitSystem::Unknown:
return {};
}
return {};
}
} // namespace kappa::service
+55
View File
@@ -0,0 +1,55 @@
#include "kappa/system/activate.hpp"
#include <filesystem>
#include <format>
#include <fstream>
namespace kappa::system {
ActivateResult write_hostname(const std::string& hostname,
std::string_view prefix) {
if (hostname.empty()) {
return {false, "hostname is empty"};
}
std::filesystem::path path = std::filesystem::path(prefix) / "etc/hostname";
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) {
return {false, std::format("cannot create {}: {}", path.parent_path().string(), ec.message())};
}
std::ofstream out(path);
if (!out) {
return {false, std::format("cannot write {}", path.string())};
}
out << hostname << "\n";
return {true, {}};
}
ActivateResult write_timezone(const std::string& timezone,
std::string_view prefix) {
if (timezone.empty()) {
return {false, "timezone is empty"};
}
// /etc/localtime is a symlink to /usr/share/zoneinfo/{timezone}
std::filesystem::path localtime = std::filesystem::path(prefix) / "etc/localtime";
std::filesystem::path zoneinfo = std::filesystem::path(prefix) / "usr/share/zoneinfo" / timezone;
std::error_code ec;
if (!std::filesystem::exists(zoneinfo, ec)) {
return {false, std::format("timezone data not found: {}", zoneinfo.string())};
}
std::filesystem::create_directories(localtime.parent_path(), ec);
// Remove existing symlink/file if present
std::filesystem::remove(localtime, ec);
std::filesystem::create_symlink(zoneinfo, localtime, ec);
if (ec) {
return {false, std::format("cannot create symlink {}: {}", localtime.string(), ec.message())};
}
return {true, {}};
}
} // namespace kappa::system
+61
View File
@@ -1,4 +1,8 @@
#include "kappa/tools/doctor.hpp"
#include "kappa/boot/types.hpp"
#include "kappa/service/types.hpp"
#include <filesystem>
#include <sys/stat.h>
namespace kappa::tools {
@@ -32,6 +36,17 @@ std::vector<Diagnostic> check_package(const dsl::PackageDef& pkg) {
}
}
for (auto& c : pkg.conflicts) {
if (c.empty()) {
diags.push_back({DiagSeverity::Warning,
"conflict entry has an empty name"});
}
if (c == pkg.name) {
diags.push_back({DiagSeverity::Error,
"package conflicts with itself: '" + c + "'"});
}
}
if (pkg.config_files.empty()) {
diags.push_back({DiagSeverity::Warning,
"no config files defined — package has no runtime configuration"});
@@ -71,8 +86,48 @@ std::vector<Diagnostic> check_config(const dsl::SystemConfig& cfg) {
if (cfg.boot.init.empty()) {
diags.push_back({DiagSeverity::Warning, "boot init system is not specified"});
}
if (!cfg.boot.init.empty()) {
auto is = kappa::service::parse_init_system(cfg.boot.init);
if (is == kappa::service::InitSystem::Unknown) {
diags.push_back({DiagSeverity::Warning,
"boot.init '" + cfg.boot.init + "' is not a recognized init system — supported: systemd, openrc, s6, runit, dinit"});
}
}
if (cfg.boot.init.empty() && !cfg.services.empty()) {
diags.push_back({DiagSeverity::Warning,
std::to_string(cfg.services.size()) + " service(s) defined but no init system configured — set boot.init"});
}
if (cfg.boot.bootloader.empty()) {
diags.push_back({DiagSeverity::Warning, "boot bootloader is not specified"});
} else {
auto bl = kappa::boot::parse_bootloader(cfg.boot.bootloader);
if (bl == kappa::boot::Bootloader::Unknown) {
diags.push_back({DiagSeverity::Warning,
"boot.bootloader '" + cfg.boot.bootloader + "' is not a recognized bootloader — supported: grub, limine"});
}
}
// Validate boot partitions
if (cfg.boot.efi.empty() && cfg.boot.root.empty()) {
diags.push_back({DiagSeverity::Error, "no boot partitions defined (efi or root)"});
} else {
// Check that each specified partition exists
auto check_partition = [&](const std::string& path, const char* label) {
if (path.empty()) return;
std::error_code ec;
if (!std::filesystem::exists(path, ec)) {
diags.push_back({DiagSeverity::Error,
std::string("boot.") + label + " '" + path + "' does not exist"});
} else {
struct stat st;
if (stat(path.c_str(), &st) == 0 && !S_ISBLK(st.st_mode)) {
diags.push_back({DiagSeverity::Warning,
std::string("boot.") + label + " '" + path + "' is not a block device"});
}
}
};
check_partition(cfg.boot.efi, "efi");
check_partition(cfg.boot.root, "root");
check_partition(cfg.boot.swap, "swap");
}
if (cfg.packages.empty()) {
@@ -105,6 +160,12 @@ std::vector<Diagnostic> check_config(const dsl::SystemConfig& cfg) {
+ " assertions defined — run 'kappa validate' to check them"});
}
for (auto& g : cfg.groups) {
if (g.name.empty()) {
diags.push_back({DiagSeverity::Warning, "group has no name"});
}
}
return diags;
}
+69 -23
View File
@@ -27,8 +27,12 @@ static void write_env(std::ostream& os, int d,
static void write_features(std::ostream& os, int d,
const std::unordered_map<std::string, dsl::FeatureDef>& feats) {
if (feats.empty()) { return; }
std::vector<std::string> keys;
for (auto& [k, _] : feats) keys.push_back(k);
std::sort(keys.begin(), keys.end());
os << Indent(d) << "features {\n";
for (auto& [k, f] : feats) {
for (auto& k : keys) {
auto& f = feats.at(k);
if (f.flag.empty() && !f.force) {
os << Indent(d + 1) << k << " = " << (f.enabled ? "true" : "false") << "\n";
} else {
@@ -105,6 +109,15 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
os << "]\n";
}
if (!pkg.conflicts.empty()) {
os << Indent(d) << "conflicts = [";
for (std::size_t i = 0; i < pkg.conflicts.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << pkg.conflicts[i] << '"';
}
os << "]\n";
}
if (!pkg.patches.empty()) {
os << Indent(d) << "patches = [\n";
for (auto& p : pkg.patches) {
@@ -151,27 +164,33 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
os << Indent(d) << "}\n";
}
if (!pkg.service.empty()) {
os << Indent(d) << "service {\n";
for (auto& [init, si] : pkg.service) {
os << Indent(d + 1) << init << " {\n";
if (!si.exec.empty()) { os << Indent(d + 2) << "exec = \"" << si.exec << "\"\n"; }
if (!si.type.empty()) { os << Indent(d + 2) << "type = \"" << si.type << "\"\n"; }
if (!si.user.empty()) { os << Indent(d + 2) << "user = \"" << si.user << "\"\n"; }
if (!si.ports.empty()) {
os << Indent(d + 2) << "ports = [";
for (std::size_t i = 0; i < si.ports.size(); ++i) {
if (!pkg.services.empty()) {
for (auto& ns : pkg.services) {
if (ns.name != "main") {
os << Indent(d) << "service " << ns.name << " {\n";
} else {
os << Indent(d) << "service {\n";
}
if (!ns.exec.empty()) { os << Indent(d + 1) << "exec = \"" << ns.exec << "\"\n"; }
if (!ns.type.empty()) { os << Indent(d + 1) << "type = \"" << ns.type << "\"\n"; }
if (!ns.user.empty()) { os << Indent(d + 1) << "user = \"" << ns.user << "\"\n"; }
if (!ns.ports.empty()) {
os << Indent(d + 1) << "ports = [";
for (std::size_t i = 0; i < ns.ports.size(); ++i) {
if (i > 0) { os << ", "; }
os << si.ports[i];
os << ns.ports[i];
}
os << "]\n";
}
for (auto& [k, v] : si.env) {
os << Indent(d + 2) << k << " = \"" << v << "\"\n";
if (!ns.description.empty()) { os << Indent(d + 1) << "description = \"" << ns.description << "\"\n"; }
if (!ns.after.empty()) { os << Indent(d + 1) << "after = \"" << ns.after << "\"\n"; }
if (!ns.restart.empty()) { os << Indent(d + 1) << "restart = \"" << ns.restart << "\"\n"; }
if (!ns.working_dir.empty()) { os << Indent(d + 1) << "working_dir = \"" << ns.working_dir << "\"\n"; }
for (auto& [k, v] : ns.env) {
os << Indent(d + 1) << k << " = \"" << v << "\"\n";
}
os << Indent(d + 1) << "}\n";
os << Indent(d) << "}\n";
}
os << Indent(d) << "}\n";
}
if (!pkg.assertions.empty()) {
@@ -189,6 +208,7 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
write_phase(os, d, "build", pkg.build);
write_phase(os, d, "check", pkg.check);
write_phase(os, d, "install", pkg.install);
write_phase(os, d, "uninstall", pkg.uninstall);
os << "}\n";
}
@@ -203,13 +223,20 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
os << "]\n\n";
}
if (!cfg.remotes.empty()) {
os << "remotes = [\n";
for (auto& r : cfg.remotes) {
os << " \"" << r << "\",\n";
}
os << "]\n\n";
}
if (!cfg.assertions.empty()) {
os << "assert {\n";
for (auto& a : cfg.assertions) {
os << " \"" << a.message << "\" : "
<< a.field << " " << a.op;
if (!a.value.empty()) { os << " \"" << a.value << '"'; }
os << "\n";
<< a.field << " " << a.op
<< " \"" << a.value << '"' << "\n";
}
os << "}\n\n";
}
@@ -222,8 +249,11 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
write_env(os, 1, s.env);
if (!s.config.empty()) {
os << " config {\n";
for (auto& [k, v] : s.config) {
os << " " << k << " = \"" << v << "\"\n";
std::vector<std::string> cfg_keys;
for (auto& [k, _] : s.config) cfg_keys.push_back(k);
std::sort(cfg_keys.begin(), cfg_keys.end());
for (auto& k : cfg_keys) {
os << " " << k << " = \"" << s.config.at(k) << "\"\n";
}
os << " }\n";
}
@@ -245,8 +275,11 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
write_features(os, 2, p.features);
if (!p.config.empty()) {
os << " config {\n";
for (auto& [k, v] : p.config) {
os << " " << k << " = " << v << "\n";
std::vector<std::string> pcfg_keys;
for (auto& [k, _] : p.config) pcfg_keys.push_back(k);
std::sort(pcfg_keys.begin(), pcfg_keys.end());
for (auto& k : pcfg_keys) {
os << " " << k << " = " << p.config.at(k) << "\n";
}
os << " }\n";
}
@@ -268,6 +301,19 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
os << "}\n\n";
}
if (!cfg.groups.empty()) {
os << "groups {\n";
for (auto& g : cfg.groups) {
os << " " << g.name;
if (g.gid < 0) {
os << " {}\n";
} else {
os << " {\n gid = " << g.gid << "\n }\n";
}
}
os << "}\n\n";
}
write_boot_block(os, 0, cfg.boot);
os << "\n";
+26
View File
@@ -0,0 +1,26 @@
#include "kappa/util.hpp"
#include <algorithm>
#include <cctype>
#include <ranges>
namespace kappa::util {
std::string to_lower(std::string_view sv) {
std::string s(sv);
std::ranges::transform(s, s.begin(),
[](unsigned char c) { return std::tolower(c); });
return s;
}
std::string shell_escape(std::string_view s) {
std::string result;
result.reserve(s.size());
for (char c : s) {
if (c == '"' || c == '\\' || c == '$' || c == '`') {
result += '\\';
}
result += c;
}
return result;
}
}
+350
View File
@@ -0,0 +1,350 @@
#!/bin/bash
# kappa init-switching integration test via systemd-nspawn
# Requires: systemd-nspawn, debootstrap (or pacstrap), kappa binary
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
KAPPA_BIN="${KAPPA_BIN:-./build/kappa}"
ROOTFS="./test-rootfs"
PASS=0
FAIL=0
cleanup() {
echo ""
echo "Cleaning up..."
rm -rf "$ROOTFS" /tmp/kappa-test-*.kap 2>/dev/null || true
}
trap cleanup EXIT
check() {
local desc="$1" cmd="$2" expect="$3"
echo -n " $desc ... "
local out
out=$(eval "$cmd" 2>&1) || true
if echo "$out" | grep -q "$expect"; then
echo -e "${GREEN}OK${NC}"
PASS=$((PASS + 1))
else
echo -e "${RED}FAIL${NC}"
echo " expected: $expect"
echo " got: $(echo "$out" | head -3 | tr '\n' ' ')"
FAIL=$((FAIL + 1))
fi
}
echo ""
echo "============================================"
echo " kappa init-switching integration tests"
echo "============================================"
echo ""
# ------------------------------------------------------------------
# 1. Create a minimal rootfs directory structure
# ------------------------------------------------------------------
echo "--- Setting up test rootfs ---"
mkdir -p "$ROOTFS"/{etc,usr/local/bin,kappa/{boot,db,store,temp},var/service}
cp "$KAPPA_BIN" "$ROOTFS/usr/local/bin/kappa"
chmod +x "$ROOTFS/usr/local/bin/kappa"
export KAPPA_ROOT="$ROOTFS/kappa"
# Create a minimal package registry (simulating installed packages)
mkdir -p "$KAPPA_ROOT/db"
# Simulate an installed system with nginx (has services), zlib (no services),
# and postgresql (multi-service, uses ${enabledinit})
cat > /tmp/kappa-test-nginx.kap << 'EOF'
package "nginx" {
version = "1.24"
source = "https://nginx.org/nginx-1.24.tar.gz"
service {
exec = "/usr/bin/nginx"
type = "forking"
ports = [80, 443]
user = "www-data"
description = "Nginx web server"
}
build { ./configure --prefix=${prefix}; make -j${jobs} }
install { make DESTDIR=${destdir} install }
}
EOF
cat > /tmp/kappa-test-zlib.kap << 'EOF'
package "zlib" {
version = "1.3"
source = "https://zlib.net/zlib-1.3.tar.gz"
build { ./configure --prefix=${prefix}; make -j${jobs} }
install { make DESTDIR=${destdir} install }
}
EOF
cat > /tmp/kappa-test-postgres.kap << 'EOF'
package "postgresql" {
version = "16.3"
source = "https://ftp.postgresql.org/source/postgresql-16.3.tar.gz"
service main {
exec = "/usr/bin/postgres"
type = "forking"
ports = [5432]
user = "postgres"
description = "PostgreSQL database server"
}
service checkpointer {
exec = "/usr/bin/postgres-checkpointer"
type = "longrun"
user = "postgres"
}
build {
case ${enabledinit} in
systemd) ./configure --with-systemd --prefix=${prefix} ;;
*) ./configure --prefix=${prefix} ;;
esac
make -j${jobs}
}
install { make DESTDIR=${destdir} install }
}
EOF
# Write installed DB (simulate nginx, zlib, postgresql already installed)
# Format: name version hash [provides...]
mkdir -p "$(dirname "$KAPPA_ROOT/db/installed")"
cat > "$KAPPA_ROOT/db/installed" << 'EOF'
nginx 1.24 a1b2c3d4e5f6a7b8
zlib 1.3 b2c3d4e5f6a7b8c9
postgresql 16.3 c3d4e5f6a7b8c9d0
init a1b2c3d4e5f6a7b8
kernel d4e5f6a7b8c9d0e1
bootloader e5f6a7b8c9d0e1f2
EOF
echo ""
# ------------------------------------------------------------------
# 2. Init system switching tests
# ------------------------------------------------------------------
echo "--- Init system switching ---"
# Base config template
config_template() {
local init="$1"
cat << EOF
system { hostname = "kappa-test" }
packages {
nginx {}
zlib {}
postgresql {}
$init {}
}
services {
nginx { enable = true }
postgresql { enable = true }
postgresql.checkpointer { enable = true }
}
boot {
kernel = "linux"
init = "$init"
root = "/dev/sda1"
bootloader = "limine"
}
groups {
wheel { gid = 998 }
}
users {
root { shell = "/bin/zsh" }
}
EOF
}
# Test systemd config
config_template "systemd" > /tmp/kappa-test-systemd.kap
check "parse systemd config" \
"$KAPPA_BIN parse-config /tmp/kappa-test-systemd.kap" \
'systemd — system and service manager'
check "systemd bootloader in config" \
"$KAPPA_BIN parse-config /tmp/kappa-test-systemd.kap" \
'Limine'
# Test openrc config
config_template "openrc" > /tmp/kappa-test-openrc.kap
check "parse openrc config" \
"$KAPPA_BIN parse-config /tmp/kappa-test-openrc.kap" \
'OpenRC — dependency-based init'
# Test s6 config
config_template "s6" > /tmp/kappa-test-s6.kap
check "parse s6 config" \
"$KAPPA_BIN parse-config /tmp/kappa-test-s6.kap" \
's6 — s6 supervision suite'
# Test runit config
config_template "runit" > /tmp/kappa-test-runit.kap
check "parse runit config" \
"$KAPPA_BIN parse-config /tmp/kappa-test-runit.kap" \
'runit — supervision suite'
# Test dinit config
config_template "dinit" > /tmp/kappa-test-dinit.kap
check "parse dinit config" \
"$KAPPA_BIN parse-config /tmp/kappa-test-dinit.kap" \
'dinit — service manager'
echo ""
# ------------------------------------------------------------------
# 3. Service generation verification
# ------------------------------------------------------------------
echo "--- Service file generation ---"
# Verify service specs are parsed correctly per init
for init in systemd openrc s6 runit dinit; do
check "service block present for $init config" \
"$KAPPA_BIN format /tmp/kappa-test-$init.kap" \
'enable = true'
done
# Verify the format output contains correct service fields
check "nginx service has type forking" \
"$KAPPA_BIN format /tmp/kappa-test-nginx.kap" \
'type = "forking"'
check "postgresql has named service checkpointer" \
"$KAPPA_BIN format /tmp/kappa-test-postgres.kap" \
'service checkpointer'
check "postgresql build uses enabledinit" \
"$KAPPA_BIN format /tmp/kappa-test-postgres.kap" \
'enabledinit'
echo ""
# ------------------------------------------------------------------
# 4. Rebuild impact analysis
# ------------------------------------------------------------------
echo "--- Rebuild impact analysis ---"
# Write an initial installed state matching s6 config
# (reuse the same installed DB from above)
config_template "s6" > /tmp/kappa-test-rebuild-s6.kap
# The packages list includes "s6" which isn't in the installed DB,
# so rebuild correctly shows it as a new package to build.
check "rebuild detects new init package" \
"$KAPPA_BIN rebuild /tmp/kappa-test-rebuild-s6.kap 2>&1" \
'packages to rebuild'
# Now test switching FROM s6 TO systemd
config_template "systemd" > /tmp/kappa-test-rebuild-systemd.kap
# The rebuild should detect init_changed
# Note: this requires the installed DB to have the old init hash
check "rebuild detects init change" \
"$KAPPA_BIN rebuild /tmp/kappa-test-rebuild-systemd.kap 2>&1" \
'packages to rebuild'
echo ""
# ------------------------------------------------------------------
# 5. Bootloader switching tests
# ------------------------------------------------------------------
echo "--- Bootloader switching ---"
for bl in grub limine; do
cat > "/tmp/kappa-test-bl-$bl.kap" << KAPEOF
system { hostname = "test" }
packages { nginx {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "$bl"
}
users { root { shell = "/bin/sh" } }
KAPEOF
check "bootloader $bl recognized in config" \
"$KAPPA_BIN parse-config /tmp/kappa-test-bl-$bl.kap" \
"$bl"
done
# Test unknown bootloader
cat > /tmp/kappa-test-badbl.kap << 'KAPEOF'
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "lilo"
}
users { root { shell = "/bin/sh" } }
KAPEOF
check "doctor warns on unknown bootloader" \
"$KAPPA_BIN doctor /tmp/kappa-test-badbl.kap 2>&1" \
'not a recognized bootloader'
echo ""
# ------------------------------------------------------------------
# 6. Groups and conflicts
# ------------------------------------------------------------------
echo "--- Groups and conflicts ---"
cat > /tmp/kappa-test-groups2.kap << 'KAPEOF'
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
groups {
wheel { gid = 998 }
audio {}
docker { gid = 995 }
}
users { root { shell = "/bin/sh" } }
KAPEOF
check "groups with gid format correctly" \
"$KAPPA_BIN format /tmp/kappa-test-groups2.kap" \
'gid = 998'
check "groups without gid format as empty" \
"$KAPPA_BIN format /tmp/kappa-test-groups2.kap" \
'audio {}'
# Conflicts test
cat > /tmp/kappa-test-conflict-pkg.kap << 'KAPEOF'
package "systemd" {
version = "255"
source = "https://example.com/systemd.tar.gz"
provides = ["udev", "logind"]
conflicts = ["eudev", "elogind"]
build { make }
install { make install }
}
KAPEOF
check "conflicts parse correctly" \
"$KAPPA_BIN parse-package /tmp/kappa-test-conflict-pkg.kap" \
'valid'
check "conflicts format round-trips" \
"$KAPPA_BIN format /tmp/kappa-test-conflict-pkg.kap" \
'conflicts = \['
echo ""
echo "============================================"
echo -e " Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}"
echo "============================================"
[ "$FAIL" -eq 0 ] || exit 1
Executable
+230
View File
@@ -0,0 +1,230 @@
#!/bin/sh
# $KAPPA integration test suite
# Run: docker run --rm -v $(pwd):/opt/$KAPPA kappa-test ./test.sh
set -e
KAPPA="${KAPPA_BIN:-./build/kappa}"
PASS=0
FAIL=0
check() {
local desc="$1" cmd="$2" expect="$3"
local out
out=$(eval "$cmd" 2>&1) || true
if echo "$out" | grep -q "$expect"; then
echo " ✓ $desc"
PASS=$((PASS + 1))
else
echo " ✗ $desc"
echo " expected: $expect"
echo " got: $(echo "$out" | head -3)"
FAIL=$((FAIL + 1))
fi
}
echo ""
echo "=== Package DSL tests ==="
echo ""
check "parse foo.kap" \
"$KAPPA parse-package examples/foo.kap" \
'package "foo" 1.2.3 — valid'
check "parse postgres.kap (multi-service)" \
"$KAPPA parse-package examples/postgres.kap" \
'package "postgresql" 16.3 — valid'
check "format foo.kap round-trips service block" \
"$KAPPA format examples/foo.kap" \
'service {'
check "format shows conflicts when present" \
"$KAPPA format /tmp/test_conflict.kap" \
'conflicts ='
check "format postgres.kap shows named services" \
"$KAPPA format examples/postgres.kap" \
'service checkpointer {'
echo ""
echo "=== Config DSL tests ==="
echo ""
check "parse config.kap" \
"$KAPPA parse-config examples/config.kap" \
'system config — valid'
check "config.kap shows init description" \
"$KAPPA parse-config examples/config.kap" \
's6 — s6 supervision suite'
check "config.kap shows bootloader description" \
"$KAPPA parse-config examples/config.kap" \
'Limine — modern multiprotocol bootloader'
echo ""
echo "=== Doctor tests ==="
echo ""
check "doctor foo.kap" \
"$KAPPA doctor examples/foo.kap" \
'no issues found'
echo ""
echo "=== Validate tests ==="
echo ""
check "validate foo.kap" \
"$KAPPA validate examples/foo.kap" \
'valid package'
echo ""
echo "=== Rebuild tests ==="
echo ""
check "rebuild detects new packages" \
"$KAPPA rebuild examples/config.kap" \
'packages to rebuild'
echo ""
echo "=== Init system recognition tests ==="
echo ""
# Create temp config with each init system
for init in systemd openrc s6 runit dinit; do
cat > /tmp/test_init.kap << KAPEOF
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "$init"
root = "/dev/sda1"
bootloader = "limine"
}
users { root { shell = "/bin/sh" } }
KAPEOF
check "recognizes init=$init" \
"$KAPPA parse-config /tmp/test_init.kap" \
"$init"
check "doctor accepts init=$init" \
"$KAPPA doctor /tmp/test_init.kap 2>&1" \
""
done
# Test unknown init
cat > /tmp/test_badinit.kap << 'KAPEOF'
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "fakething"
root = "/dev/sda1"
bootloader = "limine"
}
users { root { shell = "/bin/sh" } }
KAPEOF
check "warns on unknown init" \
"$KAPPA doctor /tmp/test_badinit.kap 2>&1" \
'not a recognized init system'
echo ""
echo "=== Bootloader recognition tests ==="
echo ""
for bl in grub limine; do
cat > /tmp/test_bl.kap << KAPEOF
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "$bl"
}
users { root { shell = "/bin/sh" } }
KAPEOF
check "recognizes bootloader=$bl" \
"$KAPPA parse-config /tmp/test_bl.kap" \
"$bl"
done
echo ""
echo "=== Service generation tests ==="
echo ""
# Test that format output contains service fields for each backend type hint
check "service format includes type field" \
"$KAPPA format examples/foo.kap" \
'type = "forking"'
check "service format includes ports" \
"$KAPPA format examples/foo.kap" \
'ports = \['
check "service format includes description" \
"$KAPPA format examples/foo.kap" \
'description = "Foo web server"'
echo ""
echo "=== Groups DSL tests ==="
echo ""
cat > /tmp/test_groups.kap << 'KAPEOF'
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
groups {
wheel { gid = 998 }
audio {}
docker { gid = 995 }
}
users { root { shell = "/bin/sh" } }
KAPEOF
check "parse groups block" \
"$KAPPA parse-config /tmp/test_groups.kap" \
'valid'
check "format groups round-trips" \
"$KAPPA format /tmp/test_groups.kap" \
'wheel {'
echo ""
echo "=== Conflicts tests ==="
echo ""
cat > /tmp/test_conflict.kap << 'KAPEOF'
package "systemd" {
version = "255"
source = "https://example.com/systemd-255.tar.gz"
provides = ["udev", "logind"]
conflicts = ["eudev", "elogind"]
build { make }
install { make install }
}
KAPEOF
check "parse conflicts in package def" \
"$KAPPA parse-package /tmp/test_conflict.kap" \
'valid'
check "format shows conflicts" \
"$KAPPA format /tmp/test_conflict.kap" \
'conflicts ='
echo ""
echo "=========================================="
echo " Results: $PASS passed, $FAIL failed"
echo "=========================================="
[ "$FAIL" -eq 0 ] || exit 1