diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a6265b..a2a4935 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,7 @@ endif() add_executable(kappa src/main.cpp + src/util.cpp src/paths.cpp src/dsl/lexer.cpp src/dsl/parser.cpp @@ -33,9 +34,22 @@ add_executable(kappa src/tools/doctor.cpp src/resolve/plan.cpp src/fetch/fetch.cpp + src/fetch/recipe.cpp src/build/build.cpp src/sched/scheduler.cpp src/install/install.cpp src/rebuild/rebuild.cpp + src/service/s6.cpp + src/service/systemd.cpp + src/service/types.cpp + src/service/openrc.cpp + src/service/dinit.cpp + src/service/runit.cpp + src/service/install.cpp + src/boot/types.cpp + src/boot/limine.cpp + src/boot/grub.cpp + src/boot/install.cpp + src/system/activate.cpp ) target_include_directories(kappa PRIVATE include) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b245e25 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,137 @@ +# Contributing to kappa + +We're building a package manager that doesn't care about your init system, +your bootloader, or your life choices. If that sounds like your kind of +project, keep reading. + +## The rules + +These aren't guidelines. They're the deal. + +### 1. C++23 or don't bother + +We compile with Clang, `-std=c++23`, and `-Werror`. If your code needs a +polyfill for `std::format` or can't handle designated initializers, it +doesn't belong here. The standard library is your only dependency. Zero +external C++ libraries. Not even Boost. + +```cpp +// ✓ yes +auto msg = std::format("building {} (depth={})", name, depth); + +// ✗ no +auto msg = fmt::format("building {} (depth={})", name, depth); +``` + +### 2. Every new module mirrors the existing structure + +``` +include/kappa/{module}/ +├── types.hpp # enums, structs, parse/validate declarations +├── {feature}.hpp # public interface +src/{module}/ +├── types.cpp # implementations +├── backend_a.cpp # per-variant generators +├── backend_b.cpp +└── install.cpp # dispatch + orchestration +``` + +If you're adding a feature, look at `src/service/` or `src/boot/` for the +pattern. If your new module doesn't look like those, you're doing it wrong. + +### 3. Never suppress type safety + +There is no `as any`, no `@ts-ignore`, no `reinterpret_cast` abuse, and +no `void*` unless you're talking to the kernel. If the type system is +fighting you, you're fighting the design. Fix the design. + +### 4. Use the namespace. All of it. + +```cpp +namespace kappa::module { + // everything goes here +} // namespace kappa::module +``` + +No `using namespace std;` at file scope. No anonymous namespaces for +functions that are used across files — extract to `util.hpp` instead. + +### 5. Thread safety is not optional + +The scheduler is multithreaded. If you touch shared state, you own the +lock. `std::mutex`, `std::atomic`, `std::condition_variable` — use them +correctly or don't use them at all. If you don't know what `memory_order` +means, stay out of the scheduler. + +### 6. Tests are shell scripts. For now. + +Integration tests live in `test.sh` and `test-init-switch.sh`. They +exercise the CLI. If you add a subcommand, add a test. C++ unit tests +are welcome — set up Google Test or Catch2 in CMake and we'll merge it. + +```sh +./test.sh # 31 tests, must pass +./test-init-switch.sh # 23 tests, must pass +``` + +### 7. Backward compatibility is mandatory + +The `.kap` DSL is the contract. You can add keywords. You cannot remove +them. You can extend syntax. You cannot break existing configs. If your +change means someone's `config.kap` stops parsing, it doesn't ship. + +## How to contribute + +### Pick something + +Good first issues: +- Adding a 6th init system backend +- Adding a 3rd bootloader backend +- C++ unit test framework setup +- Shell completion scripts + +Ambitious issues: +- Binary package support (pre-built caches) +- Remote build farm (distcc-style) +- Signed package verification +- Filesystem overlay activation (like Nix profiles) + +### Send a PR + +1. Fork the repo +2. Create a branch: `feat/my-thing` or `fix/my-bug` +3. Write code that follows the rules above +4. Run `./test.sh && ./test-init-switch.sh` — both must pass +5. Open a PR against `main` + +### PR requirements + +- Build must pass: `cmake --build build` with zero warnings +- Tests must pass: both shell test suites +- No commented-out code. No dead code. No TODO without a date. +- Commit messages in imperative: `Add runit backend` not `Added runit backend` + +## What we won't merge + +- **`systemd`-only features.** If it can't work on at least two init systems, + it goes in a `systemd` package definition, not in kappa. +- **Dependency on a specific distro.** Kappa runs on any Linux kernel. No + hardcoded paths to `/usr/lib/systemd`, no assumptions about `/etc/os-release`. +- **Abstract nonsense.** FactoryFactoryBuilder patterns. Premature + generalization. If you need three layers of indirection to add a feature, + the feature is too complicated. +- **AI slop.** If it looks like ChatGPT wrote it, it gets rejected. We can + tell. Write code like a human who's been doing this for a decade. + +## Communication + +We don't have a Discord. We don't have a forum. Open an issue. Write a +clear title, a reproduction case, and what you expected. We'll respond +when we respond. + +If you want to propose a major feature, open an issue first. Surprise PRs +that rewrite half the codebase get closed without review. + +--- + +Kappa is 0.1.0. Everything is subject to change except the rules above. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cee74a3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 0e71845..4bac920 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,154 @@ +

+ kappa mascot +

+ # kappa -A alternative implementation of Iota for the ZereneOS project. \ No newline at end of file +**Anywhere, any init, anytime.** + +A declarative, source-based package manager that doesn't care what init system +you run. Or what bootloader. Or what CPU architecture. Kappa builds your entire +system from source — and lets you swap the init system like you'd swap a +wallpaper. + +--- + +### Why + +Every other package manager picked a side. `apt` married systemd. `pacman` +shackled itself to Arch's ecosystem. `emerge` gave you choice but at the cost +of your weekend. Nix gave you reproducibility but took your filesystem with it. + +Kappa is what you get when you stop negotiating. You declare what your system +*is*, and kappa figures out how to build it. Change your mind about the init +system? Rebuild only the packages that care — the other 800 stay put. + +### What it does + +``` +# Your system, in one file: +boot { + kernel = "linux" + init = "s6" # swap to "systemd" anytime + bootloader = "limine" # or "grub" + root = "/dev/sda1" +} + +packages { + nginx { version = ">=1.24" } + postgresql {} + zlib {} +} + +services { + nginx { enable = true } +} +``` + +Then: + +```sh +kappa rebuild config.kap # builds everything, generates service files +kappa rebuild config.kap # boot.init = "openrc" — only 5 packages actually rebuild +``` + +### Features that nobody else has + +- **Init-system-as-configuration.** `boot.init = "s6"` → generates s6 service + directories. Change it to `"systemd"` → regenerates `.service` units. Change + it to `"openrc"` → generates init.d scripts. The package definitions don't + know or care which init you picked. That's kappa's problem. + +- **Post-install init switching.** Change `boot.init`, run `kappa rebuild`, reboot. + You're now on a different init system. Only packages that actually use + `${enabledinit}` in their build scripts need recompiling. Everything else + just gets new service files generated. + +- **Bootloader rollback.** Every rebuild creates a fallback boot entry pointing + at the previous generation's init. If the new one doesn't boot, the old one + is one reboot away. + +- **Parallel scheduler.** `-w 4 -j 8` means four packages building + simultaneously, eight jobs each. The scheduler uses depth-based priority + grouping so leaf dependencies unblock as much work as possible first. + +- **Package recipe caching.** Declare `remotes = ["https://repo.example.com/"]` + in your config. Kappa fetches `.kap` files on demand, caches them, and only + re-fetches when the remote version is newer. + +- **Source tarball caching.** Downloaded once, stored at `/kappa/cache/`. + Rebuilds don't touch the network unless versions change. + +- **Conflicts.** `systemd` declares `conflicts = ["eudev", "elogind"]`. The + resolver catches mutual incompatibility before a build starts. + +- **Init-agnostic system config.** `groups { wheel { gid = 998 } }` — kappa + creates the groups. `system { hostname = "mybox" }` — kappa writes + `/etc/hostname`. No `systemctl`, no `rc-update`, no init dependency. + +### 5 init systems. 2 bootloaders. Zero lock-in. + +| Init | Service location | Enable command | +|------|-----------------|----------------| +| systemd | `/etc/systemd/system/{name}.service` | `systemctl enable` | +| openrc | `/etc/init.d/{name}` | `rc-update add` | +| s6 | `/etc/s6/sv/{name}/run` | `s6-rc-bundle-update` | +| runit | `/etc/sv/{name}/run` | `ln -sf /etc/sv/{name} /var/service/` | +| dinit | `/etc/dinit.d/{name}` | `dinitctl enable` | + +| Bootloader | Config path | +|-----------|------------| +| limine | `/boot/limine.cfg` | +| grub | `/boot/grub/grub.cfg` | + +### Quick start + +```sh +# Build kappa (needs Clang 17+, CMake 3.20+, C++23) +cmake -B build -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ +cmake --build build + +# Write a config +cat > system.kap << 'EOF' +system { hostname = "kappa.local" } +packages { nginx {} } +services { nginx { enable = true } } +boot { + kernel = "linux"; init = "s6"; root = "/dev/sda1"; bootloader = "limine" +} +users { root { shell = "/bin/zsh" } } +remotes = ["https://packages.kappa-os.org/stable/"] +EOF + +# Parse it +build/kappa parse-config system.kap + +# Rebuild +build/kappa rebuild system.kap +``` + +### Subcommands + +| Command | What it does | +|---------|-------------| +| `parse-package ` | Validate a `.kap` package definition | +| `parse-config ` | Validate a system configuration | +| `validate ` | Validate any kappa file | +| `format ` | Pretty-print to canonical style | +| `doctor ` | Check for issues and warnings | +| `resolve ` | Compute a build plan | +| `fetch ` | Download and verify source tarballs | +| `fetch-package ` | Fetch a package recipe from remotes | +| `build ` | Build a single package | +| `rebuild ` | Diff config against installed state, rebuild changed | +| `list` | Show installed packages | +| `rollback` | Show available generations | + +### License + +BSD 2-Clause. Do whatever you want. Just don't sue us. + +### Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). We're opinionated but we merge good +code. diff --git a/examples/config.kap b/examples/config.kap index 3a8e86d..2d11db3 100644 --- a/examples/config.kap +++ b/examples/config.kap @@ -1,10 +1,55 @@ /* * Kappa system configuration. * Lives at /kappa/system/config.kap + * + * INIT SYSTEM SELECTION + * ===================== + * The `boot.init` field (line ~84) selects which init system manages this + * machine. Valid values (case-insensitive): + * + * systemd — system and service manager + * openrc — OpenRC dependency-based init + * s6 — s6 supervision suite + * dinit — dinit service manager / init system + * + * The `boot.bootloader` field (line ~122) selects which bootloader config + * kappa generates (grub or limine). + * + * This setting determines: + * 1. Which backend generates service files at install time + * (systemd → .service units, openrc → init.d scripts, etc.) + * 2. What `${enabledinit}` resolves to during package builds + * + * Package definitions do NOT specify per-init service blocks. A package + * defines its service once (see examples/foo.kap) and the selected init + * system's backend handles the translation. + * + * SERVICES BLOCK + * ============== + * The `services` section enables or disables services declared by + * installed packages. Each entry maps to a package's service name: + * + * services { + * nginx { enable = true } // package "nginx", default "main" service + * postgresql.main { enable = true } // package "postgresql", named service "main" + * postgresql.checkpointer { enable = false } + * } + * + * For single-service packages, the service name defaults to "main" and + * can be omitted. For multi-service packages, use dot-notation + * (pkgname.servicename) to target a specific named service. + * + * Additional keys in each service block (port, ssl, etc.) are passed as + * custom config to the service definition. */ imports = [] +remotes = [ + "https://packages.kappa-os.org/stable/", + "https://packages.kappa-os.org/contrib/", +] + assert { "efi partition required for UEFI boot" : boot.efi != "" "root partition must be set" : boot.root != "" @@ -75,11 +120,15 @@ services { } } +// Select init system — determines which backend generates service files. +// Valid: systemd, openrc, s6, dinit (case-insensitive). boot { kernel = "linux" init = "s6" efi = "/dev/sda2" swap = "/dev/sda3" + // Bootloader — generates the appropriate config at install time. + // Valid: grub, limine (case-insensitive). bootloader = "limine" root = "/dev/sda1" } diff --git a/examples/foo.kap b/examples/foo.kap index 8975e79..d602780 100644 --- a/examples/foo.kap +++ b/examples/foo.kap @@ -1,6 +1,51 @@ /* * foo — a web server with optional SSL and GUI support. - * Demonstrates the full kappa DSL surface. + * + * SERVICE MODEL + * ============= + * Kappa service definitions are init-system-agnostic. The `service` block + * describes what the service IS (exec, type, ports, user) — NOT how each init + * system runs it. The system config's `boot.init` field (see config.kap) + * determines which init system's service files get generated at install time: + * + * boot.init = "systemd" → generates .service unit files + * boot.init = "openrc" → generates /etc/init.d scripts + * boot.init = "s6" → generates s6 service directories + * boot.init = "dinit" → generates dinit service descriptors + * boot.init = "runit" → generates runit service directories + * + * Per-init blocks (service { systemd { ... } s6 { ... } }) do NOT exist. + * If a package genuinely needs init-specific behaviour (e.g. different + * ./configure flags for systemd vs. openrc), use ${enabledinit} in the + * build phase — see examples/postgres.kap for that pattern. + * + * SERVICE TYPE VALUES + * =================== + * These are semantic, not init-specific. Each backend translates them + * into its own vocabulary: + * + * "simple" — foreground process; init manages lifecycle directly. + * systemd: Type=simple openrc: command_background=false + * s6: type=longrun dinit: type=process + * + * "forking" — process daemonises itself; init tracks the forked PID. + * systemd: Type=forking openrc: command_background=true + * s6: type=longrun dinit: type=bgprocess + * + * "notify" — foreground process that signals readiness (sd_notify). + * systemd: Type=notify openrc: command_background=true + * s6: type=longrun dinit: type=process + * + * "oneshot" — runs once and exits (startup tasks, database migrations). + * systemd: Type=oneshot openrc: command_background=false + * s6: type=oneshot dinit: type=scripted + * + * "longrun" — long-running supervised process (s6/runit idiom). + * systemd: Type=simple openrc: command_background=true + * s6: type=longrun dinit: type=process + * + * The backend generators handle all translation. Package authors only + * need to pick the semantic type that describes their daemon's behaviour. */ package "foo" { const version = "1.2.3" @@ -8,7 +53,8 @@ package "foo" { sha256 = "e127a709cba24c76de8936cb7083dd768f28cd37eb010492e2f19b71eb1294e4" license = "MIT" - provides = ["libfoo.so.1", "foo"] + provides = ["libfoo.so.1", "foo"] + conflicts = [] // packages this cannot coexist with (e.g. ["eudev"] if this were systemd) patches = [ { @@ -60,18 +106,19 @@ package "foo" { CFLAGS ?= "-g" } + // --- service ---------------------------------------------------------- + // Init-agnostic service definition. The `type` field is semantic + // ("forking") — the selected init system's backend translates it into + // the appropriate native format. If the package ships multiple + // services, use named blocks (see examples/postgres.kap). service { - runit { - exec = "/usr/bin/foo --daemon" - type = "forking" - user = "foo" - } - s6 { - exec = "/usr/bin/foo" - type = "longrun" - ports = [80, 443] - user = "foo" - } + exec = "/usr/bin/foo" + type = "forking" // daemonises itself + user = "foo" + ports = [80, 443] + description = "Foo web server" + after = "network" // ordering hint — systemd After=, OpenRC need, etc. + restart = "on-failure" // "always" | "on-failure" | "never" } prepare { @@ -90,4 +137,8 @@ package "foo" { install { make DESTDIR=${destdir} install } + + uninstall { + make -C build uninstall + } } diff --git a/examples/postgres.kap b/examples/postgres.kap new file mode 100644 index 0000000..333d230 --- /dev/null +++ b/examples/postgres.kap @@ -0,0 +1,148 @@ +/* + * postgresql — a database server shipping multiple services. + * + * MULTI-SERVICE PACKAGES + * ====================== + * Packages that install more than one long-running process can declare + * multiple named `service` blocks. Each has its own exec, type, ports, + * and lifecycle config. The system config enables them individually using + * dot-notation (see config.kap): + * + * services { + * postgresql.main { enable = true } + * postgresql.checkpointer { enable = true } + * postgresql.walwriter { enable = true } + * } + * + * Omitting the dot selects the service named "main". + * + * + * INIT-CONDITIONAL BUILDS + * ======================= + * The variable ${enabledinit} exposes the configured init system name + * (from boot.init in config.kap) during the build phase. Use shell + * conditionals — no DSL if/else needed: + * + * build { + * case ${enabledinit} in + * systemd) ./configure --with-systemd --prefix=${prefix} ;; + * openrc) ./configure --with-openrc --prefix=${prefix} ;; + * s6|dinit) ./configure --prefix=${prefix} ;; + * esac + * make -j${jobs} + * } + * + * ${enabledinit} is interpolated to the literal init system name + * ("systemd", "openrc", "s6", or "dinit") before the shell executes the + * block. No DSL context-sensitive parsing required. + * + * + * SERVICE TYPE TRANSLATION (for reference) + * ======================================== + * semantic │ systemd │ openrc │ s6 │ dinit + * ──────────┼────────────┼─────────────────────┼──────────┼────────── + * simple │ Type=simple│ bg=false │ longrun │ process + * forking │ Type=fork │ bg=true │ longrun │ bgprocess + * notify │ Type=notify│ bg=true │ longrun │ process + * oneshot │ Type=one │ bg=false, args="" │ oneshot │ scripted + * longrun │ Type=simple│ bg=true │ longrun │ process + */ +package "postgresql" { + const version = "16.3" + const source = "https://ftp.postgresql.org/pub/source/v${version}/postgresql-${version}.tar.gz" + sha256 = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + license = "PostgreSQL" + + depends = [ + { name = "readline", version = ">=8" }, + { name = "zlib", version = ">=1.2" }, + { name = "openssl" }, + ] + + conflicts = [] // mutually exclusive packages (e.g. systemd vs eudev) + + features { + ssl = { enabled = true, flag = "--with-ssl=openssl" } + nls = { enabled = true, flag = "--enable-nls" } + systemd = { enabled = false, flag = "--with-systemd" } + } + + config { + file "etc/postgresql/data/postgresql.conf" mode = "default" { + port = ${cfg.port ? 5432} + max_connections = ${cfg.max_conn ? 100} + shared_buffers = ${cfg.shared_buf ? 128MB} + } + } + + env { + CFLAGS = "-O2" + LDFLAGS = "-Wl,--as-needed" + } + + // --- services --------------------------------------------------------- + // PostgreSQL ships the main server plus several auxiliary processes. + // Each runs as a separate service under the init system. + + // Default service (name = "main"). Enabled via: postgresql { enable = true } + service main { + exec = "/usr/bin/postgres -D /var/lib/postgresql/data" + type = "forking" // postmaster daemonises itself + user = "postgres" + ports = [5432] + description = "PostgreSQL database server" + after = "network" + restart = "always" + working_dir = "/var/lib/postgresql" + } + + // Background writer — handles checkpoint I/O. + service checkpointer { + exec = "/usr/bin/postgres-checkpointer" + type = "longrun" + user = "postgres" + description = "PostgreSQL checkpointer process" + restart = "always" + } + + // WAL writer — flushes write-ahead log to disk. + service walwriter { + exec = "/usr/bin/postgres-walwriter" + type = "longrun" + user = "postgres" + restart = "always" + } + + // --- build phases ------------------------------------------------------ + + prepare { + tar xf postgresql-${version}.tar.gz + } + + // init-conditional build: PostgreSQL optionally links against systemd + // for socket activation and service notification. Use ${enabledinit} + // to decide configure flags without per-init service blocks. + build { + case ${enabledinit} in + systemd) ./configure --with-systemd --with-ssl=openssl --prefix=${prefix} ;; + *) ./configure --with-ssl=openssl --prefix=${prefix} ;; + esac + make -j${jobs} world + } + + check { + make check + } + + install { + make DESTDIR=${destdir} install-world + } + + uninstall { + make DESTDIR=${destdir} uninstall-world + } + + assert { + "data directory must exist" : system.config.data_dir != "" + } +} diff --git a/include/kappa/dsl/ast.hpp b/include/kappa/dsl/ast.hpp index b2dbd54..dfba282 100644 --- a/include/kappa/dsl/ast.hpp +++ b/include/kappa/dsl/ast.hpp @@ -44,12 +44,17 @@ struct EnvEntry { bool soft = false; }; -struct ServiceInit { +struct NamedService { + std::string name = "main"; std::string exec; std::string type; std::string user; std::vector ports; std::unordered_map env; + std::string description; + std::string after; + std::string restart; + std::string working_dir; }; struct Assertion { @@ -70,17 +75,19 @@ struct PackageDef { std::vector depends; std::vector provides; std::vector outputs; + std::vector conflicts; std::unordered_map features; std::vector config_files; std::vector patches; std::vector env_entries; - std::unordered_map service; + std::vector services; std::vector assertions; std::unordered_set const_keys; Phase prepare; Phase build; Phase check; Phase install; + Phase uninstall; }; } // namespace kappa::dsl diff --git a/include/kappa/dsl/system.hpp b/include/kappa/dsl/system.hpp index 0afd3b0..2632479 100644 --- a/include/kappa/dsl/system.hpp +++ b/include/kappa/dsl/system.hpp @@ -39,6 +39,11 @@ struct BootBlock { std::unordered_map params; }; +struct GroupDef { + std::string name; + int gid = -1; // -1 = auto-assign +}; + struct UserRef { std::string name; std::string shell; @@ -54,11 +59,13 @@ struct ServiceRef { struct SystemConfig { std::vector imports; + std::vector remotes; SystemBlock system; std::vector packages; std::vector services; BootBlock boot; std::vector users; + std::vector groups; std::vector assertions; }; diff --git a/include/kappa/dsl/token.hpp b/include/kappa/dsl/token.hpp index 648b34a..636829b 100644 --- a/include/kappa/dsl/token.hpp +++ b/include/kappa/dsl/token.hpp @@ -28,6 +28,7 @@ enum class TokenType { KwDepends, KwProvides, KwOutputs, + KwConflicts, KwFeatures, KwConfig, KwConst, @@ -44,6 +45,7 @@ enum class TokenType { KwBuild, KwCheck, KwInstall, + KwUninstall, KwTrue, KwFalse, }; diff --git a/include/kappa/paths.hpp b/include/kappa/paths.hpp index e9fc7cd..92b1168 100644 --- a/include/kappa/paths.hpp +++ b/include/kappa/paths.hpp @@ -14,6 +14,8 @@ std::filesystem::path temp_dir(); std::filesystem::path db_dir(); std::filesystem::path system_dir(); std::filesystem::path builds_dir(); +std::filesystem::path cache_dir(); +std::filesystem::path packages_dir(); void ensure_directories(); diff --git a/src/boot/grub.cpp b/src/boot/grub.cpp new file mode 100644 index 0000000..103a253 --- /dev/null +++ b/src/boot/grub.cpp @@ -0,0 +1,48 @@ +#include "kappa/boot/bootloader.hpp" + +#include +#include + +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 diff --git a/src/boot/install.cpp b/src/boot/install.cpp new file mode 100644 index 0000000..238c644 --- /dev/null +++ b/src/boot/install.cpp @@ -0,0 +1,69 @@ +#include "kappa/boot/bootloader.hpp" + +#include +#include +#include +#include + +namespace kappa::boot { + +// --------------------------------------------------------------------------- +// Backend config-file generators (defined in separate .cpp files) +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// generate_bootloader_config — dispatch to the correct backend +// --------------------------------------------------------------------------- + +std::string generate_bootloader_config(Bootloader bl, const BootSpec& spec) { + switch (bl) { + case Bootloader::Grub: + return generate_grub_config(spec); + case Bootloader::Limine: + return generate_limine_config(spec); + case Bootloader::Unknown: + default: + return {}; + } +} + +// --------------------------------------------------------------------------- +// install_bootloader_config — write the generated config file to disk +// --------------------------------------------------------------------------- + +BootloaderInstallResult install_bootloader_config(Bootloader bl, + const BootSpec& spec, + std::string_view prefix) { + namespace fs = std::filesystem; + + auto paths = bootloader_paths(bl, prefix); + if (paths.config_path.empty()) { + return {false, {}, "Unknown bootloader"}; + } + + std::string content = generate_bootloader_config(bl, spec); + if (content.empty()) { + return {false, {}, "Failed to generate bootloader config"}; + } + + std::error_code ec; + + fs::path file_path = paths.config_path; + fs::create_directories(file_path.parent_path(), ec); + if (ec) { + return {false, {}, ec.message()}; + } + + { + std::ofstream out(file_path); + if (!out) { + return {false, {}, + std::format("Failed to write {}", file_path.string())}; + } + out << content; + } + + return {true, file_path.string(), {}}; +} + +} // namespace kappa::boot diff --git a/src/boot/limine.cpp b/src/boot/limine.cpp new file mode 100644 index 0000000..1f99773 --- /dev/null +++ b/src/boot/limine.cpp @@ -0,0 +1,42 @@ +#include "kappa/boot/bootloader.hpp" + +#include +#include +#include + +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 diff --git a/src/boot/types.cpp b/src/boot/types.cpp new file mode 100644 index 0000000..e3fb2dc --- /dev/null +++ b/src/boot/types.cpp @@ -0,0 +1,65 @@ +#include "kappa/boot/types.hpp" +#include "kappa/util.hpp" + +#include +#include +#include +#include + +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 all_bootloaders() { + return {Bootloader::Grub, Bootloader::Limine}; +} + +std::string_view bootloader_description(Bootloader bl) { + switch (bl) { + case Bootloader::Grub: + return "GRUB — GRand Unified Bootloader"; + case Bootloader::Limine: + return "Limine — modern multiprotocol bootloader"; + case Bootloader::Unknown: + return "unknown bootloader"; + } + return "unknown bootloader"; +} + +BootloaderPaths bootloader_paths(Bootloader bl, std::string_view prefix) { + switch (bl) { + case Bootloader::Grub: + return { + .config_path = std::format("{}boot/grub/grub.cfg", prefix), + .install_cmd = "grub-install", + }; + case Bootloader::Limine: + return { + .config_path = std::format("{}boot/limine/limine.cfg", prefix), + .install_cmd = "limine", + }; + case Bootloader::Unknown: + return {}; + } + return {}; +} + +} // namespace kappa::boot diff --git a/src/config/eval.cpp b/src/config/eval.cpp index a0952d7..0f1aecb 100644 --- a/src/config/eval.cpp +++ b/src/config/eval.cpp @@ -1,4 +1,7 @@ #include "kappa/config/eval.hpp" +#include "kappa/service/types.hpp" + +#include namespace kappa::config { @@ -55,26 +58,41 @@ std::vector evaluate_assertions(const dsl::SystemConfig& cfg) { return failures; } - -std::unordered_map resolve_services( +std::unordered_map resolve_services( const dsl::SystemConfig& cfg, const std::unordered_map& packages) { - std::unordered_map resolved; + std::unordered_map resolved; auto init_system = cfg.boot.init; + auto is = kappa::service::parse_init_system(init_system); + if (is == kappa::service::InitSystem::Unknown) { + std::cerr << "warning: unknown init system '" << init_system + << "' — no services will be configured\n"; + return {}; + } + for (auto& svc : cfg.services) { if (!svc.enable) { continue; } - auto pit = packages.find(svc.name); + // svc.name can be "postgresql" or "postgresql.checkpointer" + auto dot = svc.name.find('.'); + auto pkg_name = (dot != std::string::npos) + ? svc.name.substr(0, dot) + : svc.name; + auto svc_name = (dot != std::string::npos) + ? svc.name.substr(dot + 1) + : std::string("main"); + + auto pit = packages.find(std::string(pkg_name)); if (pit == packages.end()) { continue; } auto& pkg = pit->second; - if (pkg.service.empty()) { continue; } - - auto it = pkg.service.find(init_system); - if (it != pkg.service.end()) { - resolved[svc.name] = it->second; + for (auto& ns : pkg.services) { + if (ns.name == svc_name) { + resolved[svc.name] = ns; + break; + } } } diff --git a/src/dsl/lexer.cpp b/src/dsl/lexer.cpp index d77deae..f82dd0f 100644 --- a/src/dsl/lexer.cpp +++ b/src/dsl/lexer.cpp @@ -12,8 +12,9 @@ static const std::unordered_map keywords = { {"source", TokenType::KwSource}, {"depends", TokenType::KwDepends}, {"provides", TokenType::KwProvides}, - {"outputs", TokenType::KwOutputs}, - {"features", TokenType::KwFeatures}, + {"outputs", TokenType::KwOutputs}, + {"conflicts", TokenType::KwConflicts}, + {"features", TokenType::KwFeatures}, {"config", TokenType::KwConfig}, {"const", TokenType::KwConst}, {"license", TokenType::KwLicense}, @@ -29,6 +30,7 @@ static const std::unordered_map keywords = { {"build", TokenType::KwBuild}, {"check", TokenType::KwCheck}, {"install", TokenType::KwInstall}, + {"uninstall", TokenType::KwUninstall}, {"true", TokenType::KwTrue}, {"false", TokenType::KwFalse}, }; @@ -51,6 +53,7 @@ std::string_view token_name(TokenType type) { case TokenType::KwDepends: return "depends"; case TokenType::KwProvides: return "provides"; case TokenType::KwOutputs: return "outputs"; + case TokenType::KwConflicts: return "conflicts"; case TokenType::KwFeatures: return "features"; case TokenType::KwConfig: return "config"; case TokenType::KwConst: return "const"; @@ -67,6 +70,7 @@ std::string_view token_name(TokenType type) { case TokenType::KwBuild: return "build"; case TokenType::KwCheck: return "check"; case TokenType::KwInstall: return "install"; + case TokenType::KwUninstall: return "uninstall"; case TokenType::KwTrue: return "true"; case TokenType::KwFalse: return "false"; } diff --git a/src/dsl/parser.cpp b/src/dsl/parser.cpp index 84a1e6b..3c5dfba 100644 --- a/src/dsl/parser.cpp +++ b/src/dsl/parser.cpp @@ -157,6 +157,12 @@ void Parser::parse_body(PackageDef& pkg) { pkg.outputs = parse_string_list(); break; + case TokenType::KwConflicts: + consume(TokenType::KwConflicts); + consume(TokenType::Equals); + pkg.conflicts = parse_string_list(); + break; + case TokenType::KwDepends: consume(TokenType::KwDepends); consume(TokenType::Equals); @@ -233,52 +239,56 @@ void Parser::parse_body(PackageDef& pkg) { consume(TokenType::Rbrace); break; - case TokenType::KwService: + case TokenType::KwService: { consume(TokenType::KwService); + NamedService ns; + if (!at(TokenType::Lbrace)) { + ns.name = current_.lexeme; + advance(); + } consume(TokenType::Lbrace); skip_newlines(); while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) { if (at(TokenType::Newline)) { advance(); continue; } - auto init_name = current_.lexeme; + auto key = current_.lexeme; advance(); - consume(TokenType::Lbrace); - skip_newlines(); - ServiceInit si; - while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) { - if (at(TokenType::Newline)) { advance(); continue; } - auto key = current_.lexeme; - advance(); - consume(TokenType::Equals); - if (key == "exec") { - si.exec = consume(TokenType::String).lexeme; - } else if (key == "type") { - si.type = consume(TokenType::String).lexeme; - } else if (key == "user") { - si.user = consume(TokenType::String).lexeme; - } else if (key == "ports") { - consume(TokenType::Lbracket); - skip_newlines(); - while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) { - si.ports.push_back( - parse_int(current_.line, current_.col, - current_.lexeme)); - advance(); - skip_newlines(); - if (at(TokenType::Comma)) { consume(TokenType::Comma); } - skip_newlines(); - } - consume(TokenType::Rbracket); - } else { - si.env[key] = consume(TokenType::String).lexeme; - } + consume(TokenType::Equals); + if (key == "exec") { + ns.exec = consume(TokenType::String).lexeme; + } else if (key == "type") { + ns.type = consume(TokenType::String).lexeme; + } else if (key == "user") { + ns.user = consume(TokenType::String).lexeme; + } else if (key == "ports") { + consume(TokenType::Lbracket); skip_newlines(); + while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) { + ns.ports.push_back( + parse_int(current_.line, current_.col, + current_.lexeme)); + advance(); + skip_newlines(); + if (at(TokenType::Comma)) { consume(TokenType::Comma); } + skip_newlines(); + } + consume(TokenType::Rbracket); + } else if (key == "description") { + ns.description = consume(TokenType::String).lexeme; + } else if (key == "after") { + ns.after = consume(TokenType::String).lexeme; + } else if (key == "restart") { + ns.restart = consume(TokenType::String).lexeme; + } else if (key == "working_dir") { + ns.working_dir = consume(TokenType::String).lexeme; + } else { + ns.env[key] = consume(TokenType::String).lexeme; } - consume(TokenType::Rbrace); - pkg.service[std::string(init_name)] = std::move(si); skip_newlines(); } consume(TokenType::Rbrace); + pkg.services.push_back(std::move(ns)); break; + } case TokenType::KwAssert: consume(TokenType::KwAssert); @@ -340,6 +350,11 @@ void Parser::parse_body(PackageDef& pkg) { pkg.install = parse_phase(); break; + case TokenType::KwUninstall: + consume(TokenType::KwUninstall); + pkg.uninstall = parse_phase(); + break; + default: throw ParseError(current_.line, current_.col, std::format("unexpected token '{}' in package body", diff --git a/src/dsl/system.cpp b/src/dsl/system.cpp index 003dd33..468c724 100644 --- a/src/dsl/system.cpp +++ b/src/dsl/system.cpp @@ -28,6 +28,7 @@ private: void parse_services_block(SystemConfig& cfg); void parse_boot_block(SystemConfig& cfg); void parse_users_block(SystemConfig& cfg); + void parse_groups_block(SystemConfig& cfg); std::string consume_ident(); std::string consume_string(); @@ -130,6 +131,17 @@ SystemConfig SysParser::parse() { skip_newlines(); } consume(TokenType::Rbracket); + } else if (kw == "remotes") { + consume(TokenType::Equals); + consume(TokenType::Lbracket); + skip_newlines(); + while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) { + cfg.remotes.push_back(consume(TokenType::String).lexeme); + skip_newlines(); + if (at(TokenType::Comma)) { consume(TokenType::Comma); } + skip_newlines(); + } + consume(TokenType::Rbracket); } else if (kw == "assert") { consume(TokenType::Lbrace); skip_newlines(); @@ -171,6 +183,7 @@ SystemConfig SysParser::parse() { else if (kw == "services") { parse_services_block(cfg); } else if (kw == "boot") { parse_boot_block(cfg); } else if (kw == "users") { parse_users_block(cfg); } + else if (kw == "groups") { parse_groups_block(cfg); } else { throw ParseError(current_.line, current_.col, std::format("unknown section '{}'", kw)); @@ -411,6 +424,34 @@ void SysParser::parse_users_block(SystemConfig& cfg) { consume(TokenType::Rbrace); } +void SysParser::parse_groups_block(SystemConfig& cfg) { + consume(TokenType::Lbrace); + skip_newlines(); + while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) { + skip_newlines(); + if (at(TokenType::Rbrace)) break; + GroupDef g; + g.name = consume_ident(); + if (at(TokenType::Lbrace)) { + consume(TokenType::Lbrace); + skip_newlines(); + while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) { + if (at(TokenType::Newline)) { advance(); continue; } + auto key = consume_ident(); + consume(TokenType::Equals); + if (key == "gid") { + g.gid = parse_int(current_.line, current_.col, current_.lexeme); + advance(); + } + skip_newlines(); + } + consume(TokenType::Rbrace); + } + cfg.groups.push_back(std::move(g)); + } + consume(TokenType::Rbrace); +} + SystemConfig parse_system_config(std::string_view source) { SysParser p(source); return p.parse(); @@ -420,12 +461,14 @@ static void merge_config(SystemConfig& base, SystemConfig&& imported) { if (!imported.system.hostname.empty()) { base.system.hostname = std::move(imported.system.hostname); } if (!imported.system.timezone.empty()) { base.system.timezone = std::move(imported.system.timezone); } for (auto& e : imported.system.env) { base.system.env.push_back(std::move(e)); } + for (auto& r : imported.remotes) { base.remotes.push_back(std::move(r)); } for (auto& [k, v] : imported.system.config) { base.system.config[k] = std::move(v); } for (auto& [k, v] : imported.system.features) { base.system.features[k] = std::move(v); } if (imported.system.rollback.keep > 0) { base.system.rollback.keep = imported.system.rollback.keep; } for (auto& p : imported.packages) { base.packages.push_back(std::move(p)); } for (auto& s : imported.services) { base.services.push_back(std::move(s)); } for (auto& u : imported.users) { base.users.push_back(std::move(u)); } + for (auto& g : imported.groups) { base.groups.push_back(std::move(g)); } if (!imported.boot.kernel.empty()) { base.boot.kernel = std::move(imported.boot.kernel); } if (!imported.boot.init.empty()) { base.boot.init = std::move(imported.boot.init); } if (!imported.boot.efi.empty()) { base.boot.efi = std::move(imported.boot.efi); } diff --git a/src/eval/vars.cpp b/src/eval/vars.cpp index e3e2fb8..6d46c7e 100644 --- a/src/eval/vars.cpp +++ b/src/eval/vars.cpp @@ -11,7 +11,8 @@ Scope make_default_scope() { s.builtins["jobs"] = "1"; s.builtins["jobopts"] = "-j1"; s.builtins["destdir"] = (paths::temp_dir() / "destdir").string(); - s.builtins["userargs"] = ""; + s.builtins["userargs"] = ""; + s.builtins["enabledinit"] = ""; return s; } diff --git a/src/fetch/fetch.cpp b/src/fetch/fetch.cpp index 8c77040..c19809f 100644 --- a/src/fetch/fetch.cpp +++ b/src/fetch/fetch.cpp @@ -126,16 +126,37 @@ FetchResult fetch(const dsl::PackageDef& pkg) { } auto dest_name = pkg.name + "-" + pkg.version; - auto dest_file = fs::path(paths::temp_dir()) / (dest_name + "." + ext); + // Sanitize: replace path separators to prevent traversal + for (auto& c : dest_name) { + if (c == '/' || c == '\\') c = '_'; + } + auto cache_path = paths::cache_dir() / (dest_name + "." + ext); + bool from_cache = false; + fs::path dest_file; + if (fs::exists(cache_path)) { + dest_file = cache_path; + from_cache = true; + } else { + dest_file = fs::path(paths::temp_dir()) / (dest_name + "." + ext); + } result.work_dir = fs::path(paths::temp_dir()) / dest_name; if (ext == "git") { int rc = exec_cmd({"git", "clone", url, result.work_dir.string()}); if (rc != 0) { result.error = "git clone failed"; return result; } } else { - fs::create_directories(paths::temp_dir()); - int rc = exec_cmd({"curl", "-L", "-o", dest_file.string(), url}); - if (rc != 0) { result.error = "download failed"; return result; } + if (!from_cache) { + fs::create_directories(paths::temp_dir()); + int rc = exec_cmd({"curl", "-L", "-o", dest_file.string(), url}); + if (rc != 0) { result.error = "download failed"; return result; } + std::error_code ec; + // Atomic cache write: write to .tmp then rename + auto cache_tmp = fs::path(cache_path.string() + ".tmp"); + fs::copy(dest_file, cache_tmp, ec); + if (!ec) { + fs::rename(cache_tmp, cache_path, ec); + } + } bool verified = false; for (auto algo : {"sha512", "sha256", "md5"}) { @@ -170,7 +191,9 @@ FetchResult fetch(const dsl::PackageDef& pkg) { if (rc2 != 0) { result.error = "extraction failed"; return result; } } - fs::remove(dest_file); + if (!from_cache) { + fs::remove(dest_file); + } } for (auto& patch : pkg.patches) { diff --git a/src/fetch/recipe.cpp b/src/fetch/recipe.cpp new file mode 100644 index 0000000..0f37ad8 --- /dev/null +++ b/src/fetch/recipe.cpp @@ -0,0 +1,128 @@ +#include "kappa/fetch/recipe.hpp" + +#include "kappa/dsl/parser.hpp" +#include "kappa/dsl/system.hpp" +#include "kappa/paths.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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& remotes) { + RecipeResult result; + + // Check cached version first + auto cache_path = paths::packages_dir() / (name + ".kap"); + std::string cached_version; + if (std::filesystem::exists(cache_path)) { + std::ifstream in(cache_path); + if (in) { + std::ostringstream buf; + buf << in.rdbuf(); + try { + auto pkg = dsl::parse(buf.str()); + cached_version = pkg.version; + } catch (...) { + // Corrupt cache — will re-download + } + } + } + + // Try each remote + std::string best_content; + std::string best_version; + std::string best_url; + + for (auto& remote : remotes) { + auto url = remote; + if (!url.empty() && url.back() != '/') url += '/'; + url += name + ".kap"; + + // Download to temp + auto temp_path = paths::temp_dir() / (name + ".kap.tmp"); + int rc = exec_curl(temp_path.string(), url); + if (rc != 0) continue; + + // Parse downloaded file + std::ifstream in(temp_path); + if (!in) { std::filesystem::remove(temp_path); continue; } + std::ostringstream buf; + buf << in.rdbuf(); + in.close(); + + std::string remote_version; + try { + auto pkg = dsl::parse(buf.str()); + remote_version = pkg.version; + } catch (...) { + std::filesystem::remove(temp_path); + continue; + } + + // Compare versions — keep the best (highest) + // Simple string comparison for now; semver later + if (remote_version > best_version) { + best_version = remote_version; + best_content = buf.str(); + best_url = url; + } + + std::filesystem::remove(temp_path); + } + + if (best_content.empty()) { + if (!cached_version.empty()) { + // No remote available but have cache + result.ok = true; + result.path = cache_path.string(); + result.version = cached_version; + result.updated = false; + return result; + } + result.error = "package '" + name + "' not found in any remote"; + return result; + } + + // Update cache if remote is newer + if (best_version > cached_version || cached_version.empty()) { + std::error_code ec; + std::filesystem::create_directories(paths::packages_dir(), ec); + std::ofstream out(cache_path); + if (!out) { + result.error = "cannot write to cache"; + return result; + } + out << best_content; + result.updated = true; + } + + result.ok = true; + result.path = cache_path.string(); + result.version = best_version; + return result; +} + +} // namespace kappa::fetch diff --git a/src/main.cpp b/src/main.cpp index a5f13f7..b264130 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,10 +4,13 @@ #include "kappa/dsl/parser.hpp" #include "kappa/dsl/system.hpp" #include "kappa/fetch/fetch.hpp" +#include "kappa/fetch/recipe.hpp" #include "kappa/install/install.hpp" #include "kappa/paths.hpp" #include "kappa/rebuild/rebuild.hpp" #include "kappa/resolve/plan.hpp" +#include "kappa/boot/types.hpp" +#include "kappa/service/types.hpp" #include "kappa/tools/doctor.hpp" #include "kappa/tools/format.hpp" @@ -37,6 +40,7 @@ Subcommands: doctor Check a .kap file for issues and warnings resolve Resolve a build plan from a system config fetch Download and verify source for a package + fetch-package Fetch a package recipe from configured remotes build Build a package from its source directory rebuild Compare config to installed state, rebuild changed list List installed packages @@ -87,6 +91,60 @@ static void handle_parse_error(const char* path, std::cerr << "error: " << e.what() << '\n'; } +static resolve::Registry build_registry(const dsl::SystemConfig& cfg) { + resolve::Registry registry; + + for (const auto& pref : cfg.packages) { + bool found = false; + + // Check standard locations: local .kap, examples/, cache/packages/ + std::vector search_paths = { + std::string(pref.name) + ".kap", + std::string("examples/") + pref.name + ".kap", + (paths::packages_dir() / (pref.name + ".kap")).string(), + }; + + for (const auto& sp : search_paths) { + std::ifstream in(sp); + if (!in) continue; + std::ostringstream buf; + buf << in.rdbuf(); + try { + auto pkg = dsl::parse(buf.str()); + registry[pkg.name] = std::move(pkg); + found = true; + break; + } catch (...) { + continue; + } + } + + // If not found locally, try remotes + if (!found && !cfg.remotes.empty()) { + auto result = fetch::fetch_recipe(pref.name, cfg.remotes); + if (result.ok && !result.path.empty()) { + std::ifstream in(result.path); + if (in) { + std::ostringstream buf; + buf << in.rdbuf(); + try { + auto pkg = dsl::parse(buf.str()); + registry[pkg.name] = std::move(pkg); + found = true; + } catch (...) {} + } + } + } + + if (!found) { + std::cerr << "warning: package '" << pref.name + << "' not found locally or in remotes\n"; + } + } + + return registry; +} + int main(int argc, char* argv[]) { paths::ensure_directories(); @@ -117,7 +175,8 @@ int main(int argc, char* argv[]) { || (subcommand == "build") || (subcommand == "rebuild") || (subcommand == "list") - || (subcommand == "rollback"); + || (subcommand == "fetch-package") + || (subcommand == "rollback"); if (!valid_subcommand) { std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n"; @@ -218,6 +277,26 @@ int main(int argc, char* argv[]) { << cfg.packages.size() << " packages, " << cfg.services.size() << " services, " << cfg.users.size() << " users)\n"; + if (!cfg.boot.init.empty()) { + auto is = kappa::service::parse_init_system(cfg.boot.init); + std::cout << " init: " << cfg.boot.init; + if (is != kappa::service::InitSystem::Unknown) { + std::cout << " (" << kappa::service::init_description(is) << ")"; + } else { + std::cout << " (unrecognized)"; + } + std::cout << "\n"; + } + if (!cfg.boot.bootloader.empty()) { + auto bl = kappa::boot::parse_bootloader(cfg.boot.bootloader); + std::cout << " bootloader: " << cfg.boot.bootloader; + if (bl != kappa::boot::Bootloader::Unknown) { + std::cout << " (" << kappa::boot::bootloader_description(bl) << ")"; + } else { + std::cout << " (unrecognized)"; + } + std::cout << "\n"; + } return 0; } catch (const std::runtime_error& e) { handle_parse_error(file_arg, source, e); @@ -358,13 +437,7 @@ int main(int argc, char* argv[]) { try { auto cfg = dsl::parse_system_config(source); - resolve::Registry registry; - auto* pkg_arg = (argc > 3) ? argv[3] : nullptr; - if (pkg_arg != nullptr) { - auto pkg_src = read_file(pkg_arg); - auto pkg = dsl::parse(pkg_src); - registry[pkg.name] = std::move(pkg); - } + auto registry = build_registry(cfg); auto plan = resolve::resolve(cfg, registry); @@ -420,6 +493,41 @@ int main(int argc, char* argv[]) { } } + if (subcommand == "fetch-package") { + if (file_arg == nullptr) { + std::cerr << "error: no package name specified\n"; + return 1; + } + try { + std::vector remotes; + auto config_path = std::filesystem::path(paths::system_dir()) / "config.kap"; + if (std::filesystem::exists(config_path)) { + auto cfg_src = read_file(config_path.c_str()); + try { + auto cfg = dsl::parse_system_config(cfg_src); + remotes = cfg.remotes; + } catch (...) { + } + } + auto result = fetch::fetch_recipe(file_arg, remotes); + if (result.ok) { + if (result.updated) { + std::cout << "fetched " << file_arg << " " << result.version + << " → " << result.path << "\n"; + } else { + std::cout << file_arg << " " << result.version + << " (cached, up to date)\n"; + } + return 0; + } + std::cerr << "fetch failed: " << result.error << "\n"; + return 1; + } catch (const std::exception& e) { + std::cerr << "fetch error: " << e.what() << "\n"; + return 1; + } + } + if (subcommand == "rebuild") { try { auto cfg = dsl::parse_system_config(source); @@ -442,8 +550,46 @@ int main(int argc, char* argv[]) { std::cout << " building " << name << " (new)\n"; } if (cs.kernel_changed) { std::cout << " kernel changed\n"; } - if (cs.init_changed) { std::cout << " init changed\n"; } - if (cs.bootloader_changed) { std::cout << " bootloader changed\n"; } + if (cs.init_changed) { + auto is_name = cfg.boot.init; + auto is = kappa::service::parse_init_system(is_name); + if (is != kappa::service::InitSystem::Unknown) { + std::cout << " init system: " << is_name << " (" + << kappa::service::init_description(is) << ")\n"; + } else { + std::cout << " init changed (" << is_name + << " — unrecognized)\n"; + } + + auto registry = build_registry(cfg); + auto impact = rebuild::compute_init_impact(cfg, registry); + + if (!impact.service_rebuild.empty()) { + std::cout << " full rebuild (uses ${enabledinit}): " + << impact.service_rebuild.size() + << " packages\n"; + } + if (!impact.service_only.empty()) { + std::cout << " service files only: " + << impact.service_only.size() + << " packages\n"; + } + if (!impact.skipped.empty()) { + std::cout << " no services — skipped: " + << impact.skipped.size() << " packages\n"; + } + } + if (cs.bootloader_changed) { + auto bl_name = cfg.boot.bootloader; + auto bl = kappa::boot::parse_bootloader(bl_name); + if (bl != kappa::boot::Bootloader::Unknown) { + std::cout << " bootloader: " << bl_name << " (" + << kappa::boot::bootloader_description(bl) << ")\n"; + } else { + std::cout << " bootloader changed (" << bl_name + << " — unrecognized)\n"; + } + } if (cs.services_changed) { std::cout << " services changed\n"; } return 0; diff --git a/src/paths.cpp b/src/paths.cpp index 84dfa6d..a3eb99d 100644 --- a/src/paths.cpp +++ b/src/paths.cpp @@ -20,6 +20,8 @@ std::filesystem::path temp_dir() { return g_root / "temp"; } std::filesystem::path db_dir() { return g_root / "db"; } std::filesystem::path system_dir() { return g_root / "system"; } std::filesystem::path builds_dir() { return g_root / "system" / "builds"; } +std::filesystem::path cache_dir() { return g_root / "cache"; } +std::filesystem::path packages_dir() { return cache_dir() / "packages"; } void ensure_directories() { std::error_code ec; @@ -27,6 +29,8 @@ void ensure_directories() { std::filesystem::create_directories(temp_dir(), ec); std::filesystem::create_directories(db_dir(), ec); std::filesystem::create_directories(builds_dir(), ec); + std::filesystem::create_directories(cache_dir(), ec); + std::filesystem::create_directories(packages_dir(), ec); } } // namespace kappa::paths diff --git a/src/rebuild/rebuild.cpp b/src/rebuild/rebuild.cpp index 8f3dac7..5dc7e95 100644 --- a/src/rebuild/rebuild.cpp +++ b/src/rebuild/rebuild.cpp @@ -1,6 +1,8 @@ #include "kappa/rebuild/rebuild.hpp" #include "kappa/install/install.hpp" +#include "kappa/service/types.hpp" +#include #include namespace kappa::rebuild { @@ -43,13 +45,17 @@ ChangeSet compute_changes(const dsl::SystemConfig& cfg) { } for (auto& e : installed) { - if (e.name == "kernel" && e.hash != cfg.boot.kernel) { + // Compare the installed version (which stores the identifier string + // for virtual packages like init/kernel/bootloader) against the + // config value. These virtual entries have their identity in the + // version field, not the hash field. + if (e.name == "kernel" && e.version != cfg.boot.kernel) { cs.kernel_changed = true; } - if (e.name == "init" && e.hash != cfg.boot.init) { + if (e.name == "init" && e.version != cfg.boot.init) { cs.init_changed = true; } - if (e.name == "bootloader" && e.hash != cfg.boot.bootloader) { + if (e.name == "bootloader" && e.version != cfg.boot.bootloader) { cs.bootloader_changed = true; } } @@ -61,4 +67,57 @@ ChangeSet compute_changes(const dsl::SystemConfig& cfg) { return cs; } +InitImpact compute_init_impact(const dsl::SystemConfig& cfg, + const resolve::Registry& registry) { + InitImpact impact; + auto init_system = cfg.boot.init; + + // Only compute impact if an init system is actually configured + if (init_system.empty()) { return impact; } + + auto is = kappa::service::parse_init_system(init_system); + if (is == kappa::service::InitSystem::Unknown) { + std::cerr << "warning: unknown init system '" << init_system + << "' — cannot compute init impact\n"; + return impact; + } + + // For each package in the config that has a matching registry entry... + for (auto& pref : cfg.packages) { + auto rit = registry.find(pref.name); + if (rit == registry.end()) { continue; } + + auto& pkg = rit->second; + + // No services — nothing to do + if (pkg.services.empty()) { + impact.skipped.push_back(pkg.name); + continue; + } + + // Check if build scripts reference ${enabledinit} + bool uses_enabledinit = false; + auto check_phase = [&](const dsl::Phase& phase) { + for (auto& cmd : phase.commands) { + if (cmd.find("${enabledinit}") != std::string::npos) { + uses_enabledinit = true; + return; + } + } + }; + check_phase(pkg.prepare); + if (!uses_enabledinit) check_phase(pkg.build); + if (!uses_enabledinit) check_phase(pkg.check); + if (!uses_enabledinit) check_phase(pkg.install); + + if (uses_enabledinit) { + impact.service_rebuild.push_back(pkg.name); + } else { + impact.service_only.push_back(pkg.name); + } + } + + return impact; +} + } // namespace kappa::rebuild diff --git a/src/resolve/plan.cpp b/src/resolve/plan.cpp index c8d794d..cde17c2 100644 --- a/src/resolve/plan.cpp +++ b/src/resolve/plan.cpp @@ -1,7 +1,9 @@ #include "kappa/resolve/plan.hpp" #include "kappa/config/merge.hpp" +#include #include +#include namespace kappa::resolve { @@ -21,9 +23,10 @@ BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) { auto resolved = config::resolve_package(pkg, cfg.system, pref); BuildStep step; - step.name = pkg.name; - step.package = &pkg; - step.resolved = std::move(resolved); + step.name = pkg.name; + step.package = &pkg; + step.resolved = std::move(resolved); + step.enabled_init = cfg.boot.init; for (auto& dep : pkg.depends) { if (!dep.feature.empty()) { @@ -39,6 +42,20 @@ BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) { nodes.push_back(std::move(step)); } + // Detect conflicts between selected packages + std::unordered_set 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 in_degree(nodes.size(), 0); std::vector> adj(nodes.size()); diff --git a/src/sched/scheduler.cpp b/src/sched/scheduler.cpp index bda6647..324fecc 100644 --- a/src/sched/scheduler.cpp +++ b/src/sched/scheduler.cpp @@ -1,27 +1,290 @@ #include "kappa/sched/scheduler.hpp" #include "kappa/build/build.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + 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 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 nodes; + std::unordered_map name_to_idx; + + // Ready packages grouped by priority → max-heap + std::priority_queue, ReadyOrder> ready[3]; + + // Packages waiting (pending_deps > 0 but not yet ready) + std::unordered_set waiting; + + std::mutex mtx; + std::condition_variable cv; + + std::atomic active_workers{0}; + std::atomic completed{0}; + std::atomic 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 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 rem_deps(s.nodes.size()); + for (std::size_t i = 0; i < s.nodes.size(); ++i) { + rem_deps[i] = static_cast(s.nodes[i].dependents.size()); + } + + while (!leaf_queue.empty()) { + auto u = leaf_queue.front(); + leaf_queue.pop(); + Node& node = s.nodes[u]; + + if (node.step == nullptr) continue; + for (auto dep_idx : node.dependents) { + Node& dep_node = s.nodes[dep_idx]; + if (node.depth + 1 > dep_node.depth) { + dep_node.depth = node.depth + 1; + } + if (--rem_deps[dep_idx] == 0) { + leaf_queue.push(dep_idx); + } + } + } +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- SchedResult run(const resolve::BuildPlan& plan, const std::string& work_root, int workers, int jobs) { - SchedResult result; + if (plan.steps.empty()) { + return {true, {}, {}}; + } - for (auto& step : plan.steps) { - auto r = build::build(step, work_root + "/" + step.name, jobs); - if (r.ok) { - result.built.push_back(step.name); - } else { - result.failed.push_back(step.name); - result.ok = false; - return result; + Scheduler s; + s.work_root = work_root; + s.jobs_per_worker = std::max(1, jobs); + s.total_packages = static_cast(plan.steps.size()); + s.nodes.resize(plan.steps.size()); + + // Build name → index map + for (std::size_t i = 0; i < plan.steps.size(); ++i) { + s.name_to_idx[plan.steps[i].name] = i; + } + + // Wire up nodes: dependencies, dependents, pending_deps + for (std::size_t i = 0; i < plan.steps.size(); ++i) { + Node& node = s.nodes[i]; + node.step = &plan.steps[i]; + node.depth = 0; + + for (auto& dep : plan.steps[i].dependencies) { + auto it = s.name_to_idx.find(dep.name); + if (it == s.name_to_idx.end()) { + std::cerr << std::format("warning: dependency '{}' of '{}' not in build plan\n", + dep.name, plan.steps[i].name); + continue; + } + + // dep → i (dependency is upstream) + Node& dep_node = s.nodes[it->second]; + dep_node.dependents.push_back(i); + node.pending_deps++; } } - result.ok = true; - return result; + // Compute depths for priority + compute_depths(s); + + // Enqueue root nodes (pending_deps == 0) + for (std::size_t i = 0; i < s.nodes.size(); ++i) { + if (s.nodes[i].pending_deps == 0) { + enqueue_ready(s, i); + } else { + s.waiting.insert(i); + } + } + + int num_workers = std::max(1, std::min(workers, s.total_packages)); + std::cout << std::format("scheduler: {} packages, {} workers, {} jobs/worker\n", + s.total_packages, num_workers, s.jobs_per_worker); + + // Spawn workers + std::vector 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 diff --git a/src/service/dinit.cpp b/src/service/dinit.cpp new file mode 100644 index 0000000..81c5411 --- /dev/null +++ b/src/service/dinit.cpp @@ -0,0 +1,74 @@ +#include "kappa/service/service.hpp" + +#include +#include + +namespace kappa::service { + +std::string generate_dinit_service(const ServiceSpec& spec) { + std::ostringstream out; + + // Header + out << std::format("# Generated by kappa — do not edit manually\n"); + out << std::format("# dinit service: {}\n", spec.name); + out << "\n"; + + // Type mapping + std::string dinit_type; + if (spec.type == "simple" || spec.type == "notify" || + spec.type == "longrun") { + dinit_type = "process"; + } else if (spec.type == "forking") { + dinit_type = "bgprocess"; + } else if (spec.type == "oneshot") { + dinit_type = "scripted"; + } else { + dinit_type = "process"; + } + + out << std::format("type = {}\n", dinit_type); + out << std::format("command = {}\n", spec.exec); + + // Restart policy + if (spec.restart_policy == "always") { + out << "restart = true\n"; + } else if (spec.restart_policy == "on-failure") { + out << "restart = true\n"; + } else if (spec.restart_policy == "never") { + out << "restart = false\n"; + } else if (!spec.restart_policy.empty()) { + out << "restart = true\n"; + } + + // depends-on + if (!spec.after.empty()) { + out << std::format("depends-on = {}\n", spec.after); + } + + // working-dir + if (!spec.working_dir.empty()) { + out << std::format("working-dir = {}\n", spec.working_dir); + } + + // run-as + if (!spec.user.empty()) { + out << std::format("run-as = {}\n", spec.user); + } + + // Environment variables (as comments — dinit doesn't support inline env) + if (!spec.env.empty()) { + out << "\n# Environment variables:\n"; + for (const auto& [key, value] : spec.env) { + out << std::format("# {}={}\n", key, value); + } + } + + // description + if (!spec.description.empty()) { + out << std::format("description = {}\n", spec.description); + } + + return out.str(); +} + +} // namespace kappa::service diff --git a/src/service/install.cpp b/src/service/install.cpp new file mode 100644 index 0000000..97a16d9 --- /dev/null +++ b/src/service/install.cpp @@ -0,0 +1,202 @@ +#include "kappa/service/service.hpp" + +#include +#include +#include + +namespace kappa::service { + +// --------------------------------------------------------------------------- +// Backend service-file generators (defined in separate .cpp files) +// systemd_service and s6_service are declared in service.hpp +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// ServiceSpec::from_service_init +// --------------------------------------------------------------------------- + +ServiceSpec ServiceSpec::from_service_init(const dsl::NamedService& ns) { + ServiceSpec spec; + spec.name = ns.name; + spec.description = ns.description; + spec.exec = ns.exec; + spec.user = ns.user; + spec.ports = ns.ports; + spec.env = ns.env; + spec.after = ns.after; + spec.working_dir = ns.working_dir; + + // Normalize type + if (ns.type == "forking" || ns.type == "longrun" || + ns.type == "notify" || ns.type == "oneshot") { + spec.type = ns.type; + } else { + spec.type = "simple"; + } + + // Restart policy + spec.restart_policy = ns.restart; + if (spec.restart_policy.empty() && ns.type == "longrun") { + spec.restart_policy = "always"; + } + + return spec; +} + +// --------------------------------------------------------------------------- +// generate_service_file — dispatch to the correct backend +// --------------------------------------------------------------------------- + +std::string generate_service_file(InitSystem is, const ServiceSpec& spec) { + switch (is) { + case InitSystem::Systemd: + return generate_systemd_service(spec); + case InitSystem::OpenRC: + return generate_openrc_service(spec); + case InitSystem::S6: + return generate_s6_service(spec); + case InitSystem::Dinit: + return generate_dinit_service(spec); + case InitSystem::Runit: + return generate_runit_service(spec); + case InitSystem::Unknown: + default: + return {}; + } +} + +// --------------------------------------------------------------------------- +// install_service — write the generated file(s) to disk +// --------------------------------------------------------------------------- + +ServiceInstallResult install_service(InitSystem is, + const ServiceSpec& spec, + std::string_view prefix) { + namespace fs = std::filesystem; + + auto paths = init_paths(is, prefix); + if (paths.service_dir.empty()) { + return {false, {}, "Unknown init system"}; + } + + std::error_code ec; + + // ---- S6: directory-based layout (type + run) ---- + // S6: directory-based layout. Content generated inline rather than + // calling generate_s6_service() to avoid parsing the combined output. + if (is == InitSystem::S6) { + fs::path svc_dir = fs::path(paths.service_dir) / spec.name; + fs::create_directories(svc_dir, ec); + if (ec) { + return {false, {}, ec.message()}; + } + + // --- type file --- + { + fs::path type_path = svc_dir / "type"; + std::ofstream out(type_path); + if (!out) { + return {false, {}, + std::format("Failed to write {}", type_path.string())}; + } + out << ((spec.type == "oneshot") ? "oneshot" : "longrun"); + } + + // --- run file --- + fs::path run_path = svc_dir / "run"; + { + std::ofstream out(run_path); + if (!out) { + return {false, {}, + std::format("Failed to write {}", run_path.string())}; + } + out << "#!/bin/execlineb -P\n"; + out << "# Generated by kappa — do not edit manually\n"; + out << std::format("# s6 service: {}\n", spec.name); + if (!spec.working_dir.empty()) { + out << std::format("cd {}\n", spec.working_dir); + } + for (const auto& [key, value] : spec.env) { + out << std::format("export {} \"{}\"\n", key, value); + } + if (!spec.user.empty()) { + out << std::format("s6-setuidgid {}\n", spec.user); + } + out << spec.exec << "\n"; + } + + // Make run file executable + fs::permissions(run_path, + fs::perms::owner_exec | fs::perms::group_exec | + fs::perms::others_exec, + fs::perm_options::add, ec); + + return {true, svc_dir.string(), {}}; + } + + // ---- Runit: directory-based layout (run) ---- + if (is == InitSystem::Runit) { + fs::path svc_dir = fs::path(paths.service_dir) / spec.name; + fs::create_directories(svc_dir, ec); + if (ec) { + return {false, {}, ec.message()}; + } + + fs::path run_path = svc_dir / "run"; + { + std::ofstream out(run_path); + if (!out) { + return {false, {}, + std::format("Failed to write {}", run_path.string())}; + } + out << generate_runit_service(spec); + } + + // Make run executable + fs::permissions(run_path, + fs::perms::owner_exec | fs::perms::group_exec | + fs::perms::others_exec, + fs::perm_options::add, ec); + + return {true, svc_dir.string(), {}}; + } + + // ---- Systemd / OpenRC / Dinit: single service file ---- + std::string content = generate_service_file(is, spec); + if (content.empty()) { + return {false, {}, "Failed to generate service file for init system"}; + } + + // Determine filename + std::string filename; + switch (is) { + case InitSystem::Systemd: + filename = std::format("{}.service", spec.name); + break; + case InitSystem::OpenRC: + case InitSystem::Dinit: + filename = spec.name; + break; + default: + return {false, {}, "Unknown init system"}; + } + + fs::path file_path = fs::path(paths.service_dir) / filename; + fs::create_directories(file_path.parent_path(), ec); + if (ec) { + return {false, {}, ec.message()}; + } + + { + std::ofstream out(file_path); + if (!out) { + return {false, {}, + std::format("Failed to write {}", file_path.string())}; + } + out << content; + } + + return {true, file_path.string(), {}}; +} + +} // namespace kappa::service diff --git a/src/service/openrc.cpp b/src/service/openrc.cpp new file mode 100644 index 0000000..f6f72ef --- /dev/null +++ b/src/service/openrc.cpp @@ -0,0 +1,81 @@ +#include "kappa/service/service.hpp" +#include "kappa/util.hpp" + +#include +#include + +namespace kappa::service { + +namespace { + +bool is_background_type(std::string_view type) { + return type == "longrun" || type == "notify" || type == "forking"; +} + +} // namespace + +std::string generate_openrc_service(const ServiceSpec& spec) { + std::ostringstream os; + + // Shebang and header + os << "#!/sbin/openrc-run\n"; + os << "# Generated by kappa — do not edit manually\n"; + + // Description + auto desc = spec.description.empty() + ? spec.name + " service" + : spec.description; + os << std::format("description=\"{}\"\n", util::shell_escape(desc)); + + // Command + os << std::format("\ncommand=\"{}\"\n", util::shell_escape(spec.exec)); + + // User + if (!spec.user.empty()) { + os << std::format("command_user=\"{}\"\n", util::shell_escape(spec.user)); + } + + // command_background and command_args (type-specific) + if (spec.type == "oneshot") { + os << "command_background=false\n"; + os << "command_args=\"\"\n"; + } else if (is_background_type(spec.type)) { + os << "command_background=true\n"; + } else if (!spec.working_dir.empty()) { + os << "command_background=true\n"; + } + + // Working directory + if (!spec.working_dir.empty()) { + os << std::format("directory=\"{}\"\n", + util::shell_escape(spec.working_dir)); + } + + // Depend block (if after or ports) + bool has_depend = !spec.after.empty() || !spec.ports.empty(); + if (has_depend) { + os << "\ndepend() {\n"; + if (!spec.after.empty()) { + os << std::format(" need {}\n", spec.after); + } + if (!spec.ports.empty()) { + os << " use net\n"; + } + os << "}\n"; + } + + // Restart policy comment + if (!spec.restart_policy.empty()) { + os << std::format("# restart policy: {}\n", spec.restart_policy); + } + + // Environment exports + for (auto& [key, value] : spec.env) { + os << std::format("export {}=\"{}\"\n", + key, util::shell_escape(value)); + } + + return os.str(); +} + +} // namespace kappa::service diff --git a/src/service/runit.cpp b/src/service/runit.cpp new file mode 100644 index 0000000..78db191 --- /dev/null +++ b/src/service/runit.cpp @@ -0,0 +1,57 @@ +#include "kappa/service/service.hpp" +#include "kappa/util.hpp" + +#include +#include + +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 diff --git a/src/service/s6.cpp b/src/service/s6.cpp new file mode 100644 index 0000000..f5270e5 --- /dev/null +++ b/src/service/s6.cpp @@ -0,0 +1,47 @@ +#include "kappa/service/service.hpp" + +#include +#include + +namespace kappa::service { + +std::string generate_s6_service(const ServiceSpec& spec) { + std::ostringstream out; + + // --- type file content --- + std::string type_content; + if (spec.type == "oneshot") { + type_content = "oneshot"; + } else { + // "longrun", "notify", and anything else map to longrun in s6 + type_content = "longrun"; + } + + // --- run file content --- + std::ostringstream run; + run << "#!/bin/execlineb -P\n"; + run << "# Generated by kappa — do not edit manually\n"; + run << std::format("# s6 service: {}\n", spec.name); + + if (!spec.working_dir.empty()) { + run << std::format("cd {}\n", spec.working_dir); + } + for (const auto& [key, value] : spec.env) { + run << std::format("export {} \"{}\"\n", key, value); + } + if (!spec.user.empty()) { + run << std::format("s6-setuidgid {}\n", spec.user); + } + run << spec.exec << "\n"; + + // --- combined output --- + out << std::format("# --- s6 service directory: {} ---\n", spec.name); + out << "# file: type\n"; + out << type_content << "\n"; + out << "# file: run\n"; + out << run.str(); + + return out.str(); +} + +} // namespace kappa::service diff --git a/src/service/systemd.cpp b/src/service/systemd.cpp new file mode 100644 index 0000000..1aaee34 --- /dev/null +++ b/src/service/systemd.cpp @@ -0,0 +1,62 @@ +#include "kappa/service/service.hpp" + +#include +#include + +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 diff --git a/src/service/types.cpp b/src/service/types.cpp new file mode 100644 index 0000000..56a7e53 --- /dev/null +++ b/src/service/types.cpp @@ -0,0 +1,98 @@ +#include "kappa/service/types.hpp" +#include "kappa/util.hpp" + +#include +#include +#include +#include + +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 all_systems() { + return {InitSystem::Systemd, InitSystem::OpenRC, + InitSystem::S6, InitSystem::Runit, InitSystem::Dinit}; +} + +std::string_view init_description(InitSystem is) { + switch (is) { + case InitSystem::Systemd: + return "systemd — system and service manager"; + case InitSystem::OpenRC: + return "OpenRC — dependency-based init system"; + case InitSystem::S6: + return "s6 — s6 supervision suite"; + case InitSystem::Runit: + return "runit — supervision suite"; + case InitSystem::Dinit: + return "dinit — service manager / init system"; + case InitSystem::Unknown: + return "unknown init system"; + } + return "unknown init system"; +} + +InitPaths init_paths(InitSystem is, std::string_view prefix) { + switch (is) { + case InitSystem::Systemd: + return { + .service_dir = std::format("{}etc/systemd/system", prefix), + .enable_cmd = "systemctl enable", + .disable_cmd = "systemctl disable", + }; + case InitSystem::OpenRC: + return { + .service_dir = std::format("{}etc/init.d", prefix), + .enable_cmd = "rc-update add", + .disable_cmd = "rc-update del", + }; + case InitSystem::S6: + return { + .service_dir = std::format("{}etc/s6/sv", prefix), + .enable_cmd = "s6-rc-bundle-update", + .disable_cmd = "s6-rc-bundle-update", + }; + case InitSystem::Runit: + return { + .service_dir = std::format("{}etc/sv", prefix), + .enable_cmd = "ln -sf /etc/sv", + .disable_cmd = "rm -f /var/service", + }; + case InitSystem::Dinit: + return { + .service_dir = std::format("{}etc/dinit.d", prefix), + .enable_cmd = "dinitctl enable", + .disable_cmd = "dinitctl disable", + }; + case InitSystem::Unknown: + return {}; + } + return {}; +} + +} // namespace kappa::service diff --git a/src/system/activate.cpp b/src/system/activate.cpp new file mode 100644 index 0000000..07963ed --- /dev/null +++ b/src/system/activate.cpp @@ -0,0 +1,55 @@ +#include "kappa/system/activate.hpp" + +#include +#include +#include + +namespace kappa::system { + +ActivateResult write_hostname(const std::string& hostname, + std::string_view prefix) { + if (hostname.empty()) { + return {false, "hostname is empty"}; + } + + std::filesystem::path path = std::filesystem::path(prefix) / "etc/hostname"; + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) { + return {false, std::format("cannot create {}: {}", path.parent_path().string(), ec.message())}; + } + + std::ofstream out(path); + if (!out) { + return {false, std::format("cannot write {}", path.string())}; + } + out << hostname << "\n"; + return {true, {}}; +} + +ActivateResult write_timezone(const std::string& timezone, + std::string_view prefix) { + if (timezone.empty()) { + return {false, "timezone is empty"}; + } + + // /etc/localtime is a symlink to /usr/share/zoneinfo/{timezone} + std::filesystem::path localtime = std::filesystem::path(prefix) / "etc/localtime"; + std::filesystem::path zoneinfo = std::filesystem::path(prefix) / "usr/share/zoneinfo" / timezone; + + std::error_code ec; + if (!std::filesystem::exists(zoneinfo, ec)) { + return {false, std::format("timezone data not found: {}", zoneinfo.string())}; + } + + std::filesystem::create_directories(localtime.parent_path(), ec); + // Remove existing symlink/file if present + std::filesystem::remove(localtime, ec); + std::filesystem::create_symlink(zoneinfo, localtime, ec); + if (ec) { + return {false, std::format("cannot create symlink {}: {}", localtime.string(), ec.message())}; + } + return {true, {}}; +} + +} // namespace kappa::system diff --git a/src/tools/doctor.cpp b/src/tools/doctor.cpp index cad11bb..05dce7e 100644 --- a/src/tools/doctor.cpp +++ b/src/tools/doctor.cpp @@ -1,4 +1,8 @@ #include "kappa/tools/doctor.hpp" +#include "kappa/boot/types.hpp" +#include "kappa/service/types.hpp" +#include +#include namespace kappa::tools { @@ -32,6 +36,17 @@ std::vector check_package(const dsl::PackageDef& pkg) { } } + for (auto& c : pkg.conflicts) { + if (c.empty()) { + diags.push_back({DiagSeverity::Warning, + "conflict entry has an empty name"}); + } + if (c == pkg.name) { + diags.push_back({DiagSeverity::Error, + "package conflicts with itself: '" + c + "'"}); + } + } + if (pkg.config_files.empty()) { diags.push_back({DiagSeverity::Warning, "no config files defined — package has no runtime configuration"}); @@ -71,8 +86,48 @@ std::vector check_config(const dsl::SystemConfig& cfg) { if (cfg.boot.init.empty()) { diags.push_back({DiagSeverity::Warning, "boot init system is not specified"}); } + if (!cfg.boot.init.empty()) { + auto is = kappa::service::parse_init_system(cfg.boot.init); + if (is == kappa::service::InitSystem::Unknown) { + diags.push_back({DiagSeverity::Warning, + "boot.init '" + cfg.boot.init + "' is not a recognized init system — supported: systemd, openrc, s6, runit, dinit"}); + } + } + if (cfg.boot.init.empty() && !cfg.services.empty()) { + diags.push_back({DiagSeverity::Warning, + std::to_string(cfg.services.size()) + " service(s) defined but no init system configured — set boot.init"}); + } + if (cfg.boot.bootloader.empty()) { + diags.push_back({DiagSeverity::Warning, "boot bootloader is not specified"}); + } else { + auto bl = kappa::boot::parse_bootloader(cfg.boot.bootloader); + if (bl == kappa::boot::Bootloader::Unknown) { + diags.push_back({DiagSeverity::Warning, + "boot.bootloader '" + cfg.boot.bootloader + "' is not a recognized bootloader — supported: grub, limine"}); + } + } + // Validate boot partitions if (cfg.boot.efi.empty() && cfg.boot.root.empty()) { diags.push_back({DiagSeverity::Error, "no boot partitions defined (efi or root)"}); + } else { + // Check that each specified partition exists + auto check_partition = [&](const std::string& path, const char* label) { + if (path.empty()) return; + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + diags.push_back({DiagSeverity::Error, + std::string("boot.") + label + " '" + path + "' does not exist"}); + } else { + struct stat st; + if (stat(path.c_str(), &st) == 0 && !S_ISBLK(st.st_mode)) { + diags.push_back({DiagSeverity::Warning, + std::string("boot.") + label + " '" + path + "' is not a block device"}); + } + } + }; + check_partition(cfg.boot.efi, "efi"); + check_partition(cfg.boot.root, "root"); + check_partition(cfg.boot.swap, "swap"); } if (cfg.packages.empty()) { @@ -105,6 +160,12 @@ std::vector check_config(const dsl::SystemConfig& cfg) { + " assertions defined — run 'kappa validate' to check them"}); } + for (auto& g : cfg.groups) { + if (g.name.empty()) { + diags.push_back({DiagSeverity::Warning, "group has no name"}); + } + } + return diags; } diff --git a/src/tools/format.cpp b/src/tools/format.cpp index 37c0d19..5641dec 100644 --- a/src/tools/format.cpp +++ b/src/tools/format.cpp @@ -27,8 +27,12 @@ static void write_env(std::ostream& os, int d, static void write_features(std::ostream& os, int d, const std::unordered_map& feats) { if (feats.empty()) { return; } + std::vector keys; + for (auto& [k, _] : feats) keys.push_back(k); + std::sort(keys.begin(), keys.end()); os << Indent(d) << "features {\n"; - for (auto& [k, f] : feats) { + for (auto& k : keys) { + auto& f = feats.at(k); if (f.flag.empty() && !f.force) { os << Indent(d + 1) << k << " = " << (f.enabled ? "true" : "false") << "\n"; } else { @@ -105,6 +109,15 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) { os << "]\n"; } + if (!pkg.conflicts.empty()) { + os << Indent(d) << "conflicts = ["; + for (std::size_t i = 0; i < pkg.conflicts.size(); ++i) { + if (i > 0) { os << ", "; } + os << '"' << pkg.conflicts[i] << '"'; + } + os << "]\n"; + } + if (!pkg.patches.empty()) { os << Indent(d) << "patches = [\n"; for (auto& p : pkg.patches) { @@ -151,27 +164,33 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) { os << Indent(d) << "}\n"; } - if (!pkg.service.empty()) { - os << Indent(d) << "service {\n"; - for (auto& [init, si] : pkg.service) { - os << Indent(d + 1) << init << " {\n"; - if (!si.exec.empty()) { os << Indent(d + 2) << "exec = \"" << si.exec << "\"\n"; } - if (!si.type.empty()) { os << Indent(d + 2) << "type = \"" << si.type << "\"\n"; } - if (!si.user.empty()) { os << Indent(d + 2) << "user = \"" << si.user << "\"\n"; } - if (!si.ports.empty()) { - os << Indent(d + 2) << "ports = ["; - for (std::size_t i = 0; i < si.ports.size(); ++i) { + if (!pkg.services.empty()) { + for (auto& ns : pkg.services) { + if (ns.name != "main") { + os << Indent(d) << "service " << ns.name << " {\n"; + } else { + os << Indent(d) << "service {\n"; + } + if (!ns.exec.empty()) { os << Indent(d + 1) << "exec = \"" << ns.exec << "\"\n"; } + if (!ns.type.empty()) { os << Indent(d + 1) << "type = \"" << ns.type << "\"\n"; } + if (!ns.user.empty()) { os << Indent(d + 1) << "user = \"" << ns.user << "\"\n"; } + if (!ns.ports.empty()) { + os << Indent(d + 1) << "ports = ["; + for (std::size_t i = 0; i < ns.ports.size(); ++i) { if (i > 0) { os << ", "; } - os << si.ports[i]; + os << ns.ports[i]; } os << "]\n"; } - for (auto& [k, v] : si.env) { - os << Indent(d + 2) << k << " = \"" << v << "\"\n"; + if (!ns.description.empty()) { os << Indent(d + 1) << "description = \"" << ns.description << "\"\n"; } + if (!ns.after.empty()) { os << Indent(d + 1) << "after = \"" << ns.after << "\"\n"; } + if (!ns.restart.empty()) { os << Indent(d + 1) << "restart = \"" << ns.restart << "\"\n"; } + if (!ns.working_dir.empty()) { os << Indent(d + 1) << "working_dir = \"" << ns.working_dir << "\"\n"; } + for (auto& [k, v] : ns.env) { + os << Indent(d + 1) << k << " = \"" << v << "\"\n"; } - os << Indent(d + 1) << "}\n"; + os << Indent(d) << "}\n"; } - os << Indent(d) << "}\n"; } if (!pkg.assertions.empty()) { @@ -189,6 +208,7 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) { write_phase(os, d, "build", pkg.build); write_phase(os, d, "check", pkg.check); write_phase(os, d, "install", pkg.install); + write_phase(os, d, "uninstall", pkg.uninstall); os << "}\n"; } @@ -203,13 +223,20 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) { os << "]\n\n"; } + if (!cfg.remotes.empty()) { + os << "remotes = [\n"; + for (auto& r : cfg.remotes) { + os << " \"" << r << "\",\n"; + } + os << "]\n\n"; + } + if (!cfg.assertions.empty()) { os << "assert {\n"; for (auto& a : cfg.assertions) { os << " \"" << a.message << "\" : " - << a.field << " " << a.op; - if (!a.value.empty()) { os << " \"" << a.value << '"'; } - os << "\n"; + << a.field << " " << a.op + << " \"" << a.value << '"' << "\n"; } os << "}\n\n"; } @@ -222,8 +249,11 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) { write_env(os, 1, s.env); if (!s.config.empty()) { os << " config {\n"; - for (auto& [k, v] : s.config) { - os << " " << k << " = \"" << v << "\"\n"; + std::vector cfg_keys; + for (auto& [k, _] : s.config) cfg_keys.push_back(k); + std::sort(cfg_keys.begin(), cfg_keys.end()); + for (auto& k : cfg_keys) { + os << " " << k << " = \"" << s.config.at(k) << "\"\n"; } os << " }\n"; } @@ -245,8 +275,11 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) { write_features(os, 2, p.features); if (!p.config.empty()) { os << " config {\n"; - for (auto& [k, v] : p.config) { - os << " " << k << " = " << v << "\n"; + std::vector pcfg_keys; + for (auto& [k, _] : p.config) pcfg_keys.push_back(k); + std::sort(pcfg_keys.begin(), pcfg_keys.end()); + for (auto& k : pcfg_keys) { + os << " " << k << " = " << p.config.at(k) << "\n"; } os << " }\n"; } @@ -268,6 +301,19 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) { os << "}\n\n"; } + if (!cfg.groups.empty()) { + os << "groups {\n"; + for (auto& g : cfg.groups) { + os << " " << g.name; + if (g.gid < 0) { + os << " {}\n"; + } else { + os << " {\n gid = " << g.gid << "\n }\n"; + } + } + os << "}\n\n"; + } + write_boot_block(os, 0, cfg.boot); os << "\n"; diff --git a/src/util.cpp b/src/util.cpp new file mode 100644 index 0000000..c0b6ce3 --- /dev/null +++ b/src/util.cpp @@ -0,0 +1,26 @@ +#include "kappa/util.hpp" +#include +#include +#include + +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; +} +} diff --git a/test-init-switch.sh b/test-init-switch.sh new file mode 100755 index 0000000..7a32951 --- /dev/null +++ b/test-init-switch.sh @@ -0,0 +1,350 @@ +#!/bin/bash +# kappa init-switching integration test via systemd-nspawn +# Requires: systemd-nspawn, debootstrap (or pacstrap), kappa binary +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +KAPPA_BIN="${KAPPA_BIN:-./build/kappa}" +ROOTFS="./test-rootfs" +PASS=0 +FAIL=0 + +cleanup() { + echo "" + echo "Cleaning up..." + rm -rf "$ROOTFS" /tmp/kappa-test-*.kap 2>/dev/null || true +} + +trap cleanup EXIT + +check() { + local desc="$1" cmd="$2" expect="$3" + echo -n " $desc ... " + local out + out=$(eval "$cmd" 2>&1) || true + if echo "$out" | grep -q "$expect"; then + echo -e "${GREEN}OK${NC}" + PASS=$((PASS + 1)) + else + echo -e "${RED}FAIL${NC}" + echo " expected: $expect" + echo " got: $(echo "$out" | head -3 | tr '\n' ' ')" + FAIL=$((FAIL + 1)) + fi +} + +echo "" +echo "============================================" +echo " kappa init-switching integration tests" +echo "============================================" +echo "" + +# ------------------------------------------------------------------ +# 1. Create a minimal rootfs directory structure +# ------------------------------------------------------------------ +echo "--- Setting up test rootfs ---" + +mkdir -p "$ROOTFS"/{etc,usr/local/bin,kappa/{boot,db,store,temp},var/service} +cp "$KAPPA_BIN" "$ROOTFS/usr/local/bin/kappa" +chmod +x "$ROOTFS/usr/local/bin/kappa" + +export KAPPA_ROOT="$ROOTFS/kappa" + +# Create a minimal package registry (simulating installed packages) +mkdir -p "$KAPPA_ROOT/db" + +# Simulate an installed system with nginx (has services), zlib (no services), +# and postgresql (multi-service, uses ${enabledinit}) +cat > /tmp/kappa-test-nginx.kap << 'EOF' +package "nginx" { + version = "1.24" + source = "https://nginx.org/nginx-1.24.tar.gz" + service { + exec = "/usr/bin/nginx" + type = "forking" + ports = [80, 443] + user = "www-data" + description = "Nginx web server" + } + build { ./configure --prefix=${prefix}; make -j${jobs} } + install { make DESTDIR=${destdir} install } +} +EOF + +cat > /tmp/kappa-test-zlib.kap << 'EOF' +package "zlib" { + version = "1.3" + source = "https://zlib.net/zlib-1.3.tar.gz" + build { ./configure --prefix=${prefix}; make -j${jobs} } + install { make DESTDIR=${destdir} install } +} +EOF + +cat > /tmp/kappa-test-postgres.kap << 'EOF' +package "postgresql" { + version = "16.3" + source = "https://ftp.postgresql.org/source/postgresql-16.3.tar.gz" + service main { + exec = "/usr/bin/postgres" + type = "forking" + ports = [5432] + user = "postgres" + description = "PostgreSQL database server" + } + service checkpointer { + exec = "/usr/bin/postgres-checkpointer" + type = "longrun" + user = "postgres" + } + build { + case ${enabledinit} in + systemd) ./configure --with-systemd --prefix=${prefix} ;; + *) ./configure --prefix=${prefix} ;; + esac + make -j${jobs} + } + install { make DESTDIR=${destdir} install } +} +EOF + +# Write installed DB (simulate nginx, zlib, postgresql already installed) +# Format: name version hash [provides...] +mkdir -p "$(dirname "$KAPPA_ROOT/db/installed")" +cat > "$KAPPA_ROOT/db/installed" << 'EOF' +nginx 1.24 a1b2c3d4e5f6a7b8 +zlib 1.3 b2c3d4e5f6a7b8c9 +postgresql 16.3 c3d4e5f6a7b8c9d0 +init a1b2c3d4e5f6a7b8 +kernel d4e5f6a7b8c9d0e1 +bootloader e5f6a7b8c9d0e1f2 +EOF + +echo "" + +# ------------------------------------------------------------------ +# 2. Init system switching tests +# ------------------------------------------------------------------ +echo "--- Init system switching ---" + +# Base config template +config_template() { + local init="$1" + cat << EOF +system { hostname = "kappa-test" } +packages { + nginx {} + zlib {} + postgresql {} + $init {} +} +services { + nginx { enable = true } + postgresql { enable = true } + postgresql.checkpointer { enable = true } +} +boot { + kernel = "linux" + init = "$init" + root = "/dev/sda1" + bootloader = "limine" +} +groups { + wheel { gid = 998 } +} +users { + root { shell = "/bin/zsh" } +} +EOF +} + +# Test systemd config +config_template "systemd" > /tmp/kappa-test-systemd.kap +check "parse systemd config" \ + "$KAPPA_BIN parse-config /tmp/kappa-test-systemd.kap" \ + 'systemd — system and service manager' + +check "systemd bootloader in config" \ + "$KAPPA_BIN parse-config /tmp/kappa-test-systemd.kap" \ + 'Limine' + +# Test openrc config +config_template "openrc" > /tmp/kappa-test-openrc.kap +check "parse openrc config" \ + "$KAPPA_BIN parse-config /tmp/kappa-test-openrc.kap" \ + 'OpenRC — dependency-based init' + +# Test s6 config +config_template "s6" > /tmp/kappa-test-s6.kap +check "parse s6 config" \ + "$KAPPA_BIN parse-config /tmp/kappa-test-s6.kap" \ + 's6 — s6 supervision suite' + +# Test runit config +config_template "runit" > /tmp/kappa-test-runit.kap +check "parse runit config" \ + "$KAPPA_BIN parse-config /tmp/kappa-test-runit.kap" \ + 'runit — supervision suite' + +# Test dinit config +config_template "dinit" > /tmp/kappa-test-dinit.kap +check "parse dinit config" \ + "$KAPPA_BIN parse-config /tmp/kappa-test-dinit.kap" \ + 'dinit — service manager' + +echo "" + +# ------------------------------------------------------------------ +# 3. Service generation verification +# ------------------------------------------------------------------ +echo "--- Service file generation ---" + +# Verify service specs are parsed correctly per init +for init in systemd openrc s6 runit dinit; do + check "service block present for $init config" \ + "$KAPPA_BIN format /tmp/kappa-test-$init.kap" \ + 'enable = true' +done + +# Verify the format output contains correct service fields +check "nginx service has type forking" \ + "$KAPPA_BIN format /tmp/kappa-test-nginx.kap" \ + 'type = "forking"' + +check "postgresql has named service checkpointer" \ + "$KAPPA_BIN format /tmp/kappa-test-postgres.kap" \ + 'service checkpointer' + +check "postgresql build uses enabledinit" \ + "$KAPPA_BIN format /tmp/kappa-test-postgres.kap" \ + 'enabledinit' + +echo "" + +# ------------------------------------------------------------------ +# 4. Rebuild impact analysis +# ------------------------------------------------------------------ +echo "--- Rebuild impact analysis ---" + +# Write an initial installed state matching s6 config +# (reuse the same installed DB from above) + +config_template "s6" > /tmp/kappa-test-rebuild-s6.kap +# The packages list includes "s6" which isn't in the installed DB, +# so rebuild correctly shows it as a new package to build. +check "rebuild detects new init package" \ + "$KAPPA_BIN rebuild /tmp/kappa-test-rebuild-s6.kap 2>&1" \ + 'packages to rebuild' + +# Now test switching FROM s6 TO systemd +config_template "systemd" > /tmp/kappa-test-rebuild-systemd.kap + +# The rebuild should detect init_changed +# Note: this requires the installed DB to have the old init hash +check "rebuild detects init change" \ + "$KAPPA_BIN rebuild /tmp/kappa-test-rebuild-systemd.kap 2>&1" \ + 'packages to rebuild' + +echo "" + +# ------------------------------------------------------------------ +# 5. Bootloader switching tests +# ------------------------------------------------------------------ +echo "--- Bootloader switching ---" + +for bl in grub limine; do + cat > "/tmp/kappa-test-bl-$bl.kap" << KAPEOF +system { hostname = "test" } +packages { nginx {} } +services {} +boot { + kernel = "linux" + init = "s6" + root = "/dev/sda1" + bootloader = "$bl" +} +users { root { shell = "/bin/sh" } } +KAPEOF + check "bootloader $bl recognized in config" \ + "$KAPPA_BIN parse-config /tmp/kappa-test-bl-$bl.kap" \ + "$bl" +done + +# Test unknown bootloader +cat > /tmp/kappa-test-badbl.kap << 'KAPEOF' +system { hostname = "test" } +packages {} +services {} +boot { + kernel = "linux" + init = "s6" + root = "/dev/sda1" + bootloader = "lilo" +} +users { root { shell = "/bin/sh" } } +KAPEOF +check "doctor warns on unknown bootloader" \ + "$KAPPA_BIN doctor /tmp/kappa-test-badbl.kap 2>&1" \ + 'not a recognized bootloader' + +echo "" + +# ------------------------------------------------------------------ +# 6. Groups and conflicts +# ------------------------------------------------------------------ +echo "--- Groups and conflicts ---" + +cat > /tmp/kappa-test-groups2.kap << 'KAPEOF' +system { hostname = "test" } +packages {} +services {} +boot { + kernel = "linux" + init = "s6" + root = "/dev/sda1" + bootloader = "limine" +} +groups { + wheel { gid = 998 } + audio {} + docker { gid = 995 } +} +users { root { shell = "/bin/sh" } } +KAPEOF + +check "groups with gid format correctly" \ + "$KAPPA_BIN format /tmp/kappa-test-groups2.kap" \ + 'gid = 998' + +check "groups without gid format as empty" \ + "$KAPPA_BIN format /tmp/kappa-test-groups2.kap" \ + 'audio {}' + +# Conflicts test +cat > /tmp/kappa-test-conflict-pkg.kap << 'KAPEOF' +package "systemd" { + version = "255" + source = "https://example.com/systemd.tar.gz" + provides = ["udev", "logind"] + conflicts = ["eudev", "elogind"] + build { make } + install { make install } +} +KAPEOF + +check "conflicts parse correctly" \ + "$KAPPA_BIN parse-package /tmp/kappa-test-conflict-pkg.kap" \ + 'valid' + +check "conflicts format round-trips" \ + "$KAPPA_BIN format /tmp/kappa-test-conflict-pkg.kap" \ + 'conflicts = \[' + +echo "" +echo "============================================" +echo -e " Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}" +echo "============================================" + +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..a9be9c7 --- /dev/null +++ b/test.sh @@ -0,0 +1,230 @@ +#!/bin/sh +# $KAPPA integration test suite +# Run: docker run --rm -v $(pwd):/opt/$KAPPA kappa-test ./test.sh +set -e + +KAPPA="${KAPPA_BIN:-./build/kappa}" +PASS=0 +FAIL=0 + +check() { + local desc="$1" cmd="$2" expect="$3" + local out + out=$(eval "$cmd" 2>&1) || true + if echo "$out" | grep -q "$expect"; then + echo " ✓ $desc" + PASS=$((PASS + 1)) + else + echo " ✗ $desc" + echo " expected: $expect" + echo " got: $(echo "$out" | head -3)" + FAIL=$((FAIL + 1)) + fi +} + +echo "" +echo "=== Package DSL tests ===" +echo "" + +check "parse foo.kap" \ + "$KAPPA parse-package examples/foo.kap" \ + 'package "foo" 1.2.3 — valid' + +check "parse postgres.kap (multi-service)" \ + "$KAPPA parse-package examples/postgres.kap" \ + 'package "postgresql" 16.3 — valid' + +check "format foo.kap round-trips service block" \ + "$KAPPA format examples/foo.kap" \ + 'service {' + +check "format shows conflicts when present" \ + "$KAPPA format /tmp/test_conflict.kap" \ + 'conflicts =' + +check "format postgres.kap shows named services" \ + "$KAPPA format examples/postgres.kap" \ + 'service checkpointer {' + +echo "" +echo "=== Config DSL tests ===" +echo "" + +check "parse config.kap" \ + "$KAPPA parse-config examples/config.kap" \ + 'system config — valid' + +check "config.kap shows init description" \ + "$KAPPA parse-config examples/config.kap" \ + 's6 — s6 supervision suite' + +check "config.kap shows bootloader description" \ + "$KAPPA parse-config examples/config.kap" \ + 'Limine — modern multiprotocol bootloader' + +echo "" +echo "=== Doctor tests ===" +echo "" + +check "doctor foo.kap" \ + "$KAPPA doctor examples/foo.kap" \ + 'no issues found' + +echo "" +echo "=== Validate tests ===" +echo "" + +check "validate foo.kap" \ + "$KAPPA validate examples/foo.kap" \ + 'valid package' + +echo "" +echo "=== Rebuild tests ===" +echo "" + +check "rebuild detects new packages" \ + "$KAPPA rebuild examples/config.kap" \ + 'packages to rebuild' + +echo "" +echo "=== Init system recognition tests ===" +echo "" + +# Create temp config with each init system +for init in systemd openrc s6 runit dinit; do + cat > /tmp/test_init.kap << KAPEOF +system { hostname = "test" } +packages {} +services {} +boot { + kernel = "linux" + init = "$init" + root = "/dev/sda1" + bootloader = "limine" +} +users { root { shell = "/bin/sh" } } +KAPEOF + check "recognizes init=$init" \ + "$KAPPA parse-config /tmp/test_init.kap" \ + "$init" + + check "doctor accepts init=$init" \ + "$KAPPA doctor /tmp/test_init.kap 2>&1" \ + "" +done + +# Test unknown init +cat > /tmp/test_badinit.kap << 'KAPEOF' +system { hostname = "test" } +packages {} +services {} +boot { + kernel = "linux" + init = "fakething" + root = "/dev/sda1" + bootloader = "limine" +} +users { root { shell = "/bin/sh" } } +KAPEOF +check "warns on unknown init" \ + "$KAPPA doctor /tmp/test_badinit.kap 2>&1" \ + 'not a recognized init system' + +echo "" +echo "=== Bootloader recognition tests ===" +echo "" + +for bl in grub limine; do + cat > /tmp/test_bl.kap << KAPEOF +system { hostname = "test" } +packages {} +services {} +boot { + kernel = "linux" + init = "s6" + root = "/dev/sda1" + bootloader = "$bl" +} +users { root { shell = "/bin/sh" } } +KAPEOF + check "recognizes bootloader=$bl" \ + "$KAPPA parse-config /tmp/test_bl.kap" \ + "$bl" +done + +echo "" +echo "=== Service generation tests ===" +echo "" + +# Test that format output contains service fields for each backend type hint +check "service format includes type field" \ + "$KAPPA format examples/foo.kap" \ + 'type = "forking"' + +check "service format includes ports" \ + "$KAPPA format examples/foo.kap" \ + 'ports = \[' + +check "service format includes description" \ + "$KAPPA format examples/foo.kap" \ + 'description = "Foo web server"' + +echo "" +echo "=== Groups DSL tests ===" +echo "" + +cat > /tmp/test_groups.kap << 'KAPEOF' +system { hostname = "test" } +packages {} +services {} +boot { + kernel = "linux" + init = "s6" + root = "/dev/sda1" + bootloader = "limine" +} +groups { + wheel { gid = 998 } + audio {} + docker { gid = 995 } +} +users { root { shell = "/bin/sh" } } +KAPEOF + +check "parse groups block" \ + "$KAPPA parse-config /tmp/test_groups.kap" \ + 'valid' + +check "format groups round-trips" \ + "$KAPPA format /tmp/test_groups.kap" \ + 'wheel {' + +echo "" +echo "=== Conflicts tests ===" +echo "" + +cat > /tmp/test_conflict.kap << 'KAPEOF' +package "systemd" { + version = "255" + source = "https://example.com/systemd-255.tar.gz" + provides = ["udev", "logind"] + conflicts = ["eudev", "elogind"] + build { make } + install { make install } +} +KAPEOF + +check "parse conflicts in package def" \ + "$KAPPA parse-package /tmp/test_conflict.kap" \ + 'valid' + +check "format shows conflicts" \ + "$KAPPA format /tmp/test_conflict.kap" \ + 'conflicts =' + +echo "" +echo "==========================================" +echo " Results: $PASS passed, $FAIL failed" +echo "==========================================" + +[ "$FAIL" -eq 0 ] || exit 1