Compare commits

39 Commits
Author SHA1 Message Date
huntedbytheirs 70d59a3f9c comma
CI / build-and-test (push) Successful in 56s
2026-08-17 22:52:29 -04:00
huntedbytheirs d0ff52a5d7 test: add 7 tests for kappa bump and bump-all
CI / build-and-test (push) Successful in 42s
- 4 tests for kappa bump: version update, version written to file,
  source URL preserved, requires version arg
- 3 tests for kappa bump-all: finds newer version, reports count,
  requires directory arg

Total: 103 → 110 in test-full.sh (30+23+110 = 163 overall)
2026-08-05 07:58:35 -04:00
huntedbytheirs 1f61f116f8 feat: add kappa bump and kappa bump-all subcommands
CI / build-and-test (push) Successful in 37s
kappa bump <file> <version> — updates .kap file to new version.
Downloads tarball, computes sha256, rewrites file with new
version and hash. Preserves source URL with ${version} template.

kappa bump-all <dir> — scans directory of .kap files. For each,
auto-detects newer patch versions by incrementing the version
number and trying up to 15 candidates. Downloads and hashes the
first one that exists.
2026-08-05 07:55:08 -04:00
huntedbytheirs d918f7b6fc test: add 18 tests for 0.3 features
CI / build-and-test (push) Successful in 38s
- 8 tests: --features and --config flags (add, update, idempotent, file verification)
- 3 tests: transitive dependency resolution (auto-resolve chain)
- 4 tests: kappa search (name, substring, repo source, no-match)
- 3 tests: kappa upgrade (detection, version display, dry-run)

Total: 85 → 103 in test-full.sh (30+23+103 = 156 overall)
2026-08-05 07:42:59 -04:00
huntedbytheirs 01fcb20a57 feat: transitive dependency resolution
CI / build-and-test (push) Successful in 39s
Resolver now pulls in transitive dependencies automatically.
Declaring 'packages { root {} }' where root→mid→leaf resolves
all three packages in correct topological order. Previously
every package had to be listed explicitly in the config.

build_registry pre-populates from $KAPPA_ROOT/cache/packages/
so transitive deps can be found without remote fetching.
2026-08-05 07:39:36 -04:00
huntedbytheirs 33ff453cd1 feat: add kappa upgrade subcommand
CI / build-and-test (push) Successful in 39s
Compares installed packages against cached indexes to find newer
versions. kappa upgrade shows available upgrades and updates the
system config with new version constraints. --dry-run shows what
would change without applying.
2026-08-05 07:37:35 -04:00
huntedbytheirs b0b54be70e feat: add kappa search <query> subcommand
CI / build-and-test (push) Successful in 40s
Searches all cached indexes in $KAPPA_ROOT/cache/indexes/
for packages matching the query as a substring. Displays
name, version, and repo source (e.g. 'make 4.4.1 @ kappa-os/stable').
Offline — no network requests.
2026-08-05 07:36:06 -04:00
huntedbytheirs e69ebf220d feat: add --features and --config flags to kappa add
CI / build-and-test (push) Successful in 49s
kappa add nginx --features "ssl=on,gzip=off" --config "port=8080"
adds/updates feature flags and config values in the system config.
Comma-separated key=value pairs. Idempotent — only prints 'updated'
when something actually changed.
2026-08-05 07:34:12 -04:00
huntedbytheirs 7ba3fe6632 build: add test and clean targets to Makefile
CI / build-and-test (push) Successful in 38s
make test: builds then runs all three test suites (138 tests).
make clean: removes build, build-debug, build-release directories.

Finishing off 0.2.
2026-08-04 14:44:46 -04:00
huntedbytheirs 5607be0f1c docs: update README and CONTRIBUTING for v0.2 features
CI / build-and-test (push) Successful in 37s
README:
- Quick Start shows kappa add workflow instead of manual configs
- New features: indexed repos, env operators (+=, ?=), conflict reporting
- Updated subcommand table (add, remove, index, validate-index)
- Repo maintenance section (kappa index)
- Link to STYLEGUIDE.md, test counts, CI info

CONTRIBUTING:
- Updated test counts (30+23+85 instead of 31+23)
- Added test-full.sh to mandatory test suite
- Referenced STYLEGUIDE.md in PR requirements
- Updated good-first/ambitious issues for current state
2026-08-04 14:40:24 -04:00
huntedbytheirs fa1aa42a9c fix: remove duplicate conflicts test referencing uncreated file
CI / build-and-test (push) Successful in 43s
The 'format shows conflicts' test in the Package DSL section ran
before /tmp/test_conflict.kap was created (it's created later in the
Conflicts section). Removed the premature copy — the Conflicts
section already has this test after file creation.
2026-08-04 14:37:16 -04:00
huntedbytheirs 76b5a78e75 ci: use manual git checkout in Arch container
CI / build-and-test (push) Failing after 39s
actions/checkout requires Node.js, which isn't in archlinux:latest.
Manual git clone + checkout FETCH_HEAD instead. Adds git to pacman.
2026-08-04 14:34:45 -04:00
huntedbytheirs e3d41dcd20 ci: switch to Arch Linux container for modern toolchain
CI / build-and-test (push) Failing after 12s
Debian 11's libstdc++ (GCC 10) lacks <format> even with clang-18.
Arch ships clang-18 and the latest libstdc++ out of the box.
Container-based runner with pacman for dead-simple dependency install.
2026-08-04 14:32:58 -04:00
huntedbytheirs ac530e94a7 fix: install software-properties-common for llvm.sh on Debian
CI / build-and-test (push) Failing after 1m13s
Debian 11 (bullseye) doesn't have add-apt-repository by default.
llvm.sh needs it to add the LLVM apt repo.
2026-08-04 14:28:33 -04:00
huntedbytheirs 71d2eadcb9 fix: force clang-18 via LLVM script, prefer highest version
CI / build-and-test (push) Failing after 19s
<llvm.sh 18 all> installs clang-18 with full C++23 support.
sort -V ensures the highest clang version is selected when
multiple are installed. Removes -stdlib=libc++ (clang-18
provides <format> via its own header, no flag needed).
2026-08-04 14:27:13 -04:00
huntedbytheirs dc782e941c fix: install libc++ alongside clang for <format> support
CI / build-and-test (push) Failing after 39s
Clang 16 on Ubuntu 22.04 uses libstdc++ 12 which lacks <format>.
Install libc++-dev and pass -stdlib=libc++ to CMake.
2026-08-04 14:25:41 -04:00
huntedbytheirs 3302712336 fix: correct C++ compiler naming in CI (clang++-16 not clang-16++)
CI / build-and-test (push) Failing after 34s
Clang installs the C++ compiler as clang++-16, not clang-16++.
Use sed to properly transform the C compiler path.
2026-08-04 14:24:09 -04:00
huntedbytheirs 4a8b87e2cd fix: use find instead of ls for clang detection in CI
CI / build-and-test (push) Failing after 33s
ls returns exit code 2 when glob has no matches, which fails the
build step under set -e + pipefail. find returns 0 even when no
files are found.
2026-08-04 14:22:46 -04:00
huntedbytheirs 5668708fed fix: detect and install Clang 15-18 with LLVM fallback
CI / build-and-test (push) Failing after 30s
- Tries apt clang-18/17/16/15 first (Ubuntu 22.04+)
- Falls back to LLVM repo install for Ubuntu 20.04
- Auto-detects installed clang version for CMake compiler flags
- Both CMake 3.28 (binary download) and C++23 compiler required
2026-08-04 14:21:10 -04:00
huntedbytheirs 07a485a535 fix: install CMake 3.28 in CI (runner has 3.18, we need 3.20+)
CI / build-and-test (push) Failing after 26s
Download official CMake binary from Kitware releases instead of
relying on the distro package manager which provides an older version.
2026-08-04 14:17:52 -04:00
huntedbytheirs d10679d267 test: add 85 comprehensive tests and Gitea CI/CD workflow
CI / build-and-test (push) Failing after 56s
- test-full.sh: 85 tests covering CLI, DSL parsing (all keywords),
  config parsing, users/groups, assertions, imports, doctor diagnostics,
  repos/remotes, index generation, add/remove operations, install DB,
  resolver (conflicts + missing), scheduler (deptree ordering),
  build execution + error handling, env operators,
  init systems, bootloaders, variable interpolation, --root override,
  rollback config, system env, feature flags, rebuild dry-run

- .gitea/workflows/ci.yml: builds on Ubuntu with clang+cmake+ninja,
  runs all three test suites on push/PR to main

Total test count: 31 (DSL) + 23 (init-switch) + 85 (comprehensive) = 139
2026-08-04 14:15:50 -04:00
huntedbytheirs 09dde98159 fix: report package conflicts in resolve and rebuild handlers
Conflicts were detected in the resolver but silently ignored in both
the resolve and rebuild subcommands. Both now print conflict errors
and abort before any builds start.
2026-08-04 14:05:16 -04:00
huntedbytheirs 7345b026cc feat: add kappa add and kappa remove subcommands
- kappa add <pkg> [version] — adds/updates package in system config
  packages{} block; creates config if it doesn't exist

- kappa remove <pkg> — removes package from config; idempotent
  (no error if package isn't present)

- Supports --root flag for custom KAPPA_ROOT

- User workflow: kappa add make && kappa rebuild
  No manual config editing needed.
2026-08-04 14:00:51 -04:00
huntedbytheirs 7b0538c392 feat: add index-aware recipe resolution with repo support
- fetch_recipe_from_repos() iterates repos by priority, channels, with
  mirror fallback — caches indexes at /cache/indexes/

- Conditional HTTP downloads (curl -z) to only re-fetch stale indexes

- build_registry and fetch-package prefer repos { } over remotes = [...]
  when both are present

- Fallback chain: local .kap → repos (index-aware) → remotes (legacy)
2026-08-04 13:50:29 -04:00
huntedbytheirs 4f1ce17ec8 feat: add repos DSL, index format, and kappa index command
- Add repos { } block to system config with named repos, channels,
  mirrors, and priority

- Add index "name" { } file format for package indexes

- Add kappa index <dir> subcommand that scans .kap files and
  generates index.kap

- parse_index() and build_index() in DSL module

- format_index() roundtrip support in tools module

- validate and format subcommands handle all three formats
  (package, config, index) with automatic detection

- Backward compatible: remotes = [...] still works unchanged
2026-08-04 13:48:25 -04:00
huntedbytheirs 86f578bd4a feat: add GNU Make 4.4.1 package definition
Full autotools build (./configure, make, make check, make install)
verified end-to-end — builds, installs, produces working binary.
2026-08-04 13:29:31 -04:00
huntedbytheirs d02ab4b7b6 docs: update examples with += env operator and env usage docs
- foo.kap: document all three env operators (=, ?=, +=) in header,
  demonstrate += in env block with comments

- postgres.kap: demonstrate += for appending security hardening flags

- config.kap: demonstrate system-level += for inherited flags
2026-08-04 13:19:59 -04:00
huntedbytheirs e817e58698 feat: implement build backend and += env operator, add style guide
- Replace build stub with full phase execution (prepare/build/check/install)
  via /bin/sh with variable interpolation, config file generation, and
  env handling (hard-set, soft/?=, append/+=)

- Add += append operator for environment variables across DSL, parser,
  system config parser, build backend, and formatter

- Add STYLEGUIDE.md documenting all codebase conventions

- Replace EnvEntry bool soft with EnvMode enum (Set/Soft/Append)

- Add Plus token type to lexer for += parsing
2026-08-04 12:58:38 -04:00
huntedbytheirs 0aca4b49ca qol: implement easier building and distribution 2026-08-04 12:19:52 -04:00
huntedbytheirs 486a476b90 feat: wire rebuild pipeline (scheduler \u2192 build \u2192 install \u2192 services \u2192 bootloader)
The rebuild subcommand now executes the full pipeline:
- build_registry() -> resolve() -> sched::run()
- install::install() for each built package
- service::install_service() for enabled services
- boot::install_bootloader_config() for bootloader config
- system::write_hostname() / write_timezone() for system activation

Added --dry-run flag for report-only behavior (backward compat with
test suite). Added -w flag for worker parallelism and --dry-run to help.
Fixed .gitignore: 'kappa' -> '/kappa' (was matching include/kappa/,
preventing header files from being tracked since initial commit).
This commit also adds all previously-gitignored header files.
2026-07-31 08:38:53 -04:00
huntedbytheirs 7e93db2d07 feat: portability, correctness, and quality improvements for v0.2
Portability (Linux distro-agnostic):
- Remove hardcoded Clang compiler enforcement; GCC now builds
- Add find_package(Threads REQUIRED) for older glibc
- Add cmake install() target
- FHS 3.0 default root: /kappa -> /usr/local/kappa
- fs::path operator/ for all init/bootloader paths (fixes prefix fragility)
- Multi-distro zoneinfo search (FHS, NixOS, Guix, alt)
- Portable tar extraction (drop GNU-only --no-same-permissions)
- Runit enable/disable commands now prefix-aware
- --root CLI flag before/after subcommand, lazy directory creation
- Shebang constants de-duplicated to types.hpp

Correctness (race conditions, UB, corruption):
- Fix CWD race in scheduler: per-child chdir() instead of process-global
- Fix UB const_cast in exec_cmd/exec_capture: mutable argv buffers
- Fix non-atomic installed DB writes: tmp+rename pattern
- Fix read_file() no longer calls exit(1), throws instead
- Fix silent catch(...) parse errors now print diagnostics
- Fix rebuild false positives with config_hash change detection
- Fix s6 disable_cmd copy-paste bug (was identical to enable)
- Fix runit enable_cmd incomplete, disable_cmd wrong target
- Fix dinit env vars: functional env-file + companion .env

Quality:
- Add -Wall -Wextra -Wpedantic to CMake, fix 2 pre-existing warnings
- Move parse_int from error.hpp to parse_util.hpp
- Fix hash verification guard checks all three hash types
- Check patch return code in fetch.cpp
- Add explicit system_dir creation in ensure_directories()
- Add resolve to needs_dirs for build_registry() consistency
- Update stale /kappa path references in examples
- Remove inaccurate -Werror claim in CONTRIBUTING.md
- Add build-gcc/ and agent dirs to .gitignore
- Suppress clang-tidy portability-avoid-pragma-once
- Fix .gitignore /kappa pattern (was matching include/kappa/)
- Delete stale vcpkg_installed/ directory

54/54 tests pass. Builds on Clang and GCC with 0 warnings.
2026-07-31 08:28:49 -04:00
huntedbytheirs dd984f96d4 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
2026-07-31 05:32:57 -04:00
huntedbytheirs d6612d0a4a fix: rebuild review — dead include, service detection, version compare
- Removed unused <format> include from rebuild.cpp
- Service change detection simplified: any enabled service flags change
  (services aren't stored as DB entries, so element-wise compare impossible)
- Version comparison: checks if version/features/config are non-empty
  in PackageRef (conservative — rebuilds if any constraint is specified)
- Empty config with unchanged packages correctly reports 'nothing to rebuild'
2026-07-30 05:56:17 -04:00
huntedbytheirs 7712f88386 feat: rebuild, list, rollback — Phase 5 completion
- kappa rebuild: diffs config vs installed DB, reports changed/added/removed
  packages, detects kernel/init/bootloader/service changes
- kappa list: shows installed packages with versions, hashes, provides
- kappa rollback: lists available generations from /kappa/db/generations
- list + rollback don't require a file argument (no-file subcommands)
- Rebuild wired to compute_changes() → per-package change detection

Full pipeline: resolve → fetch → build → install → list/rollback/rebuild
2026-07-30 05:53:00 -04:00
huntedbytheirs c276465f3a fix: CWD restore, install error checks, fetch EINTR
- build.cpp: save/restore CWD on both success and failure paths
- install.cpp: check error_code after each filesystem op, abort on failure
- install.cpp: check write_installed() return value
- fetch.cpp: EINTR retry loop on both waitpid calls (exec_cmd + exec_capture)
2026-07-30 05:31:59 -04:00
huntedbytheirs 6c993d2d17 fix: Phase 3+4 review — deterministic hash, EINTR, env restore, installer wiring
- std::hash→FNV-1a (deterministic across runs, null-byte-separated)
- waitpid EINTR retry loop (prevents zombie processes under signals)
- setenv save/restore between builds (prevents cross-package env leaks)
- install::install() wired into kappa build CLI (store + DB + generations)
- Zero-padded generation filenames (gen-0001, lexicographic sort correct)
- Hardcoded paths fixed: /tmp/kappa-build→paths::temp_dir()/build
- Hardcoded destdir: /kappa/temp/destdir→paths::temp_dir()/destdir
- ensure_directories() called at main() startup
- build subcommand: -j N, --root wired end-to-end
2026-07-30 05:14:51 -04:00
huntedbytheirs d7fc9d45fb fix: fetcher security — fork+execve, hash verification, tar safety, CLI --help
B1: Replaced all std::system()/popen() with fork+execve — zero shell injection
B2+B3: Added sha256/sha512/md5 fields to PackageDef, parser support, actual hash comparison in verify_hash()
B4: tar --no-same-owner --no-same-permissions, zip uses unzip instead of tar
B5: Patch sha256 verified before application via -i flag (no shell redirect)
B6: kappa fetch --help now prints usage and exits 0
Also: removed dead ternary code, added <cstdlib>/<sys/wait.h>/<unistd.h>
2026-07-30 03:59:15 -04:00
huntedbytheirs 72e582eb15 fix: oracle review fixes — resolver exit code, dead include, pointer compose
- Exit code: plan.missing.empty() ? 0 : 1 (was always returning 0)
- Removed dead <algorithm> include from plan.cpp
- BuildStep now composes config::ResolvedPackage instead of duplicating
  features/config fields; PackageDef stored as const pointer
2026-07-30 00:47:50 -04:00
huntedbytheirs ee9f280346 feat: dependency resolver (Phase 1) + formatter + doctor + assertion evaluation
Resolver (Phase 1):
- resolve::resolve() takes SystemConfig + Registry → BuildPlan
- Three-layer feature/config merge (uses config::resolve_package)
- Feature-gated dependencies: deps with feature=X skipped if feature disabled
- Topological sort via Kahn's algorithm (BFS on in-degree)
- Cycle detection, missing package warnings
- CLI: kappa resolve <config> [<package>]

Formatter:
- format_package() + format_config() → canonical output
- Consistent 4-space indent, canonical declaration order

Doctor:
- check_package() + check_config() → warnings for common issues
- Missing fields, empty configs, root shell, feature warnings

Assertion evaluation:
- evaluate_assertions() resolves dotted field paths
- Supports == and != operators for config validation
- kappa validate now runs assertion checks

Review fixes:
- plan.steps.empty() exit code corrected to 0
- Removed unused <unordered_set> include
- Eliminated duplicate merge logic (uses config::resolve_package)
2026-07-30 00:36:38 -04:00
70 changed files with 8595 additions and 145 deletions
+1
View File
@@ -6,6 +6,7 @@ Checks: >
portability-*,
readability-*,
-modernize-use-trailing-return-type,
-portability-avoid-pragma-once,
-readability-identifier-length,
-readability-magic-numbers,
-readability-identifier-naming
+39
View File
@@ -0,0 +1,39 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
container: archlinux:latest
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm
pacman -S --noconfirm clang cmake ninja git
- name: Checkout
run: |
git clone --depth 1 "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
git fetch origin "$GITHUB_REF"
git checkout FETCH_HEAD
- name: Build kappa
run: |
cmake -B build -G Ninja \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++
cmake --build build
- name: DSL and CLI tests
run: ./test.sh
- name: Init-switching tests
run: ./test-init-switch.sh
- name: Comprehensive tests
run: ./test-full.sh
+11 -2
View File
@@ -33,8 +33,17 @@
*.app
# Build
build/
/build/
/build-debug/
/build-release/
/dist/
build-gcc/
compile_commands.json
vcpkg_installed/
kappa
/kappa
# Agent session state
.omo/
.logs/
.opencode/
+29 -4
View File
@@ -1,11 +1,9 @@
cmake_minimum_required(VERSION 3.20)
# Enforce Clang
set(CMAKE_C_COMPILER clang)
set(CMAKE_CXX_COMPILER clang++)
project(kappa VERSION 0.1.0 LANGUAGES CXX)
find_package(Threads REQUIRED)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
@@ -21,6 +19,7 @@ endif()
add_executable(kappa
src/main.cpp
src/util.cpp
src/paths.cpp
src/dsl/lexer.cpp
src/dsl/parser.cpp
@@ -28,5 +27,31 @@ add_executable(kappa
src/eval/vars.cpp
src/cli/diagnostic.cpp
src/config/merge.cpp
src/config/eval.cpp
src/tools/format.cpp
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)
target_link_libraries(kappa PRIVATE Threads::Threads)
target_compile_options(kappa PRIVATE -Wall -Wextra -Wpedantic)
install(TARGETS kappa RUNTIME DESTINATION bin)
+145
View File
@@ -0,0 +1,145 @@
# 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 zero warnings. 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.
Integration tests live in three suites. If you add a subcommand, add a test.
```sh
./test.sh # 30 tests — DSL parsing, config, services, init systems
./test-init-switch.sh # 23 tests — init-switching, rebuild impact, bootloaders
./test-full.sh # 85 tests — CLI, all keywords, repos, add/remove,
# resolver conflicts, deptrees, build, env ops,
# index generation, --root, imports, doctor, and more
```
All three must pass. CI enforces this on every push to `main`.
### 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 (we have 5: systemd, openrc, s6, runit, dinit)
- Adding a 3rd bootloader backend (we have 2: grub, limine)
- Improving the `kappa doctor` diagnostics for package recipes
- Adding `--features` / `--config` flags to `kappa add`
- Shell completion scripts (bash, zsh, fish)
Ambitious issues:
- Binary package support (pre-built caches)
- Remote build farm (distcc-style)
- Signed package verification with index signing
- Filesystem overlay activation (like Nix profiles)
- Transitive dependency resolution (auto-including deps not in config)
### 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 && ./test-full.sh` — all three must pass
5. Open a PR against `main`
### PR requirements
- Build must pass: `cmake --build build` with zero warnings on Clang 17+
- Tests must pass: all three shell test suites
- Follow the conventions in [STYLEGUIDE.md](STYLEGUIDE.md)
- 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"]
+72
View File
@@ -0,0 +1,72 @@
# kappa — build convenience Makefile
#
# Targets:
# build — default build (build/)
# debug — Debug build (build-debug/)
# release - Release build (build-release/)
# dist - tar.zst tarball of the release build (requires zstd)
# test — build then run all three test suites
# clean - remove all build directories
# dist-clean - remove everything under dist/
#
# Overridable: make release CXX=g++ GENERATOR="Unix Makefiles"
CC ?= clang
CXX ?= clang++
GENERATOR ?= Ninja
BUILD_DIR ?= build
DEBUG_DIR ?= build-debug
RELEASE_DIR ?= build-release
DIST_DIR ?= dist
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
GIT_TAG := $(shell git describe --tags --abbrev=0 2>/dev/null || echo 0.1.0)
STAMP := $(shell date +%Y-%M)
DIST_NAME := kappa-$(GIT_COMMIT)-$(GIT_TAG)-$(STAMP)
CMAKE := cmake
.PHONY: build debug release dist test clean dist-clean
build:
$(CMAKE) -S . -B $(BUILD_DIR) -G $(GENERATOR) \
-DCMAKE_C_COMPILER=$(CC) -DCMAKE_CXX_COMPILER=$(CXX)
$(CMAKE) --build $(BUILD_DIR)
debug:
$(CMAKE) -S . -B $(DEBUG_DIR) -G $(GENERATOR) \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_COMPILER=$(CC) -DCMAKE_CXX_COMPILER=$(CXX)
$(CMAKE) --build $(DEBUG_DIR)
release:
$(CMAKE) -S . -B $(RELEASE_DIR) -G $(GENERATOR) \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=$(CC) -DCMAKE_CXX_COMPILER=$(CXX)
$(CMAKE) --build $(RELEASE_DIR)
dist: release
rm -rf $(DIST_DIR)/$(DIST_NAME)
$(CMAKE) --install $(RELEASE_DIR) --prefix $(DIST_DIR)/$(DIST_NAME)
tar --zstd -cf $(DIST_DIR)/$(DIST_NAME).tar.zst -C $(DIST_DIR) $(DIST_NAME)
rm -rf $(DIST_DIR)/$(DIST_NAME)
@echo "dist: $(DIST_DIR)/$(DIST_NAME).tar.zst"
dist-clean:
rm -rf $(DIST_DIR)
test: build
@echo "==> test.sh"
@./test.sh
@echo ""
@echo "==> test-init-switch.sh"
@./test-init-switch.sh
@echo ""
@echo "==> test-full.sh"
@./test-full.sh
@echo ""
@echo "all test suites passed"
clean:
rm -rf $(BUILD_DIR) $(DEBUG_DIR) $(RELEASE_DIR)
+201 -1
View File
@@ -1,3 +1,203 @@
<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 with indexed repos.** Declare named repos with
channels and mirrors in your config. Kappa fetches `index.kap` from each
repo, caches it, and only re-fetches when the remote changes. Package recipes
are resolved from the index — fast, offline-tolerant, and mirror-aware.
```kap
repos {
kappa-os {
url = "https://packages.kappa-os.org/"
channels = ["stable"]
mirrors = [
"https://cdn.kappa-os.org/",
"https://eu.kappa-os.org/",
]
}
}
```
`remotes = [...]` still works. Repos are tried first, then legacy remotes.
- **Source tarball caching.** Downloaded once, stored at `$KAPPA_ROOT/cache/`
(default: `/usr/local/kappa/cache/`). Rebuilds don't touch the network
unless versions change.
- **Env operators.** Three ways to set build environment variables:
`=` (hard set), `+=` (append with space), `?=` (soft set — only if not
already defined). System-level env propagates to all packages.
```kap
env {
CFLAGS = "-O2 -march=native" # overwrite
CFLAGS += "-pipe" # append → "-O2 -march=native -pipe"
CFLAGS ?= "-g" # soft — only if not set
}
```
- **Conflicts.** `systemd` declares `conflicts = ["eudev", "elogind"]`. The
resolver catches mutual incompatibility before a build starts — and now
actually reports it, rather than silently ignoring it.
- **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
> **Note for 0.1.x users**: The default store root has moved from `/kappa` to
> `/usr/local/kappa` (FHS 3.0). Set `KAPPA_ROOT` to your existing `/kappa`
> directory to keep using the old location.
```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
# Add packages to your system config
build/kappa add make
build/kappa add nginx ">=1.24"
build/kappa add zlib
# Rebuild — kappa fetches recipes, resolves deps, builds everything
build/kappa rebuild $KAPPA_ROOT/system/config.kap
# Remove packages
build/kappa remove zlib
```
No config files to write by hand. `kappa add` writes the `packages {}` block
for you. `kappa rebuild` handles the rest.
### Subcommands
| Command | What it does |
|---------|-------------|
| `add <pkg> [version]` | Add a package to system config |
| `remove <pkg>` | Remove a package from system config |
| `build <package>` | Build a single package from its `.kap` definition |
| `rebuild <config>` | Diff config against installed state, rebuild changed |
| `resolve <config>` | Compute a build plan (shows order, deps, conflicts) |
| `doctor <file>` | Check a file for issues and warnings |
| `fetch <package>` | Download and verify source tarballs |
| `fetch-package <name>` | Fetch a package recipe from remotes |
| `format <file>` | Pretty-print to canonical style |
| `index <dir>` | Build an `index.kap` from `.kap` files in a directory |
| `list` | Show installed packages |
| `parse-config <file>` | Validate a system configuration |
| `parse-package <file>` | Validate a `.kap` package definition |
| `rollback` | Show available generations |
| `validate <file>` | Validate any kappa file (package, config, or index) |
### Repo maintenance
```sh
# Generate an index from a directory of .kap files
kappa index ./packages/
# → packages/index.kap
# Host the directory behind any HTTP server. That's your repo.
```
The index is a tiny text file listing every package and version.
Clients fetch it once, cache it, and check for updates via HTTP headers.
### 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. See [STYLEGUIDE.md](STYLEGUIDE.md) for code conventions.
Tests: 138 integration tests across three suites. CI runs on Arch Linux.
Everything passes or nothing merges.
+484
View File
@@ -0,0 +1,484 @@
# Style Guide
If you're reading this because you thought kappa's C++ looked different from
what you're used to — good. That's the point.
This isn't a suggestion box. It's what the codebase looks like, and it's what
your code will look like after you've rewritten it three times because the PR
reviewer sent it back. Save yourself the rewrite. Read this first.
---
## The Philosophy
We write C++ like it's the year 2026 and the committee finally shipped
something usable. No polyfills. No third-party libraries. No Boost. The
standard library is sufficient for a package manager. If you disagree, you
haven't read `<format>` closely enough.
Every line of kappa assumes the reader is competent. We don't explain what
`std::string_view` is. We don't annotate obvious control flow. Comments exist
to explain *why*, never *what*. If your code needs a comment to be
understood, the code is wrong.
Simplicity is a moral position. The scheduler is the hardest thing in this
codebase, and it's 290 lines. If your feature adds more than that, you're
building the wrong feature.
---
## Naming
### Structs, classes, enums
```cpp
// PascalCase. Always.
struct BuildResult { };
enum class TokenType : std::uint8_t { };
// Enum values are PascalCase too. This isn't Java.
enum class EnvMode : std::uint8_t { Set, Soft, Append };
// Acronyms stay capitalized. B-Tree is BTree, not Btree.
// Two-letter acronyms stay capitalized. ID, not Id.
```
Type names state what the thing *is*, not what it's *for*. `SchedResult`, not
`ResultForScheduler`. `InitPaths`, not `PathsForInitSystems`.
### Variables and functions
```cpp
// snake_case. No Hungarian notation. No m_ prefix. No s_ prefix.
int pending_deps = 0;
void compute_depths(Scheduler& s);
std::string_view token_name(TokenType type);
```
Member variables and locals look identical. If you can't tell them apart,
your functions are too long. Fix the function.
### Files
```
src/service/openrc.cpp # snake_case, lowercase
include/kappa/service/types.hpp # .hpp for headers, .cpp for source
```
One public class per header is a myth invented by Java developers. Group
related declarations. `types.hpp` holds all enums and structs for a module.
If a module has one public struct and one public function, they go in the
same header.
---
## Formatting
### Indentation and braces
Four spaces. Attached braces (a K&R variant).
```cpp
// ✓ yes — brace on the same line as the control structure
if (pid < 0) {
return -1;
}
// ✗ no — Allman/BSD braces on their own line
if (pid < 0)
{
return -1;
}
// ✗ no — missing braces on single-statement bodies
if (pid < 0) return -1;
```
Always braces. Even for single statements. The compiler doesn't care.
The human reviewing your diff at 2 AM does. clang-tidy will flag bare
bodies — apply the fix every time.
### Line length
100 columns. Not 80 — we're not teletypes. Not 120 — if you need 120
characters to express a thought, your thought is too complicated. Split it.
### Section separators
```cpp
// --- Section description ---
// or
// ---------------------------------------------------------------------------
// Longer section description spanning the full runway
// ---------------------------------------------------------------------------
```
Thin lines (`---`) for sub-sections within a file. Thick lines
(`-----------`) for top-level section boundaries. The difference communicates
hierarchy without nesting.
### Switch cases
```cpp
switch (is) {
case InitSystem::Systemd:
return generate_systemd_service(spec);
case InitSystem::S6:
return generate_s6_service(spec);
case InitSystem::Unknown:
default:
return {};
}
```
Cases at the same indentation as the switch. No blocks around single-return
cases. Break or return in every non-fallthrough case. If you're falling
through intentionally, wrap it with `[[fallthrough]];` on a line by itself.
---
## Types
### Use `auto` when the type is obvious, explicit when it isn't
```cpp
// ✓ yes — type is obvious from initialization
auto scope = eval::make_default_scope();
auto& pkg = registry.at(name);
// ✓ yes — structured bindings, type is obvious
for (auto& [key, val] : features) { }
// ✓ yes — explicit where the type carries meaning
std::unique_lock lock(s.mtx); // not auto lock = ...
std::uint64_t h = 14695981039346656037ULL; // not auto h = ...
```
### `const` is the default
Everything is `const` until proven mutable.
```cpp
// ✓ yes
const auto& step = plan.steps[i];
for (const auto& entry : entries) { }
// ✗ no — mutable when it shouldn't be
auto& step = plan.steps[i];
```
### View types over owning types in parameters
```cpp
// ✓ yes
void set_root(std::string_view path);
bool is_supported(std::string_view name);
// ✗ no
void set_root(const std::string& path);
```
Return owning types. Accept views. The caller decides ownership. You decide
what you need to read.
### Strong enums only
```cpp
// ✓ yes
enum class InitSystem : std::uint8_t { Systemd, OpenRC, S6 };
// ✗ no
enum InitSystem { INIT_SYSTEMD, INIT_OPENRC, INIT_S6 };
```
No unscoped enums. No ALL_CAPS enum values. No integer conversions without
explicit intent. If you need to serialize an enum to an integer, write a
`to_string` function. The enum's numeric value is an implementation detail,
not an interface.
---
## Functions
### One responsibility per function
If your function name contains the word "and", it does at least two things
and needs to be split. If the body doesn't fit on one screen, it does too
much. "One screen" means approximately 30 lines. The scheduler's
`compute_depths` is 30 lines. So is the resolver's `resolve`. They're at the
upper bound. If yours is longer, you're doing something wrong.
### Error handling: return, don't throw
```cpp
// ✓ yes
struct FetchResult {
std::filesystem::path work_dir;
std::string error;
bool ok() const { return error.empty(); }
};
FetchResult fetch(const PackageDef& pkg);
// ✗ no
void fetch(const PackageDef& pkg); // throws on error
```
Exceptions are for unrecoverable programmer errors — out-of-memory, null
dereference, violated invariants. They belong in constructors and in the
parser (where `ParseError` is the only way to unwind back to diagnostics).
Every operational failure — network down, disk full, configure script failed,
hash mismatch — is a return value. A struct with `bool ok` and
`std::string error`. Check the `ok` field, read the `error` string, don't
catch exceptions for normal operation.
### Return early, return often
```cpp
// ✓ yes
if (to.empty()) {
return {false, "destination is empty"};
}
// ... main logic ...
// ✗ no
if (!to.empty()) {
// ... 40 lines of nesting ...
} else {
return {false, "destination is empty"};
}
```
Guard clauses at the top. Happy path straight down the left margin. If your
code has three levels of nesting, you missed an early return opportunity.
### Static helpers over lambdas
If a helper is more than 5 lines, extract it to a file-static function above
the public API. Named functions are greppable. Named functions show up in
stack traces. Lambdas don't. The one exception is a `run_phase` lambda in
`build()` — it captures local state that would require a 5-parameter helper
and it's clearly a one-off control flow wrapper, not a reusable abstraction.
---
## Namespaces
```cpp
namespace kappa::module {
// Everything goes here.
} // namespace kappa::module
```
C++17 nested namespace syntax. Closing brace gets a comment with the
namespace name. These comments survive diffs, refactors, and editors that
collapse braces. They cost one line and save ten minutes of scrolling up to
figure out which brace closes what.
### No `using namespace` at file scope
```cpp
// ✓ yes — inside a function
namespace fs = std::filesystem;
// ✗ no — at file scope
using namespace std;
```
Namespace aliases are acceptable inside functions — `namespace fs =
std::filesystem;` is fine when the file does a lot of path manipulation. But
at file scope? No. You're not writing `using namespace std;` at the top of a
header and you're not doing the subtler version of the same sin.
---
## Headers
```cpp
#pragma once
#include "kappa/resolve/plan.hpp"
#include "kappa/dsl/ast.hpp"
#include <string>
#include <vector>
namespace kappa::build {
struct BuildResult { };
BuildResult build(const resolve::BuildStep& step,
const std::string& work_dir,
int jobs);
} // namespace kappa::build
```
`#pragma once` at the top. No include guards. This is 2026.
Project headers first, in quotes. System headers second, in angle brackets.
Blank line between the two groups. Alphabetical within each group.
Headers include only what they need to compile. If `build.hpp` uses
`resolve::BuildStep` by reference, it includes `resolve/plan.hpp`. It does
not forward-declare `BuildStep` — we don't forward-declare across module
boundaries. The include is the contract: "this module depends on that one."
Headers never contain implementation. No `inline` functions. No
template definitions in headers (we don't use templates). The one exception
is `parse_util.hpp`, which defines `ParseError` inline because it's a thin
exception wrapper and splitting it would be ceremony for ceremony's sake.
One exception per codebase is a pattern. Two is a problem.
---
## Modules
Every module follows this structure:
```
include/kappa/{module}/
├── types.hpp # enums, structs, parse/validate declarations
├── {feature}.hpp # public function declarations
src/{module}/
├── types.cpp # parse/validate/describe implementations
├── backend_a.cpp # per-variant generation (if applicable)
├── backend_b.cpp
└── install.cpp # dispatch + orchestration (if applicable)
```
If a module doesn't need `types.hpp` (single struct, single function), both
go in `{feature}.hpp`. If a module has no backends, skip them. But don't
invent a third pattern. `service/` and `boot/` are the templates. Copy them.
---
## Strings and formatting
```cpp
// ✓ yes
auto msg = std::format("building {} (depth={})", name, depth);
result.error = std::format("command exited with code {}: {}", rc, cmd);
// ✗ no — ostringstream for trivial concatenation
std::ostringstream oss;
oss << "building " << name << " (depth=" << depth << ")";
// ✓ yes — ostringstream for incremental construction
std::ostringstream out;
out << "[Unit]\n";
out << std::format("Description={}\n", desc);
```
`std::format` for one-shot strings. `std::ostringstream` for building up
output incrementally (service files, bootloader configs, formatter output).
String concatenation with `+` is acceptable for two or three pieces.
Anything more goes through `std::format`.
### String views for parameters
```cpp
// ✓ yes
InitSystem parse_init_system(std::string_view name);
void print_error(std::ostream& os, std::string_view source,
SourceLocation loc, std::string_view message);
// ✗ no
InitSystem parse_init_system(const std::string& name);
```
Views everywhere, except when you need to store the string.
---
## The DSL
The `.kap` DSL grammar is the contract. You can extend it. You cannot break
existing configs. Every new token type requires:
1. An entry in `TokenType`
2. A case in `token_name()`
3. Parsing logic in the appropriate parser
4. A formatting case in `format.cpp`
5. At least one test in `test.sh` that exercises the new syntax
If you're adding a keyword, think twice. The lexer already has 29 token
types. Every new one increases parse time and mental overhead. Can this be
expressed with the existing grammar? If yes, don't add a keyword.
---
## Thread safety
The scheduler is multithreaded. If you touch shared state, you own the lock.
```cpp
{
std::unique_lock lock(s.mtx);
s.waiting.erase(idx);
}
// lock released here — no shared state access beyond this point
```
Use scoped locks. Never lock/unlock manually. Never hold a lock across a
condition variable wait without understanding why. If you think you need
`memory_order_release`, you probably need `memory_order_acq_rel` and you
should document why in a three-line comment above the operation.
If a data structure is touched by multiple threads, its access pattern must
be documented at the declaration site, not in a PR description. "This is
only written under the lock, read atomically elsewhere" goes in the header.
---
## What clang-tidy enforces
We run with `-Wall -Wextra -Wpedantic` and a `.clang-tidy` config. Zero
warnings. Not "zero warnings except for that one file." Zero.
The following are non-negotiable:
- Every `if`/`for`/`while` body has braces
- `auto` variables that are never modified are `const auto`
- Variables are initialized at declaration
- No unused includes
- No redundant declarations
If clang-tidy suggests a fix and you disagree, you're wrong. Apply the fix.
The only acceptable override is `// NOLINT` with a justification comment —
and if you write that more than twice in a file, the reviewer will ask you to
rethink your design.
---
## What we reject
- **Comments that narrate the code.** `// Increment counter` above `i++`
is an insult. Delete it.
- **Dead code.** No commented-out blocks. No `#if 0`. If it's not used,
it doesn't exist. Git remembers.
- **Premature abstraction.** Three identical lines do not need a
function. Ten do. The threshold is somewhere in between and you should
err on the side of duplication.
- **C heritage.** `printf`, `malloc`, `NULL`, raw `char*` strings,
`#define` constants. The 1970s called. Don't answer.
- **Over-engineering.** The build backend doesn't need a plugin
architecture. The lexer doesn't need a state machine framework.
Solve the problem in front of you, not the one you imagine someone
might have in three years.
- **Cleverness.** If your solution makes you feel smart, it's wrong.
The best code is the code you forget about because it never breaks.
---
Kappa does one thing: build your system from source, init-agnostically.
Everything in this style guide exists to keep that codebase small, fast, and
comprehensible. If a rule conflicts with that goal, the goal wins — but
you'd better have a good reason, and you'd better write it down.
+1 -1
View File
@@ -1,6 +1,6 @@
/*
* Per-package override for foo.
* Lives at /kappa/system/builds/foo.kap
* Lives at /usr/local/kappa/system/builds/foo.kap
*
* Uses system config syntax — only features/config need to be specified.
* The rest inherits from the package definition and system config.
+53 -2
View File
@@ -1,10 +1,55 @@
/*
* Kappa system configuration.
* Lives at /kappa/system/config.kap
* Lives at /usr/local/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 != ""
@@ -22,6 +67,8 @@ system {
env {
CFLAGS = "-O2 -march=native"
// packages inherit this appended flag
CFLAGS += "-pipe"
LDFLAGS = "-Wl,--as-needed"
MAKEFLAGS = "-j8"
}
@@ -56,7 +103,7 @@ packages {
/*
// Per-package overrides can also live in
// /kappa/system/builds/<name>.kap
// /usr/local/kappa/system/builds/<name>.kap
*/
}
@@ -75,11 +122,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"
}
+77 -10
View File
@@ -1,13 +1,71 @@
/*
* 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.
*
*
* ENV OPERATORS
* =============
* key = "val" hard set — overwrites any existing value
* key ?= "val" soft set — only applied if key is not already set
* key += "val" append — adds to existing value with a space separator
*
* Order matters. Subsequent entries in the same env block can append to
* earlier ones. System-level env (from config.kap) is applied before
* package env, so packages can += to flags set globally.
*/
package "foo" {
const version = "1.2.3"
const source = "https://example.com/foo-${version}.tar.gz"
sha256 = "e127a709cba24c76de8936cb7083dd768f28cd37eb010492e2f19b71eb1294e4"
license = "MIT"
provides = ["libfoo.so.1", "foo"]
conflicts = [] // packages this cannot coexist with (e.g. ["eudev"] if this were systemd)
patches = [
{
@@ -54,23 +112,28 @@ package "foo" {
}
env {
// hard set — overwrites any existing CFLAGS
CFLAGS = "-O2 -march=native"
// append — now CFLAGS is "-O2 -march=native -pipe"
CFLAGS += "-pipe"
LDFLAGS = "-Wl,--as-needed"
// soft set — only added if CFLAGS isn't already defined
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]
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 {
@@ -89,4 +152,8 @@ package "foo" {
install {
make DESTDIR=${destdir} install
}
uninstall {
make -C build uninstall
}
}
+5
View File
@@ -0,0 +1,5 @@
index "local" {
foo { version = "1.2.3" }
postgresql { version = "16.3" }
make { version = "4.4.1" }
}
+38
View File
@@ -0,0 +1,38 @@
/*
* GNU Make — the build tool that builds everything else.
*
* This is the quintessential autotools package: ./configure, make, make install.
* If kappa can build this, it can build anything downstream.
*
* INIT-AGNOSTIC
* =============
* Make itself doesn't care about init systems — no service definitions,
* no boot requirements. It's a pure build tool.
*/
package "make" {
const version = "4.4.1"
const source = "https://ftp.gnu.org/gnu/make/make-${version}.tar.gz"
sha256 = "dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3"
license = "GPL-3.0-or-later"
provides = ["make", "gmake"]
env {
CFLAGS = "-O2"
CFLAGS += "-Wno-error"
}
build {
touch aclocal.m4 configure Makefile.in */Makefile.in
./configure --prefix=${prefix}
make -j${jobs}
}
check {
make check
}
install {
make DESTDIR=${destdir} install
}
}
+150
View File
@@ -0,0 +1,150 @@
/*
* 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"
// append security hardening flags
CFLAGS += "-D_FORTIFY_SOURCE=2"
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 != ""
}
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <string>
#include <string_view>
#include "kappa/boot/types.hpp"
namespace kappa::boot {
struct BootSpec {
std::string kernel_path; // e.g. "/kappa/boot/kernel"
std::string init_path; // e.g. "/kappa/boot/init" (the symlink, stable across init swaps)
std::string root; // e.g. "/dev/sda1" or "PARTUUID=xxx"
std::string kernel_params; // extra kernel cmdline params
std::string init_prev; // previous init symlink for fallback entry; empty = no fallback
};
std::string generate_bootloader_config(Bootloader bl, const BootSpec& spec);
std::string generate_limine_config(const BootSpec& spec);
std::string generate_grub_config(const BootSpec& spec);
struct BootloaderInstallResult {
bool ok;
std::string path;
std::string error;
};
BootloaderInstallResult install_bootloader_config(Bootloader bl,
const BootSpec& spec,
std::string_view prefix = "/");
} // namespace kappa::boot
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace kappa::boot {
enum class Bootloader : std::uint8_t {
Grub,
Limine,
Unknown
};
Bootloader parse_bootloader(std::string_view name);
std::string_view to_string(Bootloader bl);
bool is_supported(std::string_view name);
std::vector<Bootloader> all_bootloaders();
std::string_view bootloader_description(Bootloader bl);
struct BootloaderPaths {
std::string config_path;
std::string install_cmd;
};
BootloaderPaths bootloader_paths(Bootloader bl, std::string_view prefix = "/");
} // namespace kappa::boot
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "kappa/resolve/plan.hpp"
#include <string>
namespace kappa::build {
struct BuildResult {
bool ok = false;
std::string phase; // phase that failed, e.g. "prepare" or "build"
std::string error;
};
// Build a single package step into work_dir.
// Runs prepare, build, check, and install phases in order,
// with full variable interpolation and config file generation.
BuildResult build(const resolve::BuildStep& step,
const std::string& work_dir,
int jobs);
} // namespace kappa::build
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "kappa/dsl/ast.hpp"
#include "kappa/dsl/system.hpp"
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace kappa::config {
struct AssertFailure {
std::string message;
std::string field;
std::string expected;
std::string actual;
};
std::string resolve_field(const dsl::SystemConfig& cfg, std::string_view path);
std::vector<AssertFailure> evaluate_assertions(const dsl::SystemConfig& cfg);
std::unordered_map<std::string, dsl::NamedService> resolve_services(
const dsl::SystemConfig& cfg,
const std::unordered_map<std::string, dsl::PackageDef>& packages);
} // namespace kappa::config
+26 -3
View File
@@ -1,5 +1,6 @@
#pragma once
#include <cstdint>
#include <string>
#include <unordered_map>
#include <unordered_set>
@@ -38,18 +39,25 @@ struct Patch {
int level = 1;
};
enum class EnvMode : std::uint8_t { Set, Soft, Append };
struct EnvEntry {
std::string key;
std::string value;
bool soft = false;
EnvMode mode = EnvMode::Set;
};
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 {
@@ -63,21 +71,36 @@ struct PackageDef {
std::string name;
std::string version;
std::string source;
std::string sha256;
std::string sha512;
std::string md5;
std::string license;
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;
};
struct IndexEntry {
std::string name;
std::string version;
};
struct IndexDef {
std::string name; // "kappa-os/stable"
std::vector<IndexEntry> packages;
};
} // namespace kappa::dsl
+17 -22
View File
@@ -1,33 +1,28 @@
#pragma once
#include "kappa/dsl/parse_util.hpp"
#include <format>
#include <stdexcept>
#include <string>
#include <string_view>
namespace kappa::dsl {
class ParseError : public std::runtime_error {
public:
ParseError(int line, int col, const std::string& msg)
: std::runtime_error(std::format("{}:{}: {}", line, col, msg)) {}
};
inline std::string msg_expected(std::string_view expected, std::string_view got) {
return std::format("expected {}, got '{}'", expected, got);
}
inline int parse_int(int line, int col, const std::string& lexeme) {
try {
std::size_t pos = 0;
int val = std::stoi(lexeme, &pos);
if (pos != lexeme.size()) {
throw ParseError(line, col,
std::format("expected integer, got '{}'", lexeme));
}
return val;
} catch (const std::invalid_argument&) {
throw ParseError(line, col,
std::format("expected integer, got '{}'", lexeme));
} catch (const std::out_of_range&) {
throw ParseError(line, col,
std::format("integer out of range: '{}'", lexeme));
}
inline std::string msg_unclosed_block(std::string_view block) {
return std::format("unclosed {} — missing '}}' before end of file", block);
}
inline std::string msg_unknown_decl(std::string_view token) {
return std::format("unknown declaration '{}'", token);
}
inline std::string msg_not_a_string() {
return "expected a quoted string value, got bare word — wrap it in \"quotes\"";
}
} // namespace kappa::dsl
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <format>
#include <stdexcept>
#include <string>
namespace kappa::dsl {
class ParseError : public std::runtime_error {
public:
ParseError(int line, int col, const std::string& msg)
: std::runtime_error(std::format("{}:{}: {}", line, col, msg)) {}
};
/// Parse an integer from a DSL token lexeme.
/// Throws ParseError on invalid input or overflow.
inline int parse_int(int line, int col, const std::string& lexeme) {
try {
std::size_t pos = 0;
int val = std::stoi(lexeme, &pos);
if (pos != lexeme.size()) {
throw ParseError(line, col,
std::format("expected an integer, got '{}'", lexeme));
}
return val;
} catch (const std::invalid_argument&) {
throw ParseError(line, col,
std::format("expected an integer, got '{}'", lexeme));
} catch (const std::out_of_range&) {
throw ParseError(line, col,
std::format("integer out of range: '{}'", lexeme));
}
}
} // namespace kappa::dsl
+19
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;
@@ -52,17 +57,31 @@ struct ServiceRef {
std::unordered_map<std::string, std::string> config;
};
struct RepoDef {
std::string name;
std::string url;
std::vector<std::string> channels;
std::vector<std::string> mirrors;
int priority = 50;
};
struct SystemConfig {
std::vector<std::string> imports;
std::vector<std::string> remotes; // legacy — flat URL list
std::vector<RepoDef> repos; // named repos with channels/mirrors
SystemBlock system;
std::vector<PackageRef> packages;
std::vector<ServiceRef> services;
BootBlock boot;
std::vector<UserRef> users;
std::vector<GroupDef> groups;
std::vector<Assertion> assertions;
};
SystemConfig parse_system_config(std::string_view source);
SystemConfig resolve_imports(const SystemConfig& cfg, const std::string& base_dir);
IndexDef parse_index(std::string_view source);
IndexDef build_index(const std::string& directory);
} // namespace kappa::dsl
+10
View File
@@ -13,6 +13,7 @@ enum class TokenType {
Lbrace, // {
Rbrace, // }
Equals, // =
Plus, // +
Lbracket, // [
Rbracket, // ]
Comma, // ,
@@ -28,6 +29,7 @@ enum class TokenType {
KwDepends,
KwProvides,
KwOutputs,
KwConflicts,
KwFeatures,
KwConfig,
KwConst,
@@ -37,12 +39,20 @@ enum class TokenType {
KwService,
KwAssert,
KwImport,
KwSha256,
KwSha512,
KwMd5,
KwPrepare,
KwBuild,
KwCheck,
KwInstall,
KwUninstall,
KwTrue,
KwFalse,
// Repo / index
KwIndex,
KwRepos,
};
struct Token {
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "kappa/dsl/ast.hpp"
#include <filesystem>
#include <string>
namespace kappa::fetch {
struct FetchResult {
std::filesystem::path work_dir;
std::string error;
bool ok() const { return error.empty(); }
};
FetchResult fetch(const dsl::PackageDef& pkg);
} // namespace kappa::fetch
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "kappa/dsl/system.hpp"
#include <string>
#include <vector>
namespace kappa::fetch {
struct RecipeResult {
bool ok = false;
std::string path; // path to the cached .kap file
std::string version; // version of the fetched package
bool updated = false; // true if the cache was updated (remote was newer)
std::string error;
};
// Legacy: flat URL list (backward compat with remotes = [...])
RecipeResult fetch_recipe(const std::string& name,
const std::vector<std::string>& remotes);
// Index-aware: named repos with channels, mirrors, and cached indexes
RecipeResult fetch_recipe_from_repos(const std::string& name,
const std::vector<dsl::RepoDef>& repos);
} // namespace kappa::fetch
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "kappa/resolve/plan.hpp"
#include <filesystem>
#include <string>
#include <unordered_map>
#include <vector>
namespace kappa::install {
struct InstallResult {
bool ok = false;
std::string hash;
std::filesystem::path store_path;
std::string error;
};
InstallResult install(const resolve::BuildStep& step,
const std::filesystem::path& work_dir);
struct DbEntry {
std::string name;
std::string version;
std::string hash;
std::string config_hash; // FNV-1a hash of features+config, empty if unknown
std::vector<std::string> provides;
};
std::vector<DbEntry> read_installed();
bool write_installed(const std::vector<DbEntry>& entries);
bool record_generation(const std::vector<std::string>& hashes, int keep);
std::string compute_config_hash(
const std::unordered_map<std::string, dsl::FeatureDef>& features,
const std::unordered_map<std::string, std::string>& config);
} // namespace kappa::install
+14 -7
View File
@@ -1,16 +1,23 @@
#pragma once
#include <filesystem>
#include <string>
#include <string_view>
namespace kappa::paths {
inline const std::filesystem::path root{"/kappa"};
inline const auto bin_dir = root / "bin";
inline const auto temp_dir = root / "temp";
inline const auto db_dir = root / "db";
inline const auto system_dir = root / "system";
inline const auto builds_dir = system_dir / "builds";
void set_root(std::string_view path);
void ensure_directories();
std::filesystem::path root_path();
std::filesystem::path bin_dir();
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();
bool ensure_directories();
bool directories_exist();
} // namespace kappa::paths
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "kappa/dsl/system.hpp"
#include "kappa/resolve/plan.hpp"
#include <string>
#include <vector>
namespace kappa::rebuild {
struct ChangeSet {
std::vector<std::string> added;
std::vector<std::string> changed;
std::vector<std::string> removed;
bool kernel_changed = false;
bool init_changed = false;
bool services_changed = false;
bool bootloader_changed = false;
};
struct InitImpact {
std::vector<std::string> service_rebuild; // packages needing full rebuild (use ${enabledinit})
std::vector<std::string> service_only; // packages needing only service file regeneration
std::vector<std::string> skipped; // packages with no services — no action needed
};
ChangeSet compute_changes(const dsl::SystemConfig& cfg);
InitImpact compute_init_impact(const dsl::SystemConfig& cfg,
const resolve::Registry& registry);
} // namespace kappa::rebuild
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "kappa/config/merge.hpp"
#include "kappa/dsl/ast.hpp"
#include "kappa/dsl/system.hpp"
#include <string>
#include <unordered_map>
#include <vector>
namespace kappa::resolve {
struct ResolvedDep {
std::string name;
std::string version_constraint;
};
struct BuildStep {
std::string name;
const dsl::PackageDef* package = nullptr;
config::ResolvedPackage resolved;
std::vector<ResolvedDep> dependencies;
std::string enabled_init; // boot.init value for ${enabledinit}
};
struct BuildPlan {
std::vector<BuildStep> steps;
std::vector<std::string> cycles;
std::vector<std::string> conflicts;
std::vector<std::string> missing;
};
using Registry = std::unordered_map<std::string, dsl::PackageDef>;
BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry);
} // namespace kappa::resolve
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "kappa/resolve/plan.hpp"
#include <string>
#include <vector>
namespace kappa::sched {
struct SchedResult {
bool ok = false;
std::vector<std::string> built;
std::vector<std::string> failed;
};
// Run the build plan with worker-level (-w) and job-level (-j) parallelism.
// Workers atomically claim ready packages, build them, and notify dependents.
// The same package is never built twice — deduplicated in the dependency graph.
//
// -w N package-level parallelism (concurrent package builds)
// -j N job-level parallelism (make -jN per package)
//
// The scheduler uses depth-based priority groups (Beta → Alpha → Zeta)
// so that deep dependencies unblock as many packages as possible first.
SchedResult run(const resolve::BuildPlan& plan,
const std::string& work_root,
int workers = 1,
int jobs = 1);
} // namespace kappa::sched
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "kappa/dsl/ast.hpp"
#include "kappa/service/types.hpp"
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace kappa::service {
struct ServiceSpec {
std::string name;
std::string description;
std::string exec;
std::string type;
std::string user;
std::vector<int> ports;
std::unordered_map<std::string, std::string> env;
std::string after;
std::string restart_policy;
std::string working_dir;
static ServiceSpec from_service_init(const dsl::NamedService& ns);
};
std::string generate_systemd_service(const ServiceSpec& spec);
std::string generate_s6_service(const ServiceSpec& spec);
std::string generate_openrc_service(const ServiceSpec& spec);
std::string generate_dinit_service(const ServiceSpec& spec);
std::string generate_runit_service(const ServiceSpec& spec);
std::string generate_service_file(InitSystem is, const ServiceSpec& spec);
struct ServiceInstallResult {
bool ok;
std::string path;
std::string error;
};
ServiceInstallResult install_service(
InitSystem is,
const ServiceSpec& spec,
std::string_view prefix = "/");
} // namespace kappa::service
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace kappa::service {
enum class InitSystem : std::uint8_t {
Systemd,
OpenRC,
S6,
Runit,
Dinit,
Unknown
};
InitSystem parse_init_system(std::string_view name);
std::string_view to_string(InitSystem is);
bool is_supported(std::string_view name);
std::vector<InitSystem> all_systems();
std::string_view init_description(InitSystem is);
struct InitPaths {
std::string service_dir;
std::string enable_cmd;
std::string disable_cmd;
};
InitPaths init_paths(InitSystem is, std::string_view prefix = "/");
// Shebangs for s6 execline-based run scripts.
// Extracted here so backends share a single definition.
inline constexpr std::string_view s6_execline_shebang = "#!/bin/execlineb -P\n";
} // namespace kappa::service
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "kappa/dsl/system.hpp"
#include <string>
namespace kappa::system {
struct ActivateResult {
bool ok;
std::string error;
};
ActivateResult write_hostname(const std::string& hostname,
std::string_view prefix = "/");
ActivateResult write_timezone(const std::string& timezone,
std::string_view prefix = "/");
} // namespace kappa::system
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include "kappa/dsl/ast.hpp"
#include "kappa/dsl/system.hpp"
#include <string>
#include <vector>
namespace kappa::tools {
enum class DiagSeverity { Warning, Error };
struct Diagnostic {
DiagSeverity severity;
std::string message;
};
std::vector<Diagnostic> check_package(const dsl::PackageDef& pkg);
std::vector<Diagnostic> check_config(const dsl::SystemConfig& cfg);
} // namespace kappa::tools
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "kappa/dsl/ast.hpp"
#include "kappa/dsl/system.hpp"
#include <ostream>
namespace kappa::tools {
void format_package(std::ostream& os, const dsl::PackageDef& pkg);
void format_config(std::ostream& os, const dsl::SystemConfig& cfg);
void format_index(std::ostream& os, const dsl::IndexDef& idx);
} // namespace kappa::tools
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <string>
#include <string_view>
namespace kappa::util {
std::string to_lower(std::string_view sv);
std::string shell_escape(std::string_view s);
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "binutils"
version = "2.46.1"
description = "GNU Binary Utilities (ld, as, objdump, readelf)"
[source]
url = "https://ftp.gnu.org/gnu/${name}/${name}-${version}.tar.xz" # ${} is string substitution
sha256 = "e127a709cba24c76de8936cb7083dd768f28cd37eb010492e2f19b71eb1294e4" # or sha512, or md5
[dependencies]
deps = ["zlib", "gettext"]
[build]
system = "autotools" # or cargo, make, cmake, or meson
+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
+86
View File
@@ -0,0 +1,86 @@
#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 {};
}
}
namespace {
std::string read_current_init(std::string_view prefix) {
namespace fs = std::filesystem;
fs::path init_link = fs::path(prefix) / "boot/init";
std::error_code ec;
if (!fs::is_symlink(init_link, ec)) return {};
auto target = fs::read_symlink(init_link, ec);
if (ec) return {};
return target.string();
}
} // namespace
// ---------------------------------------------------------------------------
// 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"};
}
BootSpec spec_copy = spec;
if (spec_copy.init_prev.empty()) {
spec_copy.init_prev = read_current_init(prefix);
}
std::string content = generate_bootloader_config(bl, spec_copy);
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
+66
View File
@@ -0,0 +1,66 @@
#include "kappa/boot/types.hpp"
#include "kappa/util.hpp"
#include <algorithm>
#include <cctype>
#include <filesystem>
#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::filesystem::path(prefix) / "boot/grub/grub.cfg").string(),
.install_cmd = "grub-install",
};
case Bootloader::Limine:
return {
.config_path = (std::filesystem::path(prefix) / "boot/limine/limine.cfg").string(),
.install_cmd = "limine",
};
case Bootloader::Unknown:
return {};
}
return {};
}
} // namespace kappa::boot
+324
View File
@@ -0,0 +1,324 @@
#include "kappa/build/build.hpp"
#include "kappa/eval/vars.hpp"
#include <cstdlib>
#include <filesystem>
#include <format>
#include <fstream>
#include <iostream>
#include <string_view>
#include <sys/wait.h>
#include <unistd.h>
#include <unordered_map>
namespace kappa::build {
namespace fs = std::filesystem;
// ---------------------------------------------------------------------------
// Shell execution — runs a command through /bin/sh in the given cwd.
// Environment variables are applied before exec.
// Returns exit code, or -1 if fork/exec fails.
// ---------------------------------------------------------------------------
static int exec_sh(const std::string& cmd,
const std::unordered_map<std::string, std::string>& env,
const fs::path& cwd) {
pid_t pid = fork();
if (pid == 0) {
for (const auto& [k, v] : env) {
setenv(k.c_str(), v.c_str(), 1);
}
if (!cwd.empty()) {
std::error_code ec;
fs::current_path(cwd, ec);
}
execl("/bin/sh", "sh", "-c", cmd.c_str(), nullptr);
_exit(127);
}
if (pid < 0) { return -1; }
int status = 0;
pid_t w;
do {
w = waitpid(pid, &status, 0);
} while (w == -1 && errno == EINTR);
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
// ---------------------------------------------------------------------------
// Config-file variable resolution helpers
// ---------------------------------------------------------------------------
struct CfResolved {
std::string value;
bool skip_line = false;
};
// Parse "${cfg.port ? 8080}" suffix after the variable name.
// Extracts the modifier (?, !) and default value from inner text after a space.
static void parse_cf_suffix(std::string_view& inner,
bool& optional,
bool& required,
std::string_view& default_val) {
auto space = inner.find(' ');
if (space == std::string_view::npos) { return; }
auto suffix = inner.substr(space + 1);
while (!suffix.empty() && suffix.front() == ' ') {
suffix.remove_prefix(1);
}
if (suffix.empty()) { return; }
if (suffix.front() == '?') {
optional = true;
default_val = suffix.substr(1);
while (!default_val.empty() && default_val.front() == ' ') {
default_val.remove_prefix(1);
}
} else if (suffix.front() == '!') {
required = true;
}
inner = inner.substr(0, space);
}
static void trim_trailing_spaces(std::string_view& s) {
while (!s.empty() && s.back() == ' ') {
s.remove_suffix(1);
}
}
static CfResolved resolve_cf_value(std::string_view raw,
const eval::Scope& scope) {
CfResolved result;
std::string out;
out.reserve(raw.size());
std::size_t i = 0;
while (i < raw.size()) {
if (raw[i] != '$' || i + 1 >= raw.size() || raw[i + 1] != '{') {
out += raw[i];
++i;
continue;
}
auto end = raw.find('}', i + 2);
if (end == std::string_view::npos) {
out += raw.substr(i);
break;
}
auto inner = raw.substr(i + 2, end - (i - 2));
bool optional = false;
bool required = false;
std::string_view default_val;
parse_cf_suffix(inner, optional, required, default_val);
trim_trailing_spaces(inner);
auto rv = eval::resolve(inner, scope);
if (rv.kind == eval::VarKind::Unknown) {
if (optional) {
out += default_val;
} else if (required) {
result.skip_line = true;
return result;
}
} else {
out += rv.value;
}
i = end + 1;
}
result.value = std::move(out);
return result;
}
// ---------------------------------------------------------------------------
// Write a config file to destdir.
// "default" → skip if file exists; "replace" → always overwrite;
// "merge" → not yet implemented, falls back to replace.
// ---------------------------------------------------------------------------
static void write_config_file(const dsl::ConfigFile& cf,
const eval::Scope& scope,
const fs::path& destdir) {
auto path = destdir / cf.path;
if (cf.mode == "default" && fs::exists(path)) { return; }
std::error_code ec;
fs::create_directories(path.parent_path(), ec);
if (ec) { return; }
std::ofstream out(path);
if (!out) { return; }
out << "# Generated by kappa — do not edit manually\n";
for (const auto& [key, raw_val] : cf.entries) {
auto resolved = resolve_cf_value(raw_val, scope);
if (resolved.skip_line) { continue; }
const auto& val = resolved.value;
if (val.contains(' ') || val.empty()) {
out << key << " = \"" << val << "\"\n";
} else {
out << key << " = " << val << "\n";
}
}
}
// ---------------------------------------------------------------------------
// Build variable scope from a resolved build step.
// ---------------------------------------------------------------------------
static eval::Scope build_scope(const resolve::BuildStep& step,
const fs::path& destdir,
int jobs) {
eval::Scope scope = eval::make_default_scope();
const auto& pkg = *step.package;
const auto& resolved = step.resolved;
scope.builtins["prefix"] = resolved.config.contains("prefix")
? resolved.config.at("prefix") : "/usr";
scope.builtins["jobs"] = std::to_string(jobs);
scope.builtins["jobopts"] = std::format("-j{}", jobs);
scope.builtins["destdir"] = destdir.string();
scope.builtins["enabledinit"] = step.enabled_init;
scope.config = resolved.config;
if (!scope.config.contains("prefix")) {
scope.config["prefix"] = scope.builtins["prefix"];
}
scope.features = resolved.features;
scope.package["name"] = pkg.name;
scope.package["version"] = pkg.version;
scope.package["source"] = pkg.source;
return scope;
}
// ---------------------------------------------------------------------------
// Snapshot current process environment into a map.
// ---------------------------------------------------------------------------
static std::unordered_map<std::string, std::string> capture_env() {
std::unordered_map<std::string, std::string> env;
for (char** envp = ::environ; *envp != nullptr; ++envp) {
std::string_view entry(*envp);
auto eq = entry.find('=');
if (eq != std::string_view::npos) {
env.emplace(std::string(entry.substr(0, eq)),
std::string(entry.substr(eq + 1)));
}
}
return env;
}
// ---------------------------------------------------------------------------
// Apply package env block to a mutable env map.
// ---------------------------------------------------------------------------
static void apply_package_env(
std::unordered_map<std::string, std::string>& env,
const std::vector<dsl::EnvEntry>& entries) {
for (const auto& e : entries) {
switch (e.mode) {
case dsl::EnvMode::Append: {
auto it = env.find(e.key);
if (it != env.end() && !it->second.empty()) {
it->second += ' ';
}
env[e.key] += e.value;
break;
}
case dsl::EnvMode::Soft:
env.try_emplace(e.key, e.value);
break;
case dsl::EnvMode::Set:
env[e.key] = e.value;
break;
}
}
}
// ---------------------------------------------------------------------------
// Public API — build a single package.
// ---------------------------------------------------------------------------
BuildResult build(const resolve::BuildStep& step,
const std::string& work_dir,
int jobs) {
BuildResult result;
const auto& pkg = *step.package;
// Workspace
fs::path work(work_dir);
fs::path destdir = work / "destdir";
std::error_code ec;
fs::create_directories(destdir, ec);
if (ec) {
result.phase = "setup";
result.error = std::format("cannot create destdir {}: {}",
destdir.string(), ec.message());
return result;
}
// Variable scope
auto scope = build_scope(step, destdir, jobs);
// Environment
auto env = capture_env();
apply_package_env(env, pkg.env_entries);
for (const auto& e : pkg.env_entries) {
switch (e.mode) {
case dsl::EnvMode::Append: {
const char* existing = getenv(e.key.c_str());
std::string val = existing ? std::string(existing) + " " + e.value : e.value;
setenv(e.key.c_str(), val.c_str(), 1);
break;
}
case dsl::EnvMode::Soft:
setenv(e.key.c_str(), e.value.c_str(), 0);
break;
case dsl::EnvMode::Set:
setenv(e.key.c_str(), e.value.c_str(), 1);
break;
}
}
// Config files
for (const auto& cf : pkg.config_files) {
write_config_file(cf, scope, destdir);
}
// Phases
auto run_phase = [&](const dsl::Phase& phase,
const char* phase_name) -> bool {
if (phase.commands.empty()) { return true; }
for (const auto& raw_cmd : phase.commands) {
auto cmd = eval::interpolate(raw_cmd, scope);
if (cmd.empty()) { continue; }
std::cout << std::format(" [{}] {}\n", phase_name, cmd);
int rc = exec_sh(cmd, env, work);
if (rc != 0) {
result.phase = phase_name;
result.error = std::format(
"command exited with code {}: {}", rc, cmd);
return false;
}
}
return true;
};
if (!run_phase(pkg.prepare, "prepare")) { return result; }
if (!run_phase(pkg.build, "build")) { return result; }
if (!run_phase(pkg.check, "check")) { return result; }
if (!run_phase(pkg.install, "install")) { return result; }
result.ok = true;
return result;
}
} // namespace kappa::build
+102
View File
@@ -0,0 +1,102 @@
#include "kappa/config/eval.hpp"
#include "kappa/service/types.hpp"
#include <iostream>
namespace kappa::config {
std::string resolve_field(const dsl::SystemConfig& cfg, std::string_view path) {
auto dot = path.find('.');
auto ns = path.substr(0, dot);
if (ns == "boot") {
auto key = path.substr(dot + 1);
if (key == "kernel") { return cfg.boot.kernel; }
if (key == "init") { return cfg.boot.init; }
if (key == "efi") { return cfg.boot.efi; }
if (key == "swap") { return cfg.boot.swap; }
if (key == "root") { return cfg.boot.root; }
if (key == "bootloader") { return cfg.boot.bootloader; }
auto it = cfg.boot.params.find(std::string(key));
if (it != cfg.boot.params.end()) { return it->second; }
}
if (ns == "system") {
auto key = path.substr(dot + 1);
if (key == "hostname") { return cfg.system.hostname; }
if (key == "timezone") { return cfg.system.timezone; }
}
if (ns == "features") {
auto key = path.substr(dot + 1);
auto it = cfg.system.features.find(std::string(key));
if (it != cfg.system.features.end()) {
return it->second.enabled ? "true" : "false";
}
}
return "";
}
std::vector<AssertFailure> evaluate_assertions(const dsl::SystemConfig& cfg) {
std::vector<AssertFailure> failures;
for (auto& a : cfg.assertions) {
auto actual = resolve_field(cfg, a.field);
bool pass = false;
if (a.op == "==") {
pass = (actual == a.value);
} else if (a.op == "!=") {
pass = (actual != a.value);
}
if (!pass) {
failures.push_back({a.message, a.field, a.value, actual});
}
}
return failures;
}
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::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; }
// 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;
for (auto& ns : pkg.services) {
if (ns.name == svc_name) {
resolved[svc.name] = ns;
break;
}
}
}
return resolved;
}
} // namespace kappa::config
+18 -2
View File
@@ -13,6 +13,7 @@ static const std::unordered_map<std::string_view, TokenType> keywords = {
{"depends", TokenType::KwDepends},
{"provides", TokenType::KwProvides},
{"outputs", TokenType::KwOutputs},
{"conflicts", TokenType::KwConflicts},
{"features", TokenType::KwFeatures},
{"config", TokenType::KwConfig},
{"const", TokenType::KwConst},
@@ -20,14 +21,20 @@ static const std::unordered_map<std::string_view, TokenType> keywords = {
{"patches", TokenType::KwPatches},
{"env", TokenType::KwEnv},
{"service", TokenType::KwService},
{"sha256", TokenType::KwSha256},
{"sha512", TokenType::KwSha512},
{"md5", TokenType::KwMd5},
{"assert", TokenType::KwAssert},
{"import", TokenType::KwImport},
{"prepare", TokenType::KwPrepare},
{"build", TokenType::KwBuild},
{"check", TokenType::KwCheck},
{"install", TokenType::KwInstall},
{"uninstall", TokenType::KwUninstall},
{"true", TokenType::KwTrue},
{"false", TokenType::KwFalse},
{"index", TokenType::KwIndex},
{"repos", TokenType::KwRepos},
};
std::string_view token_name(TokenType type) {
@@ -37,6 +44,7 @@ std::string_view token_name(TokenType type) {
case TokenType::Lbrace: return "{";
case TokenType::Rbrace: return "}";
case TokenType::Equals: return "=";
case TokenType::Plus: return "+";
case TokenType::Lbracket: return "[";
case TokenType::Rbracket: return "]";
case TokenType::Comma: return ",";
@@ -48,6 +56,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";
@@ -55,14 +64,20 @@ std::string_view token_name(TokenType type) {
case TokenType::KwPatches: return "patches";
case TokenType::KwEnv: return "env";
case TokenType::KwService: return "service";
case TokenType::KwSha256: return "sha256";
case TokenType::KwSha512: return "sha512";
case TokenType::KwMd5: return "md5";
case TokenType::KwAssert: return "assert";
case TokenType::KwImport: return "import";
case TokenType::KwPrepare: return "prepare";
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";
case TokenType::KwIndex: return "index";
case TokenType::KwRepos: return "repos";
}
return "?";
}
@@ -133,7 +148,7 @@ Token Lexer::next() {
return {TokenType::Newline, "\n", token_start_line_, token_start_col_};
}
if (c == '"') { return scan_string(); }
if (c == '{' || c == '}' || c == '=' || c == '[' || c == ']' || c == ',') {
if (c == '{' || c == '}' || c == '=' || c == '+' || c == '[' || c == ']' || c == ',') {
return scan_symbol();
}
return scan_ident();
@@ -145,7 +160,7 @@ Token Lexer::scan_ident() {
while (pos_ < source_.size()) {
char c = peek();
if (std::isspace(static_cast<unsigned char>(c))) { break; }
if (c == '"' || c == '[' || c == ']' || c == ',') { break; }
if (c == '"' || c == '=' || c == '+' || c == '[' || c == ']' || c == ',') { break; }
lexeme += advance();
}
@@ -187,6 +202,7 @@ Token Lexer::scan_symbol() {
case '{': return {TokenType::Lbrace, "{", token_start_line_, token_start_col_};
case '}': return {TokenType::Rbrace, "}", token_start_line_, token_start_col_};
case '=': return {TokenType::Equals, "=", token_start_line_, token_start_col_};
case '+': return {TokenType::Plus, "+", token_start_line_, token_start_col_};
case '[': return {TokenType::Lbracket, "[", token_start_line_, token_start_col_};
case ']': return {TokenType::Rbracket, "]", token_start_line_, token_start_col_};
case ',': return {TokenType::Comma, ",", token_start_line_, token_start_col_};
+79 -25
View File
@@ -45,9 +45,15 @@ void Parser::skip_newlines() {
Token Parser::consume(TokenType type) {
if (!at(type)) {
if (type == TokenType::Rbrace && at(TokenType::Eof)) {
throw ParseError(current_.line, current_.col,
std::format("expected '{}', got '{}'",
token_name(type), token_name(current_.type)));
msg_unclosed_block("block"));
}
if (type == TokenType::String && at(TokenType::Ident)) {
throw ParseError(current_.line, current_.col, msg_not_a_string());
}
throw ParseError(current_.line, current_.col,
msg_expected(token_name(type), token_name(current_.type)));
}
Token t = std::move(current_);
advance();
@@ -117,6 +123,28 @@ void Parser::parse_body(PackageDef& pkg) {
pkg.license = consume(TokenType::String).lexeme;
break;
case TokenType::KwSha256:
consume(TokenType::KwSha256);
consume(TokenType::Equals);
pkg.sha256 = consume(TokenType::String).lexeme;
break;
case TokenType::KwSha512:
consume(TokenType::KwSha512);
consume(TokenType::Equals);
pkg.sha512 = consume(TokenType::String).lexeme;
break;
case TokenType::KwMd5:
consume(TokenType::KwMd5);
consume(TokenType::Equals);
pkg.md5 = consume(TokenType::String).lexeme;
break;
case TokenType::Ident:
throw ParseError(current_.line, current_.col,
msg_unknown_decl(current_.lexeme));
case TokenType::KwProvides:
consume(TokenType::KwProvides);
consume(TokenType::Equals);
@@ -129,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);
@@ -190,47 +224,50 @@ void Parser::parse_body(PackageDef& pkg) {
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto key = consume(TokenType::Ident).lexeme;
bool soft = false;
if (at(TokenType::Ident) && current_.lexeme == "?=") {
soft = true;
EnvMode mode = EnvMode::Set;
if (at(TokenType::Plus)) {
advance();
consume(TokenType::Equals);
mode = EnvMode::Append;
} else if (at(TokenType::Ident) && current_.lexeme == "?") {
advance();
consume(TokenType::Equals);
mode = EnvMode::Soft;
} else {
consume(TokenType::Equals);
}
pkg.env_entries.push_back(
{std::move(key), consume(TokenType::String).lexeme, soft});
{std::move(key), consume(TokenType::String).lexeme, mode});
skip_newlines();
}
consume(TokenType::Rbrace);
break;
case TokenType::KwService:
case TokenType::KwService: {
consume(TokenType::KwService);
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto init_name = current_.lexeme;
NamedService ns;
if (!at(TokenType::Lbrace)) {
ns.name = 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;
ns.exec = consume(TokenType::String).lexeme;
} else if (key == "type") {
si.type = consume(TokenType::String).lexeme;
ns.type = consume(TokenType::String).lexeme;
} else if (key == "user") {
si.user = consume(TokenType::String).lexeme;
ns.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(
ns.ports.push_back(
parse_int(current_.line, current_.col,
current_.lexeme));
advance();
@@ -239,17 +276,23 @@ void Parser::parse_body(PackageDef& pkg) {
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 {
si.env[key] = consume(TokenType::String).lexeme;
ns.env[key] = consume(TokenType::String).lexeme;
}
skip_newlines();
}
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);
@@ -269,6 +312,10 @@ void Parser::parse_body(PackageDef& pkg) {
} else {
a.op = "=";
}
} else if (at(TokenType::Ident) && current_.lexeme == "!") {
advance();
consume(TokenType::Equals);
a.op = "!=";
} else {
a.op = current_.lexeme; advance();
}
@@ -307,6 +354,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",
@@ -334,9 +386,9 @@ Dependency Parser::parse_dependency_item() {
auto raw = consume(TokenType::String).lexeme;
auto colon = raw.find(':');
if (colon != std::string::npos) {
return {raw.substr(0, colon), "", raw.substr(colon + 1)};
return {raw.substr(0, colon), "", raw.substr(colon + 1), ""};
}
return {std::move(raw), "", ""};
return {std::move(raw), "", "", ""};
}
consume(TokenType::Lbrace);
@@ -480,7 +532,9 @@ Phase Parser::parse_phase() {
Command Parser::parse_command_line() {
std::string cmd;
while (!at(TokenType::Newline) && !at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (!cmd.empty() && !at(TokenType::Comma)) { cmd += ' '; }
if (!cmd.empty() && !at(TokenType::Comma)
&& current_.type != TokenType::Equals
&& !cmd.ends_with('=')) { cmd += ' '; }
cmd += current_.lexeme;
advance();
}
+226 -9
View File
@@ -1,6 +1,7 @@
#include "kappa/dsl/system.hpp"
#include "kappa/dsl/error.hpp"
#include "kappa/dsl/lexer.hpp"
#include "kappa/dsl/parser.hpp"
#include <filesystem>
#include <format>
@@ -28,6 +29,8 @@ 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);
void parse_repos_block(SystemConfig& cfg);
std::string consume_ident();
std::string consume_string();
@@ -44,9 +47,15 @@ void SysParser::skip_newlines() {
Token SysParser::consume(TokenType type) {
if (!at(type)) {
if (type == TokenType::Rbrace && at(TokenType::Eof)) {
throw ParseError(current_.line, current_.col,
std::format("expected '{}', got '{}'",
token_name(type), token_name(current_.type)));
msg_unclosed_block("block"));
}
if (type == TokenType::String && at(TokenType::Ident)) {
throw ParseError(current_.line, current_.col, msg_not_a_string());
}
throw ParseError(current_.line, current_.col,
msg_expected(token_name(type), token_name(current_.type)));
}
Token t = std::move(current_);
advance();
@@ -59,6 +68,9 @@ std::string SysParser::consume_ident() {
at(TokenType::KwService) ||
at(TokenType::KwAssert) ||
at(TokenType::KwImport) ||
at(TokenType::KwSha256) ||
at(TokenType::KwSha512) ||
at(TokenType::KwMd5) ||
at(TokenType::KwConfig) ||
at(TokenType::KwFeatures) ||
at(TokenType::KwVersion) ||
@@ -75,7 +87,9 @@ std::string SysParser::consume_ident() {
at(TokenType::KwCheck) ||
at(TokenType::KwInstall) ||
at(TokenType::KwTrue) ||
at(TokenType::KwFalse)) {
at(TokenType::KwFalse) ||
at(TokenType::KwIndex) ||
at(TokenType::KwRepos)) {
auto lexeme = current_.lexeme;
advance();
return lexeme;
@@ -121,6 +135,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();
@@ -131,12 +156,16 @@ SystemConfig SysParser::parse() {
consume(TokenType::Ident); // ":"
a.field = current_.lexeme; advance();
if (at(TokenType::Equals)) {
advance(); // first =
advance();
if (at(TokenType::Equals)) {
a.op = "=="; advance(); // second =
a.op = "=="; advance();
} else {
a.op = "=";
}
} else if (at(TokenType::Ident) && current_.lexeme == "!") {
advance();
consume(TokenType::Equals);
a.op = "!=";
} else {
a.op = current_.lexeme; advance();
}
@@ -158,6 +187,8 @@ 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 if (kw == "repos") { parse_repos_block(cfg); }
else {
throw ParseError(current_.line, current_.col,
std::format("unknown section '{}'", kw));
@@ -188,14 +219,19 @@ void SysParser::parse_system_block(SystemConfig& cfg) {
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto k = consume_ident();
bool soft = false;
if (at(TokenType::Ident) && current_.lexeme == "?=") {
soft = true;
EnvMode mode = EnvMode::Set;
if (at(TokenType::Plus)) {
advance();
consume(TokenType::Equals);
mode = EnvMode::Append;
} else if (at(TokenType::Ident) && current_.lexeme == "?") {
advance();
consume(TokenType::Equals);
mode = EnvMode::Soft;
} else {
consume(TokenType::Equals);
}
cfg.system.env.push_back({std::move(k), consume_string(), soft});
cfg.system.env.push_back({std::move(k), consume_string(), mode});
skip_newlines();
}
consume(TokenType::Rbrace);
@@ -397,6 +433,91 @@ 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);
}
void SysParser::parse_repos_block(SystemConfig& cfg) {
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
skip_newlines();
if (at(TokenType::Rbrace)) break;
RepoDef repo;
repo.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();
if (key == "url") {
consume(TokenType::Equals);
repo.url = consume_string();
} else if (key == "channels") {
consume(TokenType::Equals);
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
repo.channels.push_back(consume_string());
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (key == "mirrors") {
consume(TokenType::Equals);
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
repo.mirrors.push_back(consume_string());
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (key == "priority") {
consume(TokenType::Equals);
repo.priority = parse_int(current_.line, current_.col,
current_.lexeme);
advance();
}
skip_newlines();
}
consume(TokenType::Rbrace);
}
cfg.repos.push_back(std::move(repo));
}
consume(TokenType::Rbrace);
}
SystemConfig parse_system_config(std::string_view source) {
SysParser p(source);
return p.parse();
@@ -406,12 +527,15 @@ 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& r : imported.repos) { base.repos.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); }
@@ -436,4 +560,97 @@ SystemConfig resolve_imports(const SystemConfig& cfg, const std::string& base_di
return resolved;
}
IndexDef parse_index(std::string_view source) {
Lexer lexer(source);
Token current;
auto advance = [&] { current = lexer.next(); };
advance();
auto skip_newlines = [&] {
while (current.type == TokenType::Newline) { advance(); }
};
auto consume = [&](TokenType type) -> Token {
if (current.type != type) {
throw ParseError(current.line, current.col,
msg_expected(token_name(type),
token_name(current.type)));
}
Token t = std::move(current);
advance();
return t;
};
skip_newlines();
consume(TokenType::KwIndex);
// index "kappa-os/stable" { ... }
auto name_tok = consume(TokenType::String);
consume(TokenType::Lbrace);
IndexDef idx;
idx.name = std::move(name_tok.lexeme);
skip_newlines();
while (current.type != TokenType::Rbrace && current.type != TokenType::Eof) {
skip_newlines();
if (current.type == TokenType::Rbrace) break;
IndexEntry entry;
entry.name = current.lexeme;
advance();
if (current.type == TokenType::Lbrace) {
advance();
skip_newlines();
while (current.type != TokenType::Rbrace && current.type != TokenType::Eof) {
if (current.type == TokenType::Newline) { advance(); continue; }
auto key = current.lexeme;
advance();
consume(TokenType::Equals);
if (key == "version") {
entry.version = consume(TokenType::String).lexeme;
}
skip_newlines();
}
consume(TokenType::Rbrace);
}
idx.packages.push_back(std::move(entry));
skip_newlines();
}
consume(TokenType::Rbrace);
return idx;
}
IndexDef build_index(const std::string& directory) {
IndexDef idx;
auto dirname = std::filesystem::path(directory).filename().string();
// If the directory is empty, drop the "."
if (dirname.empty() || dirname == ".") {
dirname = "local";
}
idx.name = dirname;
std::error_code ec;
for (auto& entry : std::filesystem::directory_iterator(directory, ec)) {
if (ec) break;
if (!entry.is_regular_file()) continue;
auto ext = entry.path().extension().string();
if (ext != ".kap") continue;
std::ifstream in(entry.path());
if (!in) continue;
std::ostringstream buf;
buf << in.rdbuf();
try {
auto pkg = parse(buf.str());
idx.packages.push_back({pkg.name, pkg.version});
} catch (...) {
continue;
}
}
return idx;
}
} // namespace kappa::dsl
+3 -1
View File
@@ -1,4 +1,5 @@
#include "kappa/eval/vars.hpp"
#include "kappa/paths.hpp"
#include <string>
@@ -9,8 +10,9 @@ Scope make_default_scope() {
s.builtins["prefix"] = "/usr";
s.builtins["jobs"] = "1";
s.builtins["jobopts"] = "-j1";
s.builtins["destdir"] = "/kappa/temp/destdir";
s.builtins["destdir"] = (paths::temp_dir() / "destdir").string();
s.builtins["userargs"] = "";
s.builtins["enabledinit"] = "";
return s;
}
+238
View File
@@ -0,0 +1,238 @@
#include "kappa/fetch/fetch.hpp"
#include "kappa/paths.hpp"
#include <array>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <format>
#include <string>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
namespace kappa::fetch {
namespace fs = std::filesystem;
using namespace std::string_view_literals;
static int exec_cmd(const std::vector<std::string>& argv) {
if (argv.empty()) { return -1; }
std::vector<std::vector<char>> argv_storage(argv.size());
std::vector<char*> cargs;
for (size_t i = 0; i < argv.size(); ++i) {
argv_storage[i].assign(argv[i].begin(), argv[i].end());
argv_storage[i].push_back('\0');
cargs.push_back(argv_storage[i].data());
}
cargs.push_back(nullptr);
pid_t pid = fork();
if (pid == 0) {
execvp(cargs[0], cargs.data());
_exit(127);
}
if (pid < 0) { return -1; }
int status = 0;
pid_t w;
do { w = waitpid(pid, &status, 0); } while (w == -1 && errno == EINTR);
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
static std::string exec_capture(const std::vector<std::string>& argv) {
int pipefd[2];
if (pipe(pipefd) != 0) { return ""; }
pid_t pid = fork();
if (pid == 0) {
close(pipefd[0]);
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
std::vector<std::vector<char>> argv_storage(argv.size());
std::vector<char*> cargs;
for (size_t i = 0; i < argv.size(); ++i) {
argv_storage[i].assign(argv[i].begin(), argv[i].end());
argv_storage[i].push_back('\0');
cargs.push_back(argv_storage[i].data());
}
cargs.push_back(nullptr);
execvp(cargs[0], cargs.data());
_exit(127);
}
if (pid < 0) { close(pipefd[0]); close(pipefd[1]); return ""; }
close(pipefd[1]);
std::array<char, 256> buf;
std::string result;
ssize_t n;
while ((n = read(pipefd[0], buf.data(), buf.size() - 1)) > 0) {
buf[static_cast<std::size_t>(n)] = '\0';
result += buf.data();
}
close(pipefd[0]);
pid_t w;
do { w = waitpid(pid, nullptr, 0); } while (w == -1 && errno == EINTR);
if (!result.empty() && result.back() == '\n') { result.pop_back(); }
return result;
}
static std::string interpret_url(const dsl::PackageDef& pkg) {
auto url = pkg.source;
for (auto& [from, to] : {
std::pair{"${name}"sv, std::string_view(pkg.name)},
std::pair{"${version}"sv, std::string_view(pkg.version)}}) {
std::size_t pos = 0;
while ((pos = url.find(from, pos)) != std::string::npos) {
url.replace(pos, from.size(), to);
pos += to.size();
}
}
return url;
}
static bool verify_hash(const fs::path& file, std::string_view algo,
std::string_view expected) {
if (expected.empty()) { return true; }
std::string tool;
if (algo == "sha256") { tool = "sha256sum"; }
else if (algo == "sha512") { tool = "sha512sum"; }
else if (algo == "md5") { tool = "md5sum"; }
else { return false; }
auto output = exec_capture({tool, file.string()});
if (output.empty()) { return false; }
auto space = output.find(' ');
auto computed = (space != std::string::npos)
? output.substr(0, space)
: output;
return computed == expected;
}
FetchResult fetch(const dsl::PackageDef& pkg) {
FetchResult result;
auto url = interpret_url(pkg);
if (url.empty()) {
result.error = "empty source URL";
return result;
}
auto ext_pos = url.rfind('.');
auto ext = (ext_pos != std::string::npos)
? url.substr(ext_pos + 1)
: std::string{};
if (ext == "gz" || ext == "xz" || ext == "zst") {
auto prev = url.rfind('.', ext_pos - 1);
if (prev != std::string::npos) {
std::string compound{url.substr(prev + 1,
ext_pos - prev - 1)};
ext = compound + "." + ext;
}
}
auto dest_name = pkg.name + "-" + pkg.version;
// 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 {
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"}) {
std::string_view expected;
if (std::string_view(algo) == "sha512") { expected = pkg.sha512; }
else if (std::string_view(algo) == "sha256") { expected = pkg.sha256; }
else { expected = pkg.md5; }
if (!expected.empty()) {
if (verify_hash(dest_file, algo, expected)) {
verified = true;
} else {
result.error = std::format("{} mismatch", algo);
return result;
}
}
}
if (!verified && (!pkg.sha256.empty() || !pkg.sha512.empty() || !pkg.md5.empty())) {
result.error = "hash verification failed";
return result;
}
auto extract_cmd = std::string{"tar"};
if (ext == "zip") {
int rc2 = exec_cmd({"unzip", "-o", dest_file.string(),
"-d", result.work_dir.string()});
if (rc2 != 0) { result.error = "extraction failed"; return result; }
} else {
int rc2 = exec_cmd({"tar", "xf", dest_file.string(),
"-C", paths::temp_dir().string()});
if (rc2 != 0) { result.error = "extraction failed"; return result; }
}
if (!from_cache) {
fs::remove(dest_file);
}
}
for (auto& patch : pkg.patches) {
auto patch_path = patch.url;
auto patch_file = patch_path;
if (patch_path.starts_with("http")) {
auto local = fs::path(paths::temp_dir())
/ fs::path(patch_path).filename();
int rc = exec_cmd({"curl", "-L", "-o", local.string(),
patch_path});
if (rc != 0) { continue; }
patch_file = local.string();
}
if (!patch.sha256.empty()) {
if (!verify_hash(patch_file, "sha256", patch.sha256)) {
result.error = "patch hash mismatch: " + patch.url;
return result;
}
}
int prc = exec_cmd({"patch", "-p" + std::to_string(patch.level),
"-d", result.work_dir.string(), "-i", patch_file});
if (prc != 0) {
result.error = "patch failed: " + patch.url;
return result;
}
}
return result;
}
} // namespace kappa::fetch
+360
View File
@@ -0,0 +1,360 @@
#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;
}
// ---------------------------------------------------------------------------
// Index-aware recipe fetching
// ---------------------------------------------------------------------------
static std::string sanitize_filename(const std::string& s) {
std::string out = s;
for (auto& c : out) {
if (c == '/' || c == '\\' || c == ':') c = '_';
}
return out;
}
static int exec_curl_conditional(const std::string& output_path,
const std::string& url,
const std::string& time_cond_path) {
std::vector<std::string> argv = {"curl", "-Lsf", "-o", output_path};
if (!time_cond_path.empty()) {
argv.push_back("-z");
argv.push_back(time_cond_path);
}
argv.push_back(url);
std::vector<std::vector<char>> argv_storage(argv.size());
std::vector<char*> cargs;
for (size_t i = 0; i < argv.size(); ++i) {
argv_storage[i].assign(argv[i].begin(), argv[i].end());
argv_storage[i].push_back('\0');
cargs.push_back(argv_storage[i].data());
}
cargs.push_back(nullptr);
pid_t pid = fork();
if (pid == 0) {
execvp(cargs[0], cargs.data());
_exit(127);
}
if (pid < 0) return -1;
int status = 0;
while (waitpid(pid, &status, 0) == -1 && errno == EINTR) {}
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
static std::optional<dsl::IndexDef> load_cached_index(
const std::string& repo_name,
const std::string& channel) {
auto key = sanitize_filename(repo_name + "-" + channel) + ".kap";
auto path = paths::cache_dir() / "indexes" / key;
if (!std::filesystem::exists(path)) return std::nullopt;
std::ifstream in(path);
if (!in) return std::nullopt;
std::ostringstream buf;
buf << in.rdbuf();
try {
return dsl::parse_index(buf.str());
} catch (...) {
return std::nullopt;
}
}
static bool download_index(const dsl::RepoDef& repo,
const std::string& channel,
const std::filesystem::path& cache_path) {
auto url = repo.url;
if (!url.empty() && url.back() != '/') url += '/';
url += channel + "/index.kap";
auto temp = cache_path.string() + ".tmp";
// Try primary URL
int rc = exec_curl_conditional(temp, url, cache_path.string());
if (rc != 0) {
// Try mirrors
for (auto& mirror : repo.mirrors) {
auto murl = mirror;
if (!murl.empty() && murl.back() != '/') murl += '/';
murl += channel + "/index.kap";
rc = exec_curl_conditional(temp, murl, "");
if (rc == 0) break;
}
}
if (rc != 0) return false;
// If curl returned 0 but the file might be empty (304 not modified),
// move temp to cache only if it has content
std::error_code ec;
if (std::filesystem::file_size(temp, ec) > 0 || ec) {
std::filesystem::rename(temp, cache_path, ec);
return !ec;
}
// 304: no change, remove temp
std::filesystem::remove(temp, ec);
return true; // cache is still valid
}
static std::optional<std::string> download_recipe(
const std::string& name,
const dsl::RepoDef& repo,
const std::string& channel) {
auto remote_path = channel + "/" + name + ".kap";
auto temp = paths::temp_dir() / (name + ".kap.tmp");
// Build URL list: primary + mirrors
std::vector<std::string> urls;
auto url = repo.url;
if (!url.empty() && url.back() != '/') url += '/';
urls.push_back(url + remote_path);
for (auto& mirror : repo.mirrors) {
auto murl = mirror;
if (!murl.empty() && murl.back() != '/') murl += '/';
urls.push_back(murl + remote_path);
}
for (auto& u : urls) {
int rc = exec_curl_conditional(temp.string(), u, "");
if (rc == 0) {
std::ifstream in(temp);
if (!in) { std::filesystem::remove(temp); continue; }
std::ostringstream buf;
buf << in.rdbuf();
in.close();
std::filesystem::remove(temp);
return buf.str();
}
}
return std::nullopt;
}
RecipeResult fetch_recipe_from_repos(
const std::string& name,
const std::vector<dsl::RepoDef>& repos) {
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 (...) {}
}
}
// Ensure indexes cache directory exists
std::error_code ec;
std::filesystem::create_directories(paths::cache_dir() / "indexes", ec);
// Sort repos by priority (descending)
std::vector<const dsl::RepoDef*> sorted;
for (auto& r : repos) sorted.push_back(&r);
std::sort(sorted.begin(), sorted.end(),
[](auto* a, auto* b) { return a->priority > b->priority; });
std::string best_content;
std::string best_version;
for (auto* repo : sorted) {
for (auto& channel : repo->channels) {
// Check/update the index for this repo+channel
auto idx_key = sanitize_filename(repo->name + "-" + channel);
auto idx_cache = paths::cache_dir() / "indexes" / (idx_key + ".kap");
// Download fresh index (conditional on cache)
if (!download_index(*repo, channel, idx_cache)) continue;
// Load and search the index
auto idx = load_cached_index(repo->name, channel);
if (!idx) continue;
bool found = false;
for (auto& entry : idx->packages) {
if (entry.name != name) continue;
found = true;
break;
}
if (!found) continue;
// Download the recipe
auto content = download_recipe(name, *repo, channel);
if (!content) continue;
// Parse the downloaded recipe to get version
std::string remote_version;
try {
auto pkg = dsl::parse(*content);
remote_version = pkg.version;
} catch (...) {
continue;
}
if (remote_version > best_version) {
best_version = remote_version;
best_content = std::move(*content);
}
}
}
if (best_content.empty()) {
if (!cached_version.empty()) {
result.ok = true;
result.path = cache_path.string();
result.version = cached_version;
return result;
}
result.error = "package '" + name + "' not found in any repo";
return result;
}
// Update cache if remote is newer
if (best_version > cached_version || cached_version.empty()) {
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
+201
View File
@@ -0,0 +1,201 @@
#include "kappa/install/install.hpp"
#include "kappa/paths.hpp"
#include <algorithm>
#include <cstdint>
#include <filesystem>
#include <format>
#include <fstream>
#include <sstream>
namespace kappa::install {
namespace fs = std::filesystem;
std::string compute_config_hash(
const std::unordered_map<std::string, dsl::FeatureDef>& features,
const std::unordered_map<std::string, std::string>& config) {
// Serialize features (sorted by key): "key1=enabled/flag|key2=..."
std::vector<std::pair<std::string_view, const dsl::FeatureDef*>> feat_sorted;
for (auto& [k, v] : features) feat_sorted.emplace_back(k, &v);
std::sort(feat_sorted.begin(), feat_sorted.end());
std::string serialized;
for (auto& [k, f] : feat_sorted) {
if (!serialized.empty()) serialized += '|';
serialized += k;
serialized += '=';
if (f->force) serialized += "force:";
serialized += f->enabled ? "1:" : "0:";
serialized += f->flag;
}
serialized += '\n';
// Serialize config (sorted by key): "key1=val1|key2=val2"
std::vector<std::pair<std::string_view, std::string_view>> cfg_sorted;
for (auto& [k, v] : config) cfg_sorted.emplace_back(k, v);
std::sort(cfg_sorted.begin(), cfg_sorted.end());
for (auto& [k, v] : cfg_sorted) {
serialized += k;
serialized += '=';
serialized += v;
serialized += '|';
}
// FNV-1a 64-bit hash
std::uint64_t h = 14695981039346656037ULL;
for (char c : serialized) {
h ^= static_cast<std::uint64_t>(static_cast<unsigned char>(c));
h *= 1099511628211ULL;
}
return std::format("{:016x}", h);
}
static std::string db_file() {
return (fs::path(paths::db_dir()) / "installed").string();
}
static std::uint64_t fnv1a(std::string_view s) {
std::uint64_t h = 14695981039346656037ULL;
for (char c : s) { h ^= static_cast<std::uint64_t>(static_cast<unsigned char>(c)); h *= 1099511628211ULL; }
return h;
}
InstallResult install(const resolve::BuildStep& step,
const fs::path& work_dir) {
InstallResult result;
auto hash = std::format("{:016x}",
fnv1a(step.name + "\0" + step.resolved.original.version));
result.hash = hash;
auto dest = fs::path(paths::bin_dir()) / hash;
result.store_path = dest;
std::error_code ec;
fs::create_directories(dest, ec);
if (ec) {
result.error = "cannot create store directory";
return result;
}
auto src = work_dir / "destdir";
if (fs::exists(src)) {
for (auto& entry : fs::recursive_directory_iterator(src, ec)) {
if (ec) { break; }
auto rel = fs::relative(entry.path(), src);
auto target = dest / rel;
if (entry.is_directory()) {
fs::create_directories(target, ec);
} else {
fs::create_directories(target.parent_path(), ec);
if (!ec) { fs::rename(entry.path(), target, ec); }
}
if (ec) { break; }
}
}
auto entries = read_installed();
std::string ch = "0000000000000000";
if (!step.resolved.features.empty() || !step.resolved.config.empty()) {
ch = compute_config_hash(step.resolved.features, step.resolved.config);
}
entries.push_back({step.name, step.resolved.original.version,
hash, ch, step.resolved.original.provides});
if (!write_installed(entries)) {
result.error = "failed to write installed DB";
return result;
}
std::vector<std::string> hashes;
for (auto& e : entries) { hashes.push_back(e.hash); }
record_generation(hashes, 5);
result.ok = true;
return result;
}
std::vector<DbEntry> read_installed() {
std::vector<DbEntry> entries;
std::ifstream in(db_file());
if (!in) { return entries; }
std::string line;
while (std::getline(in, line)) {
if (line.empty()) { continue; }
std::istringstream iss(line);
DbEntry e;
iss >> e.name >> e.version >> e.hash;
std::string token;
auto pos = iss.tellg();
if (iss >> token && token.size() == 16
&& std::all_of(token.begin(), token.end(), [](char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
})) {
e.config_hash = token;
} else {
if (pos != std::streampos(-1)) {
iss.clear();
iss.seekg(pos);
}
}
std::string prov;
while (iss >> prov) { e.provides.push_back(prov); }
entries.push_back(std::move(e));
}
return entries;
}
bool write_installed(const std::vector<DbEntry>& entries) {
std::error_code ec;
fs::create_directories(paths::db_dir(), ec);
auto tmp = db_file() + ".tmp";
{
std::ofstream out(tmp);
if (!out) return false;
for (auto& e : entries) {
out << e.name << ' ' << e.version << ' ' << e.hash;
if (!e.config_hash.empty() && e.config_hash != "0000000000000000") {
out << ' ' << e.config_hash;
}
for (auto& p : e.provides) { out << ' ' << p; }
out << '\n';
}
out.close();
if (out.fail()) {
std::filesystem::remove(tmp, ec);
return false;
}
}
std::filesystem::rename(tmp, db_file(), ec);
return !ec;
}
bool record_generation(const std::vector<std::string>& hashes, int keep) {
auto gen_dir = fs::path(paths::db_dir()) / "generations";
std::error_code ec;
fs::create_directories(gen_dir, ec);
int gen = 1;
for (auto& entry : fs::directory_iterator(gen_dir, ec)) {
auto name = entry.path().filename().string();
if (name.starts_with("gen-")) { ++gen; }
}
auto gen_file = gen_dir / std::format("gen-{:04d}", gen);
std::ofstream out(gen_file);
if (!out) { return false; }
for (auto& h : hashes) { out << h << '\n'; }
std::vector<fs::path> gens;
for (auto& entry : fs::directory_iterator(gen_dir, ec)) {
gens.push_back(entry.path());
}
std::sort(gens.begin(), gens.end());
while (static_cast<int>(gens.size()) > keep && keep > 0) {
fs::remove_all(gens.front(), ec);
gens.erase(gens.begin());
}
return true;
}
} // namespace kappa::install
+1194 -9
View File
File diff suppressed because it is too large Load Diff
+46 -6
View File
@@ -1,16 +1,56 @@
#include "kappa/paths.hpp"
#include <cstdlib>
#include <system_error>
namespace kappa::paths {
void ensure_directories() {
std::error_code ec;
static std::filesystem::path g_root = [] {
if (auto* env = std::getenv("KAPPA_ROOT"); env != nullptr) {
return std::filesystem::path{env};
}
return std::filesystem::path{"/usr/local/kappa"};
}();
std::filesystem::create_directories(bin_dir, ec);
std::filesystem::create_directories(temp_dir, ec);
std::filesystem::create_directories(db_dir, ec);
std::filesystem::create_directories(builds_dir, ec);
void set_root(std::string_view path) { g_root = path; }
std::filesystem::path root_path() { return g_root; }
std::filesystem::path bin_dir() { return g_root / "bin"; }
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"; }
bool ensure_directories() {
std::error_code ec;
std::filesystem::create_directories(bin_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(temp_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(db_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(system_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(builds_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(cache_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(packages_dir(), ec);
if (ec) return false;
return true;
}
bool directories_exist() {
return std::filesystem::exists(g_root)
&& std::filesystem::exists(bin_dir())
&& std::filesystem::exists(temp_dir())
&& std::filesystem::exists(db_dir())
&& std::filesystem::exists(system_dir())
&& std::filesystem::exists(builds_dir())
&& std::filesystem::exists(cache_dir())
&& std::filesystem::exists(packages_dir());
}
} // namespace kappa::paths
+132
View File
@@ -0,0 +1,132 @@
#include "kappa/rebuild/rebuild.hpp"
#include "kappa/install/install.hpp"
#include "kappa/service/types.hpp"
#include <iostream>
#include <set>
namespace kappa::rebuild {
ChangeSet compute_changes(const dsl::SystemConfig& cfg) {
ChangeSet cs;
auto installed = install::read_installed();
std::set<std::string> installed_names;
for (auto& e : installed) { installed_names.insert(e.name); }
std::set<std::string> config_names;
for (auto& p : cfg.packages) { config_names.insert(p.name); }
for (auto& p : cfg.packages) {
if (!installed_names.contains(p.name)) {
cs.added.push_back(p.name);
continue;
}
bool found = false;
for (auto& e : installed) {
if (e.name != p.name) { continue; }
found = true;
bool changed = false;
if (!p.version.empty() && p.version != e.version) {
changed = true;
}
if (!changed && (!p.features.empty() || !p.config.empty())) {
if (e.config_hash.empty()) {
changed = true;
} else {
auto ch = install::compute_config_hash(p.features, p.config);
changed = (ch != e.config_hash);
}
}
if (changed) { cs.changed.push_back(p.name); }
break;
}
if (!found) { cs.added.push_back(p.name); }
}
for (auto& e : installed) {
if (!config_names.contains(e.name)) {
cs.removed.push_back(e.name);
}
}
for (auto& e : installed) {
// 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.version != cfg.boot.init) {
cs.init_changed = true;
}
if (e.name == "bootloader" && e.version != cfg.boot.bootloader) {
cs.bootloader_changed = true;
}
}
for (auto& svc : cfg.services) {
if (svc.enable) { cs.services_changed = true; break; }
}
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
+147
View File
@@ -0,0 +1,147 @@
#include "kappa/resolve/plan.hpp"
#include "kappa/config/merge.hpp"
#include <format>
#include <queue>
#include <unordered_set>
namespace kappa::resolve {
BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) {
BuildPlan plan;
std::unordered_map<std::string, std::size_t> name_to_idx;
std::vector<BuildStep> nodes;
for (auto& pref : cfg.packages) {
auto rit = registry.find(pref.name);
if (rit == registry.end()) {
plan.missing.push_back(pref.name);
continue;
}
auto& pkg = rit->second;
auto resolved = config::resolve_package(pkg, cfg.system, pref);
BuildStep step;
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()) {
auto fit = step.resolved.features.find(dep.feature);
if (fit == step.resolved.features.end() || !fit->second.enabled) {
continue;
}
}
step.dependencies.push_back({dep.name, dep.version});
}
name_to_idx[step.name] = nodes.size();
nodes.push_back(std::move(step));
}
// Transitive dependency discovery: pull in deps that are in the
// registry but not yet in the plan. Continue until no new deps
// are discovered — handles arbitrarily deep dependency chains.
bool changed = true;
while (changed) {
changed = false;
std::size_t current_size = nodes.size();
for (std::size_t i = 0; i < current_size; ++i) {
if (!nodes[i].package) continue;
for (auto& dep : nodes[i].package->depends) {
if (name_to_idx.contains(dep.name)) continue;
// Skip feature-conditional deps that aren't enabled
if (!dep.feature.empty()) {
auto fit = nodes[i].resolved.features.find(dep.feature);
if (fit == nodes[i].resolved.features.end() || !fit->second.enabled) {
continue;
}
}
auto rit = registry.find(dep.name);
if (rit == registry.end()) continue;
auto& pkg = rit->second;
auto resolved = config::resolve_package(pkg, cfg.system, {});
BuildStep step;
step.name = pkg.name;
step.package = &pkg;
step.resolved = std::move(resolved);
step.enabled_init = cfg.boot.init;
for (auto& ddep : pkg.depends) {
if (!ddep.feature.empty()) {
auto fit = step.resolved.features.find(ddep.feature);
if (fit == step.resolved.features.end() || !fit->second.enabled) {
continue;
}
}
step.dependencies.push_back({ddep.name, ddep.version});
}
name_to_idx[step.name] = nodes.size();
nodes.push_back(std::move(step));
changed = true;
}
}
}
// Detect conflicts between all selected packages (including transitive)
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());
for (std::size_t i = 0; i < nodes.size(); ++i) {
for (auto& dep : nodes[i].dependencies) {
auto it = name_to_idx.find(dep.name);
if (it != name_to_idx.end()) {
adj[it->second].push_back(i);
in_degree[i]++;
}
}
}
std::queue<std::size_t> q;
for (std::size_t i = 0; i < nodes.size(); ++i) {
if (in_degree[i] == 0) { q.push(i); }
}
std::vector<bool> visited(nodes.size(), false);
while (!q.empty()) {
auto u = q.front(); q.pop();
visited[u] = true;
plan.steps.push_back(nodes[u]);
for (auto v : adj[u]) {
if (--in_degree[v] == 0) { q.push(v); }
}
}
for (std::size_t i = 0; i < nodes.size(); ++i) {
if (!visited[i]) {
plan.cycles.push_back(nodes[i].name);
}
}
return plan;
}
} // namespace kappa::resolve
+290
View File
@@ -0,0 +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) {
if (plan.steps.empty()) {
return {true, {}, {}};
}
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++;
}
}
// 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
+71
View File
@@ -0,0 +1,71 @@
#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 — dinit uses env-file directive
if (!spec.env.empty()) {
out << std::format("env-file = {}.env\n", spec.name);
}
// description
if (!spec.description.empty()) {
out << std::format("description = {}\n", spec.description);
}
return out.str();
}
} // namespace kappa::service
+213
View File
@@ -0,0 +1,213 @@
#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 << s6_execline_shebang;
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;
}
// Dinit: also write companion .env file
if (is == InitSystem::Dinit && !spec.env.empty()) {
fs::path env_path = fs::path(paths.service_dir) / std::format("{}.env", spec.name);
std::ofstream env_out(env_path);
if (env_out) {
for (const auto& [key, value] : spec.env) {
env_out << key << "=" << value << "\n";
}
}
}
return {true, file_path.string(), {}};
}
} // namespace kappa::service
+83
View File
@@ -0,0 +1,83 @@
#include "kappa/service/service.hpp"
#include "kappa/util.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
namespace {
static constexpr std::string_view openrc_run_shebang = "#!/sbin/openrc-run\n";
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 << openrc_run_shebang;
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 << s6_execline_shebang;
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
+99
View File
@@ -0,0 +1,99 @@
#include "kappa/service/types.hpp"
#include "kappa/util.hpp"
#include <algorithm>
#include <cctype>
#include <filesystem>
#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::filesystem::path(prefix) / "etc/systemd/system").string(),
.enable_cmd = "systemctl enable",
.disable_cmd = "systemctl disable",
};
case InitSystem::OpenRC:
return {
.service_dir = (std::filesystem::path(prefix) / "etc/init.d").string(),
.enable_cmd = "rc-update add",
.disable_cmd = "rc-update del",
};
case InitSystem::S6:
return {
.service_dir = (std::filesystem::path(prefix) / "etc/s6/sv").string(),
.enable_cmd = "s6-rc-bundle-update",
.disable_cmd = "s6-rc-bundle-update delete",
};
case InitSystem::Runit:
return {
.service_dir = (std::filesystem::path(prefix) / "etc/sv").string(),
.enable_cmd = std::format("ln -sf {}/etc/sv/{{name}} {}/var/service/", prefix, prefix),
.disable_cmd = std::format("rm -f {}/var/service/{{name}}", prefix),
};
case InitSystem::Dinit:
return {
.service_dir = (std::filesystem::path(prefix) / "etc/dinit.d").string(),
.enable_cmd = "dinitctl enable",
.disable_cmd = "dinitctl disable",
};
case InitSystem::Unknown:
return {};
}
return {};
}
} // namespace kappa::service
+79
View File
@@ -0,0 +1,79 @@
#include "kappa/system/activate.hpp"
#include <array>
#include <filesystem>
#include <format>
#include <fstream>
#include <string_view>
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 zoneinfo data
std::filesystem::path localtime = std::filesystem::path(prefix) / "etc/localtime";
static constexpr std::array<std::string_view, 4> zoneinfo_dirs = {
"usr/share/zoneinfo", // glibc/FHS standard
"etc/zoneinfo", // NixOS
"share/zoneinfo", // Guix, some prefix installs
"usr/lib/zoneinfo", // alternative
};
std::filesystem::path zoneinfo;
for (auto dir : zoneinfo_dirs) {
auto candidate = std::filesystem::path(prefix) / dir / timezone;
if (std::filesystem::exists(candidate)) {
zoneinfo = candidate;
break;
}
}
if (zoneinfo.empty()) {
std::string tried;
for (size_t i = 0; i < zoneinfo_dirs.size(); ++i) {
if (i > 0) tried += ", ";
tried += (std::filesystem::path(prefix) / zoneinfo_dirs[i] / timezone).string();
}
return {false,
std::format("timezone data '{}' not found in any known location: tried {}",
timezone, tried)};
}
std::error_code ec;
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
+167
View File
@@ -0,0 +1,167 @@
#include "kappa/tools/doctor.hpp"
#include "kappa/boot/types.hpp"
#include "kappa/service/types.hpp"
#include <filesystem>
#include <sys/stat.h>
namespace kappa::tools {
std::vector<Diagnostic> check_package(const dsl::PackageDef& pkg) {
std::vector<Diagnostic> diags;
if (pkg.name.empty()) {
diags.push_back({DiagSeverity::Error, "package name is empty"});
}
if (pkg.version.empty()) {
diags.push_back({DiagSeverity::Error, "version is not set"});
}
if (pkg.source.empty()) {
diags.push_back({DiagSeverity::Error, "source URL is not set"});
}
if (pkg.license.empty()) {
diags.push_back({DiagSeverity::Warning, "license is not specified"});
}
for (auto& [key, feat] : pkg.features) {
if (feat.force && !feat.enabled) {
diags.push_back({DiagSeverity::Warning,
"feature '" + key + "' is forced but disabled"});
}
}
for (auto& dep : pkg.depends) {
if (dep.name.empty()) {
diags.push_back({DiagSeverity::Warning,
"dependency has an empty name"});
}
}
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"});
}
for (auto& cf : pkg.config_files) {
if (cf.entries.empty()) {
diags.push_back({DiagSeverity::Warning,
"config file '" + cf.path + "' has no entries"});
}
}
if (!pkg.assertions.empty()) {
diags.push_back({DiagSeverity::Warning,
std::to_string(pkg.assertions.size())
+ " assertions defined — run 'kappa validate' to check them"});
}
return diags;
}
std::vector<Diagnostic> check_config(const dsl::SystemConfig& cfg) {
std::vector<Diagnostic> diags;
if (cfg.system.hostname.empty()) {
diags.push_back({DiagSeverity::Warning, "system hostname is not set"});
}
if (cfg.boot.kernel.empty()) {
diags.push_back({DiagSeverity::Warning, "boot kernel is not specified"});
}
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()) {
diags.push_back({DiagSeverity::Warning, "no packages declared — nothing will be installed"});
}
for (auto& svc : cfg.services) {
if (svc.enable && svc.name.empty()) {
diags.push_back({DiagSeverity::Warning, "enabled service has no name"});
}
}
if (cfg.users.empty()) {
diags.push_back({DiagSeverity::Warning, "no users defined — root account has no shell"});
}
for (auto& u : cfg.users) {
if (u.name == "root" && u.shell.empty()) {
diags.push_back({DiagSeverity::Error, "root user has no shell set"});
}
}
if (cfg.system.rollback.keep < 0) {
diags.push_back({DiagSeverity::Error, "rollback keep is negative"});
}
if (!cfg.assertions.empty()) {
diags.push_back({DiagSeverity::Warning,
std::to_string(cfg.assertions.size())
+ " 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;
}
} // namespace kappa::tools
+384
View File
@@ -0,0 +1,384 @@
#include "kappa/tools/format.hpp"
namespace kappa::tools {
namespace {
struct Indent {
int n = 0;
Indent(int i) : n(i) {}
friend std::ostream& operator<<(std::ostream& os, const Indent& in) {
for (int i = 0; i < in.n; ++i) { os << " "; }
return os;
}
};
static void write_env(std::ostream& os, int d,
const std::vector<dsl::EnvEntry>& entries) {
if (entries.empty()) { return; }
os << Indent(d) << "env {\n";
for (auto& e : entries) {
const char* op;
switch (e.mode) {
case dsl::EnvMode::Soft: op = " ?= "; break;
case dsl::EnvMode::Append: op = " += "; break;
default: op = " = "; break;
}
os << Indent(d + 1) << e.key << op
<< '"' << e.value << "\"\n";
}
os << Indent(d) << "}\n";
}
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 : keys) {
auto& f = feats.at(k);
if (f.flag.empty() && !f.force) {
os << Indent(d + 1) << k << " = " << (f.enabled ? "true" : "false") << "\n";
} else {
os << Indent(d + 1) << k << " = { enabled = "
<< (f.enabled ? "true" : "false");
if (f.force) { os << ", force = true"; }
if (!f.flag.empty()) { os << ", flag = \"" << f.flag << "\""; }
os << " }\n";
}
}
os << Indent(d) << "}\n";
}
static void write_phase(std::ostream& os, int d, const char* name,
const dsl::Phase& phase) {
if (phase.commands.empty()) { return; }
os << Indent(d) << name << " {\n";
for (auto& cmd : phase.commands) {
os << Indent(d + 1) << cmd << "\n";
}
os << Indent(d) << "}\n";
}
static void write_boot_block(std::ostream& os, int d, const dsl::BootBlock& boot) {
os << Indent(d) << "boot {\n";
auto w = [&](const char* k, const std::string& v) {
if (!v.empty()) { os << Indent(d + 1) << k << " = \"" << v << "\"\n"; }
};
w("kernel", boot.kernel);
w("init", boot.init);
w("efi", boot.efi);
w("swap", boot.swap);
w("root", boot.root);
w("bootloader", boot.bootloader);
for (auto& [k, v] : boot.params) {
os << Indent(d + 1) << k << " = \"" << v << "\"\n";
}
os << Indent(d) << "}\n";
}
} // namespace
void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
os << "package \"" << pkg.name << "\" {\n";
int d = 1;
if (!pkg.version.empty()) {
auto c = pkg.const_keys.contains("version") ? "const " : "";
os << Indent(d) << c << "version = \"" << pkg.version << "\"\n";
}
if (!pkg.source.empty()) {
auto c = pkg.const_keys.contains("source") ? "const " : "";
os << Indent(d) << c << "source = \"" << pkg.source << "\"\n";
}
if (!pkg.license.empty()) {
os << Indent(d) << "license = \"" << pkg.license << "\"\n";
}
if (!pkg.provides.empty()) {
os << Indent(d) << "provides = [";
for (std::size_t i = 0; i < pkg.provides.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << pkg.provides[i] << '"';
}
os << "]\n";
}
if (!pkg.outputs.empty()) {
os << Indent(d) << "outputs = [";
for (std::size_t i = 0; i < pkg.outputs.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << pkg.outputs[i] << '"';
}
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) {
if (p.sha256.empty()) {
os << Indent(d + 1) << '"' << p.url << "\",\n";
} else {
os << Indent(d + 1) << "{ url = \"" << p.url
<< "\", sha256 = \"" << p.sha256
<< "\", level = " << p.level << " },\n";
}
}
os << Indent(d) << "]\n";
}
if (!pkg.depends.empty()) {
os << Indent(d) << "depends = [\n";
for (auto& dep : pkg.depends) {
os << Indent(d + 1);
if (dep.version.empty() && dep.output.empty() && dep.feature.empty()) {
os << '"' << dep.name << '"';
} else {
os << "{ name = \"" << dep.name << '"';
if (!dep.version.empty()) { os << ", version = \"" << dep.version << '"'; }
if (!dep.output.empty()) { os << ", output = \"" << dep.output << '"'; }
if (!dep.feature.empty()) { os << ", feature = \"" << dep.feature << '"'; }
os << " }";
}
os << ",\n";
}
os << Indent(d) << "]\n";
}
write_features(os, d, pkg.features);
write_env(os, d, pkg.env_entries);
for (auto& cf : pkg.config_files) {
os << Indent(d) << "config {\n";
os << Indent(d + 1) << "file \"" << cf.path
<< "\" mode = \"" << cf.mode << "\" {\n";
for (auto& [k, v] : cf.entries) {
os << Indent(d + 2) << k << " = " << v << "\n";
}
os << Indent(d + 1) << "}\n";
os << Indent(d) << "}\n";
}
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 << ns.ports[i];
}
os << "]\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) << "}\n";
}
}
if (!pkg.assertions.empty()) {
os << Indent(d) << "assert {\n";
for (auto& a : pkg.assertions) {
os << Indent(d + 1) << '"' << a.message << "\" : "
<< a.field << " " << a.op;
if (!a.value.empty()) { os << " \"" << a.value << '"'; }
os << "\n";
}
os << Indent(d) << "}\n";
}
write_phase(os, d, "prepare", pkg.prepare);
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";
}
void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
if (!cfg.imports.empty()) {
os << "imports = [";
for (std::size_t i = 0; i < cfg.imports.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << cfg.imports[i] << '"';
}
os << "]\n\n";
}
if (!cfg.remotes.empty()) {
os << "remotes = [\n";
for (auto& r : cfg.remotes) {
os << " \"" << r << "\",\n";
}
os << "]\n\n";
}
if (!cfg.repos.empty()) {
os << "repos {\n";
for (auto& repo : cfg.repos) {
os << " " << repo.name << " {\n";
os << " url = \"" << repo.url << "\"\n";
if (!repo.channels.empty()) {
os << " channels = [";
for (std::size_t i = 0; i < repo.channels.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << repo.channels[i] << '"';
}
os << "]\n";
}
if (!repo.mirrors.empty()) {
os << " mirrors = [\n";
for (auto& m : repo.mirrors) {
os << " \"" << m << "\",\n";
}
os << " ]\n";
}
if (repo.priority != 50) {
os << " priority = " << repo.priority << "\n";
}
os << " }\n";
}
os << "}\n\n";
}
if (!cfg.assertions.empty()) {
os << "assert {\n";
for (auto& a : cfg.assertions) {
os << " \"" << a.message << "\" : "
<< a.field << " " << a.op
<< " \"" << a.value << '"' << "\n";
}
os << "}\n\n";
}
auto& s = cfg.system;
os << "system {\n";
if (!s.hostname.empty()) { os << " hostname = \"" << s.hostname << "\"\n"; }
if (!s.timezone.empty()) { os << " timezone = \"" << s.timezone << "\"\n"; }
write_features(os, 1, s.features);
write_env(os, 1, s.env);
if (!s.config.empty()) {
os << " config {\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";
}
if (s.rollback.keep > 0) {
os << " rollback {\n keep = " << s.rollback.keep << "\n }\n";
}
os << "}\n\n";
if (!cfg.packages.empty()) {
os << "packages {\n";
for (auto& p : cfg.packages) {
os << " " << p.name;
if (p.version.empty() && p.features.empty() && p.config.empty()) {
os << " {}\n";
continue;
}
os << " {\n";
if (!p.version.empty()) { os << " version = \"" << p.version << "\"\n"; }
write_features(os, 2, p.features);
if (!p.config.empty()) {
os << " config {\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";
}
os << " }\n";
}
os << "}\n\n";
}
if (!cfg.services.empty()) {
os << "services {\n";
for (auto& svc : cfg.services) {
os << " " << svc.name << " {\n";
os << " enable = " << (svc.enable ? "true" : "false") << "\n";
for (auto& [k, v] : svc.config) {
os << " " << k << " = " << v << "\n";
}
os << " }\n";
}
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";
if (!cfg.users.empty()) {
os << "users {\n";
for (auto& u : cfg.users) {
os << " " << u.name << " {\n";
if (!u.shell.empty()) { os << " shell = \"" << u.shell << "\"\n"; }
if (!u.groups.empty()) {
os << " groups = [";
for (std::size_t i = 0; i < u.groups.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << u.groups[i] << '"';
}
os << "]\n";
}
for (auto& [k, v] : u.extra) {
os << " " << k << " = \"" << v << "\"\n";
}
os << " }\n";
}
os << "}\n";
}
}
void format_index(std::ostream& os, const dsl::IndexDef& idx) {
os << "index \"" << idx.name << "\" {\n";
for (auto& pkg : idx.packages) {
os << " " << pkg.name << " { version = \"" << pkg.version << "\" }\n";
}
os << "}\n";
}
} // namespace kappa::tools
+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;
}
}
Executable
+1201
View File
File diff suppressed because it is too large Load Diff
+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 --dry-run /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 --dry-run /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
+226
View File
@@ -0,0 +1,226 @@
#!/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 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