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

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

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

Bug fixes from review:
- CRITICAL: Replace std::system() with fork+execvp (command injection)
- CRITICAL: Fix scheduler deadlock on successful completion
- CRITICAL: Fix rebuild init/kernel/bootloader change detection
- HIGH: Fix path traversal via unsanitized package names in cache
- HIGH: Fix TOCTOU race in cache write with atomic rename
- HIGH: Fix formatter dropping remotes/imports blocks
- HIGH: Fix formatter stripping empty-string assert values
- HIGH: Fix formatter non-idempotent output (sorted key iteration)
- HIGH: Populate ${enabledinit} from boot.init in BuildStep
- MEDIUM: Fix data race on non-atomic scheduler stop flag
- MEDIUM: Fix compute_depths() traversal direction
- MEDIUM: Add runit to doctor supported-init warning
- MEDIUM: Extract to_lower/shell_escape to shared kappa::util
- MEDIUM: Consolidate generator declarations in headers
This commit is contained in:
2026-07-31 05:32:57 -04:00
parent d6612d0a4a
commit dd984f96d4
40 changed files with 3047 additions and 117 deletions
+48
View File
@@ -0,0 +1,48 @@
#include "kappa/boot/bootloader.hpp"
#include <format>
#include <sstream>
namespace kappa::boot {
std::string generate_grub_config(const BootSpec& spec) {
std::ostringstream oss;
oss << "# Generated by kappa — do not edit manually\n";
oss << "# GRUB boot entry\n";
oss << "\n";
oss << "set timeout=5\n";
oss << "set default=0\n";
oss << "\n";
oss << "menuentry \"Kappa\" {\n";
oss << std::format(" linux {} init={} root={}",
spec.kernel_path,
spec.init_path,
spec.root);
if (!spec.kernel_params.empty()) {
oss << " " << spec.kernel_params;
}
oss << "\n";
oss << "}\n";
if (!spec.init_prev.empty()) {
oss << "\n";
oss << "menuentry \"Kappa (fallback)\" {\n";
oss << std::format(" linux {} init={} root={}",
spec.kernel_path,
spec.init_prev,
spec.root);
if (!spec.kernel_params.empty()) {
oss << " " << spec.kernel_params;
}
oss << "\n";
oss << "}\n";
}
return oss.str();
}
} // namespace kappa::boot
+69
View File
@@ -0,0 +1,69 @@
#include "kappa/boot/bootloader.hpp"
#include <filesystem>
#include <format>
#include <fstream>
#include <iostream>
namespace kappa::boot {
// ---------------------------------------------------------------------------
// Backend config-file generators (defined in separate .cpp files)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// generate_bootloader_config — dispatch to the correct backend
// ---------------------------------------------------------------------------
std::string generate_bootloader_config(Bootloader bl, const BootSpec& spec) {
switch (bl) {
case Bootloader::Grub:
return generate_grub_config(spec);
case Bootloader::Limine:
return generate_limine_config(spec);
case Bootloader::Unknown:
default:
return {};
}
}
// ---------------------------------------------------------------------------
// install_bootloader_config — write the generated config file to disk
// ---------------------------------------------------------------------------
BootloaderInstallResult install_bootloader_config(Bootloader bl,
const BootSpec& spec,
std::string_view prefix) {
namespace fs = std::filesystem;
auto paths = bootloader_paths(bl, prefix);
if (paths.config_path.empty()) {
return {false, {}, "Unknown bootloader"};
}
std::string content = generate_bootloader_config(bl, spec);
if (content.empty()) {
return {false, {}, "Failed to generate bootloader config"};
}
std::error_code ec;
fs::path file_path = paths.config_path;
fs::create_directories(file_path.parent_path(), ec);
if (ec) {
return {false, {}, ec.message()};
}
{
std::ofstream out(file_path);
if (!out) {
return {false, {},
std::format("Failed to write {}", file_path.string())};
}
out << content;
}
return {true, file_path.string(), {}};
}
} // namespace kappa::boot
+42
View File
@@ -0,0 +1,42 @@
#include "kappa/boot/bootloader.hpp"
#include <format>
#include <sstream>
#include <string>
namespace kappa::boot {
std::string generate_limine_config(const BootSpec& spec) {
std::ostringstream out;
out << "# Generated by kappa — do not edit manually\n"
<< "# Limine boot entry\n"
<< "\n"
<< ":Kappa\n"
<< " protocol: linux\n"
<< std::format(" kernel_path: boot():{}\n", spec.kernel_path);
out << std::format(" kernel_cmdline: init={} root={}",
spec.init_path, spec.root);
if (!spec.kernel_params.empty()) {
out << " " << spec.kernel_params;
}
out << "\n";
if (!spec.init_prev.empty()) {
out << "\n"
<< ":Kappa (fallback)\n"
<< " protocol: linux\n"
<< std::format(" kernel_path: boot():{}\n", spec.kernel_path);
out << std::format(" kernel_cmdline: init={} root={}",
spec.init_prev, spec.root);
if (!spec.kernel_params.empty()) {
out << " " << spec.kernel_params;
}
out << "\n";
}
return out.str();
}
} // namespace kappa::boot
+65
View File
@@ -0,0 +1,65 @@
#include "kappa/boot/types.hpp"
#include "kappa/util.hpp"
#include <algorithm>
#include <cctype>
#include <format>
#include <ranges>
namespace kappa::boot {
Bootloader parse_bootloader(std::string_view name) {
auto lower = util::to_lower(name);
if (lower == "grub") return Bootloader::Grub;
if (lower == "limine") return Bootloader::Limine;
return Bootloader::Unknown;
}
std::string_view to_string(Bootloader bl) {
switch (bl) {
case Bootloader::Grub: return "grub";
case Bootloader::Limine: return "limine";
case Bootloader::Unknown: return "unknown";
}
return "unknown";
}
bool is_supported(std::string_view name) {
return parse_bootloader(name) != Bootloader::Unknown;
}
std::vector<Bootloader> all_bootloaders() {
return {Bootloader::Grub, Bootloader::Limine};
}
std::string_view bootloader_description(Bootloader bl) {
switch (bl) {
case Bootloader::Grub:
return "GRUB — GRand Unified Bootloader";
case Bootloader::Limine:
return "Limine — modern multiprotocol bootloader";
case Bootloader::Unknown:
return "unknown bootloader";
}
return "unknown bootloader";
}
BootloaderPaths bootloader_paths(Bootloader bl, std::string_view prefix) {
switch (bl) {
case Bootloader::Grub:
return {
.config_path = std::format("{}boot/grub/grub.cfg", prefix),
.install_cmd = "grub-install",
};
case Bootloader::Limine:
return {
.config_path = std::format("{}boot/limine/limine.cfg", prefix),
.install_cmd = "limine",
};
case Bootloader::Unknown:
return {};
}
return {};
}
} // namespace kappa::boot
+27 -9
View File
@@ -1,4 +1,7 @@
#include "kappa/config/eval.hpp"
#include "kappa/service/types.hpp"
#include <iostream>
namespace kappa::config {
@@ -55,26 +58,41 @@ std::vector<AssertFailure> evaluate_assertions(const dsl::SystemConfig& cfg) {
return failures;
}
std::unordered_map<std::string, dsl::ServiceInit> resolve_services(
std::unordered_map<std::string, dsl::NamedService> resolve_services(
const dsl::SystemConfig& cfg,
const std::unordered_map<std::string, dsl::PackageDef>& packages)
{
std::unordered_map<std::string, dsl::ServiceInit> resolved;
std::unordered_map<std::string, dsl::NamedService> resolved;
auto init_system = cfg.boot.init;
auto is = kappa::service::parse_init_system(init_system);
if (is == kappa::service::InitSystem::Unknown) {
std::cerr << "warning: unknown init system '" << init_system
<< "' — no services will be configured\n";
return {};
}
for (auto& svc : cfg.services) {
if (!svc.enable) { continue; }
auto pit = packages.find(svc.name);
// svc.name can be "postgresql" or "postgresql.checkpointer"
auto dot = svc.name.find('.');
auto pkg_name = (dot != std::string::npos)
? svc.name.substr(0, dot)
: svc.name;
auto svc_name = (dot != std::string::npos)
? svc.name.substr(dot + 1)
: std::string("main");
auto pit = packages.find(std::string(pkg_name));
if (pit == packages.end()) { continue; }
auto& pkg = pit->second;
if (pkg.service.empty()) { continue; }
auto it = pkg.service.find(init_system);
if (it != pkg.service.end()) {
resolved[svc.name] = it->second;
for (auto& ns : pkg.services) {
if (ns.name == svc_name) {
resolved[svc.name] = ns;
break;
}
}
}
+6 -2
View File
@@ -12,8 +12,9 @@ static const std::unordered_map<std::string_view, TokenType> keywords = {
{"source", TokenType::KwSource},
{"depends", TokenType::KwDepends},
{"provides", TokenType::KwProvides},
{"outputs", TokenType::KwOutputs},
{"features", TokenType::KwFeatures},
{"outputs", TokenType::KwOutputs},
{"conflicts", TokenType::KwConflicts},
{"features", TokenType::KwFeatures},
{"config", TokenType::KwConfig},
{"const", TokenType::KwConst},
{"license", TokenType::KwLicense},
@@ -29,6 +30,7 @@ static const std::unordered_map<std::string_view, TokenType> keywords = {
{"build", TokenType::KwBuild},
{"check", TokenType::KwCheck},
{"install", TokenType::KwInstall},
{"uninstall", TokenType::KwUninstall},
{"true", TokenType::KwTrue},
{"false", TokenType::KwFalse},
};
@@ -51,6 +53,7 @@ std::string_view token_name(TokenType type) {
case TokenType::KwDepends: return "depends";
case TokenType::KwProvides: return "provides";
case TokenType::KwOutputs: return "outputs";
case TokenType::KwConflicts: return "conflicts";
case TokenType::KwFeatures: return "features";
case TokenType::KwConfig: return "config";
case TokenType::KwConst: return "const";
@@ -67,6 +70,7 @@ std::string_view token_name(TokenType type) {
case TokenType::KwBuild: return "build";
case TokenType::KwCheck: return "check";
case TokenType::KwInstall: return "install";
case TokenType::KwUninstall: return "uninstall";
case TokenType::KwTrue: return "true";
case TokenType::KwFalse: return "false";
}
+49 -34
View File
@@ -157,6 +157,12 @@ void Parser::parse_body(PackageDef& pkg) {
pkg.outputs = parse_string_list();
break;
case TokenType::KwConflicts:
consume(TokenType::KwConflicts);
consume(TokenType::Equals);
pkg.conflicts = parse_string_list();
break;
case TokenType::KwDepends:
consume(TokenType::KwDepends);
consume(TokenType::Equals);
@@ -233,52 +239,56 @@ void Parser::parse_body(PackageDef& pkg) {
consume(TokenType::Rbrace);
break;
case TokenType::KwService:
case TokenType::KwService: {
consume(TokenType::KwService);
NamedService ns;
if (!at(TokenType::Lbrace)) {
ns.name = current_.lexeme;
advance();
}
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto init_name = current_.lexeme;
auto key = current_.lexeme;
advance();
consume(TokenType::Lbrace);
skip_newlines();
ServiceInit si;
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto key = current_.lexeme;
advance();
consume(TokenType::Equals);
if (key == "exec") {
si.exec = consume(TokenType::String).lexeme;
} else if (key == "type") {
si.type = consume(TokenType::String).lexeme;
} else if (key == "user") {
si.user = consume(TokenType::String).lexeme;
} else if (key == "ports") {
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
si.ports.push_back(
parse_int(current_.line, current_.col,
current_.lexeme));
advance();
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else {
si.env[key] = consume(TokenType::String).lexeme;
}
consume(TokenType::Equals);
if (key == "exec") {
ns.exec = consume(TokenType::String).lexeme;
} else if (key == "type") {
ns.type = consume(TokenType::String).lexeme;
} else if (key == "user") {
ns.user = consume(TokenType::String).lexeme;
} else if (key == "ports") {
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
ns.ports.push_back(
parse_int(current_.line, current_.col,
current_.lexeme));
advance();
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (key == "description") {
ns.description = consume(TokenType::String).lexeme;
} else if (key == "after") {
ns.after = consume(TokenType::String).lexeme;
} else if (key == "restart") {
ns.restart = consume(TokenType::String).lexeme;
} else if (key == "working_dir") {
ns.working_dir = consume(TokenType::String).lexeme;
} else {
ns.env[key] = consume(TokenType::String).lexeme;
}
consume(TokenType::Rbrace);
pkg.service[std::string(init_name)] = std::move(si);
skip_newlines();
}
consume(TokenType::Rbrace);
pkg.services.push_back(std::move(ns));
break;
}
case TokenType::KwAssert:
consume(TokenType::KwAssert);
@@ -340,6 +350,11 @@ void Parser::parse_body(PackageDef& pkg) {
pkg.install = parse_phase();
break;
case TokenType::KwUninstall:
consume(TokenType::KwUninstall);
pkg.uninstall = parse_phase();
break;
default:
throw ParseError(current_.line, current_.col,
std::format("unexpected token '{}' in package body",
+43
View File
@@ -28,6 +28,7 @@ private:
void parse_services_block(SystemConfig& cfg);
void parse_boot_block(SystemConfig& cfg);
void parse_users_block(SystemConfig& cfg);
void parse_groups_block(SystemConfig& cfg);
std::string consume_ident();
std::string consume_string();
@@ -130,6 +131,17 @@ SystemConfig SysParser::parse() {
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (kw == "remotes") {
consume(TokenType::Equals);
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
cfg.remotes.push_back(consume(TokenType::String).lexeme);
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (kw == "assert") {
consume(TokenType::Lbrace);
skip_newlines();
@@ -171,6 +183,7 @@ SystemConfig SysParser::parse() {
else if (kw == "services") { parse_services_block(cfg); }
else if (kw == "boot") { parse_boot_block(cfg); }
else if (kw == "users") { parse_users_block(cfg); }
else if (kw == "groups") { parse_groups_block(cfg); }
else {
throw ParseError(current_.line, current_.col,
std::format("unknown section '{}'", kw));
@@ -411,6 +424,34 @@ void SysParser::parse_users_block(SystemConfig& cfg) {
consume(TokenType::Rbrace);
}
void SysParser::parse_groups_block(SystemConfig& cfg) {
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
skip_newlines();
if (at(TokenType::Rbrace)) break;
GroupDef g;
g.name = consume_ident();
if (at(TokenType::Lbrace)) {
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto key = consume_ident();
consume(TokenType::Equals);
if (key == "gid") {
g.gid = parse_int(current_.line, current_.col, current_.lexeme);
advance();
}
skip_newlines();
}
consume(TokenType::Rbrace);
}
cfg.groups.push_back(std::move(g));
}
consume(TokenType::Rbrace);
}
SystemConfig parse_system_config(std::string_view source) {
SysParser p(source);
return p.parse();
@@ -420,12 +461,14 @@ static void merge_config(SystemConfig& base, SystemConfig&& imported) {
if (!imported.system.hostname.empty()) { base.system.hostname = std::move(imported.system.hostname); }
if (!imported.system.timezone.empty()) { base.system.timezone = std::move(imported.system.timezone); }
for (auto& e : imported.system.env) { base.system.env.push_back(std::move(e)); }
for (auto& r : imported.remotes) { base.remotes.push_back(std::move(r)); }
for (auto& [k, v] : imported.system.config) { base.system.config[k] = std::move(v); }
for (auto& [k, v] : imported.system.features) { base.system.features[k] = std::move(v); }
if (imported.system.rollback.keep > 0) { base.system.rollback.keep = imported.system.rollback.keep; }
for (auto& p : imported.packages) { base.packages.push_back(std::move(p)); }
for (auto& s : imported.services) { base.services.push_back(std::move(s)); }
for (auto& u : imported.users) { base.users.push_back(std::move(u)); }
for (auto& g : imported.groups) { base.groups.push_back(std::move(g)); }
if (!imported.boot.kernel.empty()) { base.boot.kernel = std::move(imported.boot.kernel); }
if (!imported.boot.init.empty()) { base.boot.init = std::move(imported.boot.init); }
if (!imported.boot.efi.empty()) { base.boot.efi = std::move(imported.boot.efi); }
+2 -1
View File
@@ -11,7 +11,8 @@ Scope make_default_scope() {
s.builtins["jobs"] = "1";
s.builtins["jobopts"] = "-j1";
s.builtins["destdir"] = (paths::temp_dir() / "destdir").string();
s.builtins["userargs"] = "";
s.builtins["userargs"] = "";
s.builtins["enabledinit"] = "";
return s;
}
+28 -5
View File
@@ -126,16 +126,37 @@ FetchResult fetch(const dsl::PackageDef& pkg) {
}
auto dest_name = pkg.name + "-" + pkg.version;
auto dest_file = fs::path(paths::temp_dir()) / (dest_name + "." + ext);
// Sanitize: replace path separators to prevent traversal
for (auto& c : dest_name) {
if (c == '/' || c == '\\') c = '_';
}
auto cache_path = paths::cache_dir() / (dest_name + "." + ext);
bool from_cache = false;
fs::path dest_file;
if (fs::exists(cache_path)) {
dest_file = cache_path;
from_cache = true;
} else {
dest_file = fs::path(paths::temp_dir()) / (dest_name + "." + ext);
}
result.work_dir = fs::path(paths::temp_dir()) / dest_name;
if (ext == "git") {
int rc = exec_cmd({"git", "clone", url, result.work_dir.string()});
if (rc != 0) { result.error = "git clone failed"; return result; }
} else {
fs::create_directories(paths::temp_dir());
int rc = exec_cmd({"curl", "-L", "-o", dest_file.string(), url});
if (rc != 0) { result.error = "download failed"; return result; }
if (!from_cache) {
fs::create_directories(paths::temp_dir());
int rc = exec_cmd({"curl", "-L", "-o", dest_file.string(), url});
if (rc != 0) { result.error = "download failed"; return result; }
std::error_code ec;
// Atomic cache write: write to .tmp then rename
auto cache_tmp = fs::path(cache_path.string() + ".tmp");
fs::copy(dest_file, cache_tmp, ec);
if (!ec) {
fs::rename(cache_tmp, cache_path, ec);
}
}
bool verified = false;
for (auto algo : {"sha512", "sha256", "md5"}) {
@@ -170,7 +191,9 @@ FetchResult fetch(const dsl::PackageDef& pkg) {
if (rc2 != 0) { result.error = "extraction failed"; return result; }
}
fs::remove(dest_file);
if (!from_cache) {
fs::remove(dest_file);
}
}
for (auto& patch : pkg.patches) {
+128
View File
@@ -0,0 +1,128 @@
#include "kappa/fetch/recipe.hpp"
#include "kappa/dsl/parser.hpp"
#include "kappa/dsl/system.hpp"
#include "kappa/paths.hpp"
#include <sys/wait.h>
#include <unistd.h>
#include <filesystem>
#include <format>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
namespace kappa::fetch {
namespace {
int exec_curl(const std::string& output_path, const std::string& url) {
pid_t pid = fork();
if (pid == 0) {
execlp("curl", "curl", "-Lsf", "-o", output_path.c_str(), url.c_str(), nullptr);
_exit(127);
}
if (pid < 0) return -1;
int status = 0;
while (waitpid(pid, &status, 0) == -1 && errno == EINTR) {}
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
} // namespace
RecipeResult fetch_recipe(const std::string& name,
const std::vector<std::string>& remotes) {
RecipeResult result;
// Check cached version first
auto cache_path = paths::packages_dir() / (name + ".kap");
std::string cached_version;
if (std::filesystem::exists(cache_path)) {
std::ifstream in(cache_path);
if (in) {
std::ostringstream buf;
buf << in.rdbuf();
try {
auto pkg = dsl::parse(buf.str());
cached_version = pkg.version;
} catch (...) {
// Corrupt cache — will re-download
}
}
}
// Try each remote
std::string best_content;
std::string best_version;
std::string best_url;
for (auto& remote : remotes) {
auto url = remote;
if (!url.empty() && url.back() != '/') url += '/';
url += name + ".kap";
// Download to temp
auto temp_path = paths::temp_dir() / (name + ".kap.tmp");
int rc = exec_curl(temp_path.string(), url);
if (rc != 0) continue;
// Parse downloaded file
std::ifstream in(temp_path);
if (!in) { std::filesystem::remove(temp_path); continue; }
std::ostringstream buf;
buf << in.rdbuf();
in.close();
std::string remote_version;
try {
auto pkg = dsl::parse(buf.str());
remote_version = pkg.version;
} catch (...) {
std::filesystem::remove(temp_path);
continue;
}
// Compare versions — keep the best (highest)
// Simple string comparison for now; semver later
if (remote_version > best_version) {
best_version = remote_version;
best_content = buf.str();
best_url = url;
}
std::filesystem::remove(temp_path);
}
if (best_content.empty()) {
if (!cached_version.empty()) {
// No remote available but have cache
result.ok = true;
result.path = cache_path.string();
result.version = cached_version;
result.updated = false;
return result;
}
result.error = "package '" + name + "' not found in any remote";
return result;
}
// Update cache if remote is newer
if (best_version > cached_version || cached_version.empty()) {
std::error_code ec;
std::filesystem::create_directories(paths::packages_dir(), ec);
std::ofstream out(cache_path);
if (!out) {
result.error = "cannot write to cache";
return result;
}
out << best_content;
result.updated = true;
}
result.ok = true;
result.path = cache_path.string();
result.version = best_version;
return result;
}
} // namespace kappa::fetch
+156 -10
View File
@@ -4,10 +4,13 @@
#include "kappa/dsl/parser.hpp"
#include "kappa/dsl/system.hpp"
#include "kappa/fetch/fetch.hpp"
#include "kappa/fetch/recipe.hpp"
#include "kappa/install/install.hpp"
#include "kappa/paths.hpp"
#include "kappa/rebuild/rebuild.hpp"
#include "kappa/resolve/plan.hpp"
#include "kappa/boot/types.hpp"
#include "kappa/service/types.hpp"
#include "kappa/tools/doctor.hpp"
#include "kappa/tools/format.hpp"
@@ -37,6 +40,7 @@ Subcommands:
doctor <file> Check a .kap file for issues and warnings
resolve <config> Resolve a build plan from a system config
fetch <package> Download and verify source for a package
fetch-package <name> Fetch a package recipe from configured remotes
build <package> Build a package from its source directory
rebuild <config> Compare config to installed state, rebuild changed
list List installed packages
@@ -87,6 +91,60 @@ static void handle_parse_error(const char* path,
std::cerr << "error: " << e.what() << '\n';
}
static resolve::Registry build_registry(const dsl::SystemConfig& cfg) {
resolve::Registry registry;
for (const auto& pref : cfg.packages) {
bool found = false;
// Check standard locations: local .kap, examples/, cache/packages/
std::vector<std::string> search_paths = {
std::string(pref.name) + ".kap",
std::string("examples/") + pref.name + ".kap",
(paths::packages_dir() / (pref.name + ".kap")).string(),
};
for (const auto& sp : search_paths) {
std::ifstream in(sp);
if (!in) continue;
std::ostringstream buf;
buf << in.rdbuf();
try {
auto pkg = dsl::parse(buf.str());
registry[pkg.name] = std::move(pkg);
found = true;
break;
} catch (...) {
continue;
}
}
// If not found locally, try remotes
if (!found && !cfg.remotes.empty()) {
auto result = fetch::fetch_recipe(pref.name, cfg.remotes);
if (result.ok && !result.path.empty()) {
std::ifstream in(result.path);
if (in) {
std::ostringstream buf;
buf << in.rdbuf();
try {
auto pkg = dsl::parse(buf.str());
registry[pkg.name] = std::move(pkg);
found = true;
} catch (...) {}
}
}
}
if (!found) {
std::cerr << "warning: package '" << pref.name
<< "' not found locally or in remotes\n";
}
}
return registry;
}
int main(int argc, char* argv[]) {
paths::ensure_directories();
@@ -117,7 +175,8 @@ int main(int argc, char* argv[]) {
|| (subcommand == "build")
|| (subcommand == "rebuild")
|| (subcommand == "list")
|| (subcommand == "rollback");
|| (subcommand == "fetch-package")
|| (subcommand == "rollback");
if (!valid_subcommand) {
std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n";
@@ -218,6 +277,26 @@ int main(int argc, char* argv[]) {
<< cfg.packages.size() << " packages, "
<< cfg.services.size() << " services, "
<< cfg.users.size() << " users)\n";
if (!cfg.boot.init.empty()) {
auto is = kappa::service::parse_init_system(cfg.boot.init);
std::cout << " init: " << cfg.boot.init;
if (is != kappa::service::InitSystem::Unknown) {
std::cout << " (" << kappa::service::init_description(is) << ")";
} else {
std::cout << " (unrecognized)";
}
std::cout << "\n";
}
if (!cfg.boot.bootloader.empty()) {
auto bl = kappa::boot::parse_bootloader(cfg.boot.bootloader);
std::cout << " bootloader: " << cfg.boot.bootloader;
if (bl != kappa::boot::Bootloader::Unknown) {
std::cout << " (" << kappa::boot::bootloader_description(bl) << ")";
} else {
std::cout << " (unrecognized)";
}
std::cout << "\n";
}
return 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
@@ -358,13 +437,7 @@ int main(int argc, char* argv[]) {
try {
auto cfg = dsl::parse_system_config(source);
resolve::Registry registry;
auto* pkg_arg = (argc > 3) ? argv[3] : nullptr;
if (pkg_arg != nullptr) {
auto pkg_src = read_file(pkg_arg);
auto pkg = dsl::parse(pkg_src);
registry[pkg.name] = std::move(pkg);
}
auto registry = build_registry(cfg);
auto plan = resolve::resolve(cfg, registry);
@@ -420,6 +493,41 @@ int main(int argc, char* argv[]) {
}
}
if (subcommand == "fetch-package") {
if (file_arg == nullptr) {
std::cerr << "error: no package name specified\n";
return 1;
}
try {
std::vector<std::string> remotes;
auto config_path = std::filesystem::path(paths::system_dir()) / "config.kap";
if (std::filesystem::exists(config_path)) {
auto cfg_src = read_file(config_path.c_str());
try {
auto cfg = dsl::parse_system_config(cfg_src);
remotes = cfg.remotes;
} catch (...) {
}
}
auto result = fetch::fetch_recipe(file_arg, remotes);
if (result.ok) {
if (result.updated) {
std::cout << "fetched " << file_arg << " " << result.version
<< " → " << result.path << "\n";
} else {
std::cout << file_arg << " " << result.version
<< " (cached, up to date)\n";
}
return 0;
}
std::cerr << "fetch failed: " << result.error << "\n";
return 1;
} catch (const std::exception& e) {
std::cerr << "fetch error: " << e.what() << "\n";
return 1;
}
}
if (subcommand == "rebuild") {
try {
auto cfg = dsl::parse_system_config(source);
@@ -442,8 +550,46 @@ int main(int argc, char* argv[]) {
std::cout << " building " << name << " (new)\n";
}
if (cs.kernel_changed) { std::cout << " kernel changed\n"; }
if (cs.init_changed) { std::cout << " init changed\n"; }
if (cs.bootloader_changed) { std::cout << " bootloader changed\n"; }
if (cs.init_changed) {
auto is_name = cfg.boot.init;
auto is = kappa::service::parse_init_system(is_name);
if (is != kappa::service::InitSystem::Unknown) {
std::cout << " init system: " << is_name << " ("
<< kappa::service::init_description(is) << ")\n";
} else {
std::cout << " init changed (" << is_name
<< " — unrecognized)\n";
}
auto registry = build_registry(cfg);
auto impact = rebuild::compute_init_impact(cfg, registry);
if (!impact.service_rebuild.empty()) {
std::cout << " full rebuild (uses ${enabledinit}): "
<< impact.service_rebuild.size()
<< " packages\n";
}
if (!impact.service_only.empty()) {
std::cout << " service files only: "
<< impact.service_only.size()
<< " packages\n";
}
if (!impact.skipped.empty()) {
std::cout << " no services — skipped: "
<< impact.skipped.size() << " packages\n";
}
}
if (cs.bootloader_changed) {
auto bl_name = cfg.boot.bootloader;
auto bl = kappa::boot::parse_bootloader(bl_name);
if (bl != kappa::boot::Bootloader::Unknown) {
std::cout << " bootloader: " << bl_name << " ("
<< kappa::boot::bootloader_description(bl) << ")\n";
} else {
std::cout << " bootloader changed (" << bl_name
<< " — unrecognized)\n";
}
}
if (cs.services_changed) { std::cout << " services changed\n"; }
return 0;
+4
View File
@@ -20,6 +20,8 @@ std::filesystem::path temp_dir() { return g_root / "temp"; }
std::filesystem::path db_dir() { return g_root / "db"; }
std::filesystem::path system_dir() { return g_root / "system"; }
std::filesystem::path builds_dir() { return g_root / "system" / "builds"; }
std::filesystem::path cache_dir() { return g_root / "cache"; }
std::filesystem::path packages_dir() { return cache_dir() / "packages"; }
void ensure_directories() {
std::error_code ec;
@@ -27,6 +29,8 @@ void ensure_directories() {
std::filesystem::create_directories(temp_dir(), ec);
std::filesystem::create_directories(db_dir(), ec);
std::filesystem::create_directories(builds_dir(), ec);
std::filesystem::create_directories(cache_dir(), ec);
std::filesystem::create_directories(packages_dir(), ec);
}
} // namespace kappa::paths
+62 -3
View File
@@ -1,6 +1,8 @@
#include "kappa/rebuild/rebuild.hpp"
#include "kappa/install/install.hpp"
#include "kappa/service/types.hpp"
#include <iostream>
#include <set>
namespace kappa::rebuild {
@@ -43,13 +45,17 @@ ChangeSet compute_changes(const dsl::SystemConfig& cfg) {
}
for (auto& e : installed) {
if (e.name == "kernel" && e.hash != cfg.boot.kernel) {
// Compare the installed version (which stores the identifier string
// for virtual packages like init/kernel/bootloader) against the
// config value. These virtual entries have their identity in the
// version field, not the hash field.
if (e.name == "kernel" && e.version != cfg.boot.kernel) {
cs.kernel_changed = true;
}
if (e.name == "init" && e.hash != cfg.boot.init) {
if (e.name == "init" && e.version != cfg.boot.init) {
cs.init_changed = true;
}
if (e.name == "bootloader" && e.hash != cfg.boot.bootloader) {
if (e.name == "bootloader" && e.version != cfg.boot.bootloader) {
cs.bootloader_changed = true;
}
}
@@ -61,4 +67,57 @@ ChangeSet compute_changes(const dsl::SystemConfig& cfg) {
return cs;
}
InitImpact compute_init_impact(const dsl::SystemConfig& cfg,
const resolve::Registry& registry) {
InitImpact impact;
auto init_system = cfg.boot.init;
// Only compute impact if an init system is actually configured
if (init_system.empty()) { return impact; }
auto is = kappa::service::parse_init_system(init_system);
if (is == kappa::service::InitSystem::Unknown) {
std::cerr << "warning: unknown init system '" << init_system
<< "' — cannot compute init impact\n";
return impact;
}
// For each package in the config that has a matching registry entry...
for (auto& pref : cfg.packages) {
auto rit = registry.find(pref.name);
if (rit == registry.end()) { continue; }
auto& pkg = rit->second;
// No services — nothing to do
if (pkg.services.empty()) {
impact.skipped.push_back(pkg.name);
continue;
}
// Check if build scripts reference ${enabledinit}
bool uses_enabledinit = false;
auto check_phase = [&](const dsl::Phase& phase) {
for (auto& cmd : phase.commands) {
if (cmd.find("${enabledinit}") != std::string::npos) {
uses_enabledinit = true;
return;
}
}
};
check_phase(pkg.prepare);
if (!uses_enabledinit) check_phase(pkg.build);
if (!uses_enabledinit) check_phase(pkg.check);
if (!uses_enabledinit) check_phase(pkg.install);
if (uses_enabledinit) {
impact.service_rebuild.push_back(pkg.name);
} else {
impact.service_only.push_back(pkg.name);
}
}
return impact;
}
} // namespace kappa::rebuild
+20 -3
View File
@@ -1,7 +1,9 @@
#include "kappa/resolve/plan.hpp"
#include "kappa/config/merge.hpp"
#include <format>
#include <queue>
#include <unordered_set>
namespace kappa::resolve {
@@ -21,9 +23,10 @@ BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) {
auto resolved = config::resolve_package(pkg, cfg.system, pref);
BuildStep step;
step.name = pkg.name;
step.package = &pkg;
step.resolved = std::move(resolved);
step.name = pkg.name;
step.package = &pkg;
step.resolved = std::move(resolved);
step.enabled_init = cfg.boot.init;
for (auto& dep : pkg.depends) {
if (!dep.feature.empty()) {
@@ -39,6 +42,20 @@ BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) {
nodes.push_back(std::move(step));
}
// Detect conflicts between selected packages
std::unordered_set<std::string> selected;
for (auto& node : nodes) { selected.insert(node.name); }
for (auto& node : nodes) {
if (!node.package) continue;
for (auto& conflict_name : node.package->conflicts) {
if (selected.contains(conflict_name)) {
plan.conflicts.push_back(
std::format("{} conflicts with {}", node.name, conflict_name));
}
}
}
std::vector<int> in_degree(nodes.size(), 0);
std::vector<std::vector<std::size_t>> adj(nodes.size());
+274 -11
View File
@@ -1,27 +1,290 @@
#include "kappa/sched/scheduler.hpp"
#include "kappa/build/build.hpp"
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <format>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace kappa::sched {
// ---------------------------------------------------------------------------
// Internal node tracked per package during scheduling
// ---------------------------------------------------------------------------
struct Node {
const resolve::BuildStep* step = nullptr;
int pending_deps = 0; // dependencies not yet built
std::vector<std::size_t> dependents; // packages waiting on this one
int depth = 0; // distance from deepest leaf
bool claimed = false;
build::BuildResult result;
};
// ---------------------------------------------------------------------------
// Priority queue keyed by depth group (Beta/Alpha/Zeta cycle)
// Deeper packages (higher depth) get priority so they unblock more work.
// ---------------------------------------------------------------------------
struct ReadyOrder {
bool operator()(const Node* a, const Node* b) const {
return a->depth < b->depth; // max-heap by depth
}
};
// ---------------------------------------------------------------------------
// Three-level depth grouping: Beta → Alpha → Zeta → Beta → ...
// Returns a priority value: Beta = 2, Alpha = 1, Zeta = 0.
// Higher value = build sooner.
// ---------------------------------------------------------------------------
static int level_priority(int depth) {
int level = depth % 3;
// Beta=0, Alpha=1, Zeta=2
// Beta should go first (priority 2), Alpha second (1), Zeta last (0)
return (3 - level) % 3;
}
// ---------------------------------------------------------------------------
// Scheduler state shared between workers
// ---------------------------------------------------------------------------
struct Scheduler {
std::vector<Node> nodes;
std::unordered_map<std::string, std::size_t> name_to_idx;
// Ready packages grouped by priority → max-heap
std::priority_queue<Node*, std::vector<Node*>, ReadyOrder> ready[3];
// Packages waiting (pending_deps > 0 but not yet ready)
std::unordered_set<std::size_t> waiting;
std::mutex mtx;
std::condition_variable cv;
std::atomic<int> active_workers{0};
std::atomic<int> completed{0};
std::atomic<bool> stop{false};
int total_packages = 0;
std::string work_root;
int jobs_per_worker = 1;
SchedResult result;
};
// ---------------------------------------------------------------------------
// Enqueue a node into the ready queue at the correct priority level
// ---------------------------------------------------------------------------
static void enqueue_ready(Scheduler& s, std::size_t idx) {
Node& node = s.nodes[idx];
int prio = level_priority(node.depth);
s.ready[prio].push(&node);
s.waiting.erase(idx);
}
// ---------------------------------------------------------------------------
// Try to claim the next ready package from any priority level.
// Returns nullptr if nothing is ready.
// ---------------------------------------------------------------------------
static Node* claim_next(Scheduler& s) {
// Check Beta (0), then Alpha (1), then Zeta (2)
for (int p = 2; p >= 0; --p) {
auto& q = s.ready[p];
if (q.empty()) continue;
Node* node = q.top();
q.pop();
node->claimed = true;
return node;
}
return nullptr;
}
// ---------------------------------------------------------------------------
// Worker loop
// ---------------------------------------------------------------------------
static void worker_loop(Scheduler& s) {
s.active_workers.fetch_add(1, std::memory_order_relaxed);
while (true) {
Node* node = nullptr;
{
std::unique_lock lock(s.mtx);
s.cv.wait(lock, [&] {
return s.stop.load(std::memory_order_acquire)
|| !s.ready[0].empty()
|| !s.ready[1].empty()
|| !s.ready[2].empty()
|| s.completed.load(std::memory_order_acquire) >= s.total_packages;
});
if (s.stop.load(std::memory_order_acquire)) break;
if (s.completed.load(std::memory_order_acquire) >= s.total_packages) break;
node = claim_next(s);
}
if (node == nullptr) continue;
// Build the package
std::cout << std::format(" building {} (depth={})\n",
node->step->name, node->depth);
auto r = build::build(*node->step,
s.work_root + "/" + node->step->name,
s.jobs_per_worker);
node->result = r;
// Mark complete and notify dependents
{
std::lock_guard lock(s.mtx);
s.completed.fetch_add(1, std::memory_order_relaxed);
if (r.ok) {
s.result.built.push_back(node->step->name);
} else {
s.result.failed.push_back(node->step->name);
s.result.ok = false;
s.stop.store(true, std::memory_order_release);
}
// Wake up dependents
for (auto dep_idx : node->dependents) {
Node& dep = s.nodes[dep_idx];
dep.pending_deps--;
if (dep.pending_deps == 0) {
enqueue_ready(s, dep_idx);
}
}
}
// Signal completion or failure
{
std::lock_guard lock(s.mtx);
if (s.completed.load(std::memory_order_acquire) >= s.total_packages) {
s.stop.store(true, std::memory_order_release);
}
}
s.cv.notify_all();
}
s.active_workers.fetch_sub(1, std::memory_order_relaxed);
}
// ---------------------------------------------------------------------------
// Compute depth for each node (distance from deepest leaf)
// Uses post-order traversal: depth = 1 + max(dep depths), leaf = 1
// ---------------------------------------------------------------------------
static void compute_depths(Scheduler& s) {
// Start from leaves (pending_deps == 0)
std::queue<std::size_t> leaf_queue;
for (std::size_t i = 0; i < s.nodes.size(); ++i) {
if (s.nodes[i].pending_deps == 0) {
s.nodes[i].depth = 1;
leaf_queue.push(i);
}
}
// Propagate upward: when a dependent is processed, its depth
// is 1 + max of its dependency depths.
// For simplicity, we approximate: depth = level from leaves.
// This is fine for prioritization — deeper = unblocks more.
std::vector<int> rem_deps(s.nodes.size());
for (std::size_t i = 0; i < s.nodes.size(); ++i) {
rem_deps[i] = static_cast<int>(s.nodes[i].dependents.size());
}
while (!leaf_queue.empty()) {
auto u = leaf_queue.front();
leaf_queue.pop();
Node& node = s.nodes[u];
if (node.step == nullptr) continue;
for (auto dep_idx : node.dependents) {
Node& dep_node = s.nodes[dep_idx];
if (node.depth + 1 > dep_node.depth) {
dep_node.depth = node.depth + 1;
}
if (--rem_deps[dep_idx] == 0) {
leaf_queue.push(dep_idx);
}
}
}
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
SchedResult run(const resolve::BuildPlan& plan,
const std::string& work_root,
int workers,
int jobs) {
SchedResult result;
if (plan.steps.empty()) {
return {true, {}, {}};
}
for (auto& step : plan.steps) {
auto r = build::build(step, work_root + "/" + step.name, jobs);
if (r.ok) {
result.built.push_back(step.name);
} else {
result.failed.push_back(step.name);
result.ok = false;
return result;
Scheduler s;
s.work_root = work_root;
s.jobs_per_worker = std::max(1, jobs);
s.total_packages = static_cast<int>(plan.steps.size());
s.nodes.resize(plan.steps.size());
// Build name → index map
for (std::size_t i = 0; i < plan.steps.size(); ++i) {
s.name_to_idx[plan.steps[i].name] = i;
}
// Wire up nodes: dependencies, dependents, pending_deps
for (std::size_t i = 0; i < plan.steps.size(); ++i) {
Node& node = s.nodes[i];
node.step = &plan.steps[i];
node.depth = 0;
for (auto& dep : plan.steps[i].dependencies) {
auto it = s.name_to_idx.find(dep.name);
if (it == s.name_to_idx.end()) {
std::cerr << std::format("warning: dependency '{}' of '{}' not in build plan\n",
dep.name, plan.steps[i].name);
continue;
}
// dep → i (dependency is upstream)
Node& dep_node = s.nodes[it->second];
dep_node.dependents.push_back(i);
node.pending_deps++;
}
}
result.ok = true;
return result;
// Compute depths for priority
compute_depths(s);
// Enqueue root nodes (pending_deps == 0)
for (std::size_t i = 0; i < s.nodes.size(); ++i) {
if (s.nodes[i].pending_deps == 0) {
enqueue_ready(s, i);
} else {
s.waiting.insert(i);
}
}
int num_workers = std::max(1, std::min(workers, s.total_packages));
std::cout << std::format("scheduler: {} packages, {} workers, {} jobs/worker\n",
s.total_packages, num_workers, s.jobs_per_worker);
// Spawn workers
std::vector<std::thread> threads;
for (int w = 0; w < num_workers; ++w) {
threads.emplace_back(worker_loop, std::ref(s));
}
// Wait for workers to finish
for (auto& t : threads) {
if (t.joinable()) t.join();
}
s.result.ok = s.result.failed.empty();
return s.result;
}
} // namespace kappa::sched
+74
View File
@@ -0,0 +1,74 @@
#include "kappa/service/service.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
std::string generate_dinit_service(const ServiceSpec& spec) {
std::ostringstream out;
// Header
out << std::format("# Generated by kappa — do not edit manually\n");
out << std::format("# dinit service: {}\n", spec.name);
out << "\n";
// Type mapping
std::string dinit_type;
if (spec.type == "simple" || spec.type == "notify" ||
spec.type == "longrun") {
dinit_type = "process";
} else if (spec.type == "forking") {
dinit_type = "bgprocess";
} else if (spec.type == "oneshot") {
dinit_type = "scripted";
} else {
dinit_type = "process";
}
out << std::format("type = {}\n", dinit_type);
out << std::format("command = {}\n", spec.exec);
// Restart policy
if (spec.restart_policy == "always") {
out << "restart = true\n";
} else if (spec.restart_policy == "on-failure") {
out << "restart = true\n";
} else if (spec.restart_policy == "never") {
out << "restart = false\n";
} else if (!spec.restart_policy.empty()) {
out << "restart = true\n";
}
// depends-on
if (!spec.after.empty()) {
out << std::format("depends-on = {}\n", spec.after);
}
// working-dir
if (!spec.working_dir.empty()) {
out << std::format("working-dir = {}\n", spec.working_dir);
}
// run-as
if (!spec.user.empty()) {
out << std::format("run-as = {}\n", spec.user);
}
// Environment variables (as comments — dinit doesn't support inline env)
if (!spec.env.empty()) {
out << "\n# Environment variables:\n";
for (const auto& [key, value] : spec.env) {
out << std::format("# {}={}\n", key, value);
}
}
// description
if (!spec.description.empty()) {
out << std::format("description = {}\n", spec.description);
}
return out.str();
}
} // namespace kappa::service
+202
View File
@@ -0,0 +1,202 @@
#include "kappa/service/service.hpp"
#include <filesystem>
#include <format>
#include <fstream>
namespace kappa::service {
// ---------------------------------------------------------------------------
// Backend service-file generators (defined in separate .cpp files)
// systemd_service and s6_service are declared in service.hpp
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ServiceSpec::from_service_init
// ---------------------------------------------------------------------------
ServiceSpec ServiceSpec::from_service_init(const dsl::NamedService& ns) {
ServiceSpec spec;
spec.name = ns.name;
spec.description = ns.description;
spec.exec = ns.exec;
spec.user = ns.user;
spec.ports = ns.ports;
spec.env = ns.env;
spec.after = ns.after;
spec.working_dir = ns.working_dir;
// Normalize type
if (ns.type == "forking" || ns.type == "longrun" ||
ns.type == "notify" || ns.type == "oneshot") {
spec.type = ns.type;
} else {
spec.type = "simple";
}
// Restart policy
spec.restart_policy = ns.restart;
if (spec.restart_policy.empty() && ns.type == "longrun") {
spec.restart_policy = "always";
}
return spec;
}
// ---------------------------------------------------------------------------
// generate_service_file — dispatch to the correct backend
// ---------------------------------------------------------------------------
std::string generate_service_file(InitSystem is, const ServiceSpec& spec) {
switch (is) {
case InitSystem::Systemd:
return generate_systemd_service(spec);
case InitSystem::OpenRC:
return generate_openrc_service(spec);
case InitSystem::S6:
return generate_s6_service(spec);
case InitSystem::Dinit:
return generate_dinit_service(spec);
case InitSystem::Runit:
return generate_runit_service(spec);
case InitSystem::Unknown:
default:
return {};
}
}
// ---------------------------------------------------------------------------
// install_service — write the generated file(s) to disk
// ---------------------------------------------------------------------------
ServiceInstallResult install_service(InitSystem is,
const ServiceSpec& spec,
std::string_view prefix) {
namespace fs = std::filesystem;
auto paths = init_paths(is, prefix);
if (paths.service_dir.empty()) {
return {false, {}, "Unknown init system"};
}
std::error_code ec;
// ---- S6: directory-based layout (type + run) ----
// S6: directory-based layout. Content generated inline rather than
// calling generate_s6_service() to avoid parsing the combined output.
if (is == InitSystem::S6) {
fs::path svc_dir = fs::path(paths.service_dir) / spec.name;
fs::create_directories(svc_dir, ec);
if (ec) {
return {false, {}, ec.message()};
}
// --- type file ---
{
fs::path type_path = svc_dir / "type";
std::ofstream out(type_path);
if (!out) {
return {false, {},
std::format("Failed to write {}", type_path.string())};
}
out << ((spec.type == "oneshot") ? "oneshot" : "longrun");
}
// --- run file ---
fs::path run_path = svc_dir / "run";
{
std::ofstream out(run_path);
if (!out) {
return {false, {},
std::format("Failed to write {}", run_path.string())};
}
out << "#!/bin/execlineb -P\n";
out << "# Generated by kappa — do not edit manually\n";
out << std::format("# s6 service: {}\n", spec.name);
if (!spec.working_dir.empty()) {
out << std::format("cd {}\n", spec.working_dir);
}
for (const auto& [key, value] : spec.env) {
out << std::format("export {} \"{}\"\n", key, value);
}
if (!spec.user.empty()) {
out << std::format("s6-setuidgid {}\n", spec.user);
}
out << spec.exec << "\n";
}
// Make run file executable
fs::permissions(run_path,
fs::perms::owner_exec | fs::perms::group_exec |
fs::perms::others_exec,
fs::perm_options::add, ec);
return {true, svc_dir.string(), {}};
}
// ---- Runit: directory-based layout (run) ----
if (is == InitSystem::Runit) {
fs::path svc_dir = fs::path(paths.service_dir) / spec.name;
fs::create_directories(svc_dir, ec);
if (ec) {
return {false, {}, ec.message()};
}
fs::path run_path = svc_dir / "run";
{
std::ofstream out(run_path);
if (!out) {
return {false, {},
std::format("Failed to write {}", run_path.string())};
}
out << generate_runit_service(spec);
}
// Make run executable
fs::permissions(run_path,
fs::perms::owner_exec | fs::perms::group_exec |
fs::perms::others_exec,
fs::perm_options::add, ec);
return {true, svc_dir.string(), {}};
}
// ---- Systemd / OpenRC / Dinit: single service file ----
std::string content = generate_service_file(is, spec);
if (content.empty()) {
return {false, {}, "Failed to generate service file for init system"};
}
// Determine filename
std::string filename;
switch (is) {
case InitSystem::Systemd:
filename = std::format("{}.service", spec.name);
break;
case InitSystem::OpenRC:
case InitSystem::Dinit:
filename = spec.name;
break;
default:
return {false, {}, "Unknown init system"};
}
fs::path file_path = fs::path(paths.service_dir) / filename;
fs::create_directories(file_path.parent_path(), ec);
if (ec) {
return {false, {}, ec.message()};
}
{
std::ofstream out(file_path);
if (!out) {
return {false, {},
std::format("Failed to write {}", file_path.string())};
}
out << content;
}
return {true, file_path.string(), {}};
}
} // namespace kappa::service
+81
View File
@@ -0,0 +1,81 @@
#include "kappa/service/service.hpp"
#include "kappa/util.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
namespace {
bool is_background_type(std::string_view type) {
return type == "longrun" || type == "notify" || type == "forking";
}
} // namespace
std::string generate_openrc_service(const ServiceSpec& spec) {
std::ostringstream os;
// Shebang and header
os << "#!/sbin/openrc-run\n";
os << "# Generated by kappa — do not edit manually\n";
// Description
auto desc = spec.description.empty()
? spec.name + " service"
: spec.description;
os << std::format("description=\"{}\"\n", util::shell_escape(desc));
// Command
os << std::format("\ncommand=\"{}\"\n", util::shell_escape(spec.exec));
// User
if (!spec.user.empty()) {
os << std::format("command_user=\"{}\"\n", util::shell_escape(spec.user));
}
// command_background and command_args (type-specific)
if (spec.type == "oneshot") {
os << "command_background=false\n";
os << "command_args=\"\"\n";
} else if (is_background_type(spec.type)) {
os << "command_background=true\n";
} else if (!spec.working_dir.empty()) {
os << "command_background=true\n";
}
// Working directory
if (!spec.working_dir.empty()) {
os << std::format("directory=\"{}\"\n",
util::shell_escape(spec.working_dir));
}
// Depend block (if after or ports)
bool has_depend = !spec.after.empty() || !spec.ports.empty();
if (has_depend) {
os << "\ndepend() {\n";
if (!spec.after.empty()) {
os << std::format(" need {}\n", spec.after);
}
if (!spec.ports.empty()) {
os << " use net\n";
}
os << "}\n";
}
// Restart policy comment
if (!spec.restart_policy.empty()) {
os << std::format("# restart policy: {}\n", spec.restart_policy);
}
// Environment exports
for (auto& [key, value] : spec.env) {
os << std::format("export {}=\"{}\"\n",
key, util::shell_escape(value));
}
return os.str();
}
} // namespace kappa::service
+57
View File
@@ -0,0 +1,57 @@
#include "kappa/service/service.hpp"
#include "kappa/util.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
std::string generate_runit_service(const ServiceSpec& spec) {
std::ostringstream out;
// Shebang
out << "#!/bin/sh\n";
// Header
out << "# Generated by kappa — do not edit manually\n";
out << std::format("# runit service: {}\n", spec.name);
out << std::format("# Type: {}\n", spec.type);
// Forking note
if (spec.type == "forking") {
out << "# NOTE: runit requires foreground execution.\n";
out << "# If the daemon forks, pass --foreground or equivalent"
" flag.\n";
}
// Oneshot note
if (spec.type == "oneshot") {
out << "# NOTE: runit has no native oneshot support. This service"
" will restart on exit.\n";
}
// Redirect stderr to stdout for logging
out << "exec 2>&1\n";
// Working directory
if (!spec.working_dir.empty()) {
out << std::format("cd \"{}\"\n", util::shell_escape(spec.working_dir));
}
// Environment variables
for (const auto& [key, value] : spec.env) {
out << std::format("export {}=\"{}\"\n", key, util::shell_escape(value));
}
// Final exec — replace the shell with the daemon
if (!spec.user.empty()) {
out << std::format("exec chpst -u {} {}\n",
util::shell_escape(spec.user), spec.exec);
} else {
out << "exec " << spec.exec << "\n";
}
return out.str();
}
} // namespace kappa::service
+47
View File
@@ -0,0 +1,47 @@
#include "kappa/service/service.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
std::string generate_s6_service(const ServiceSpec& spec) {
std::ostringstream out;
// --- type file content ---
std::string type_content;
if (spec.type == "oneshot") {
type_content = "oneshot";
} else {
// "longrun", "notify", and anything else map to longrun in s6
type_content = "longrun";
}
// --- run file content ---
std::ostringstream run;
run << "#!/bin/execlineb -P\n";
run << "# Generated by kappa — do not edit manually\n";
run << std::format("# s6 service: {}\n", spec.name);
if (!spec.working_dir.empty()) {
run << std::format("cd {}\n", spec.working_dir);
}
for (const auto& [key, value] : spec.env) {
run << std::format("export {} \"{}\"\n", key, value);
}
if (!spec.user.empty()) {
run << std::format("s6-setuidgid {}\n", spec.user);
}
run << spec.exec << "\n";
// --- combined output ---
out << std::format("# --- s6 service directory: {} ---\n", spec.name);
out << "# file: type\n";
out << type_content << "\n";
out << "# file: run\n";
out << run.str();
return out.str();
}
} // namespace kappa::service
+62
View File
@@ -0,0 +1,62 @@
#include "kappa/service/service.hpp"
#include <format>
#include <sstream>
namespace kappa::service {
std::string generate_systemd_service(const ServiceSpec& spec) {
std::ostringstream out;
// --- [Unit] ---
out << "[Unit]\n";
out << std::format("Description={}\n",
spec.description.empty() ? spec.name : spec.description);
if (!spec.after.empty()) {
out << std::format("After={}\n", spec.after);
}
// --- [Service] ---
out << "\n[Service]\n";
out << std::format("ExecStart={}\n", spec.exec);
if (spec.type == "simple") {
out << "Type=simple\n";
} else if (spec.type == "forking") {
out << "Type=forking\n";
} else if (spec.type == "oneshot") {
out << "Type=oneshot\n";
} else if (spec.type == "notify") {
out << "Type=notify\n";
} else {
out << "Type=simple\n";
}
if (!spec.user.empty()) {
out << std::format("User={}\n", spec.user);
}
if (spec.restart_policy == "always") {
out << "Restart=always\n";
} else if (spec.restart_policy == "on-failure") {
out << "Restart=on-failure\n";
} else if (spec.restart_policy == "never") {
out << "Restart=no\n";
}
if (!spec.working_dir.empty()) {
out << std::format("WorkingDirectory={}\n", spec.working_dir);
}
for (const auto& [key, value] : spec.env) {
out << std::format("Environment=\"{0}={1}\"\n", key, value);
}
// --- [Install] ---
out << "\n[Install]\n";
out << "WantedBy=multi-user.target\n";
return out.str();
}
} // namespace kappa::service
+98
View File
@@ -0,0 +1,98 @@
#include "kappa/service/types.hpp"
#include "kappa/util.hpp"
#include <algorithm>
#include <cctype>
#include <format>
#include <ranges>
namespace kappa::service {
InitSystem parse_init_system(std::string_view name) {
auto lower = util::to_lower(name);
if (lower == "systemd") return InitSystem::Systemd;
if (lower == "openrc") return InitSystem::OpenRC;
if (lower == "s6") return InitSystem::S6;
if (lower == "runit") return InitSystem::Runit;
if (lower == "dinit") return InitSystem::Dinit;
return InitSystem::Unknown;
}
std::string_view to_string(InitSystem is) {
switch (is) {
case InitSystem::Systemd: return "systemd";
case InitSystem::OpenRC: return "openrc";
case InitSystem::S6: return "s6";
case InitSystem::Runit: return "runit";
case InitSystem::Dinit: return "dinit";
case InitSystem::Unknown: return "unknown";
}
return "unknown";
}
bool is_supported(std::string_view name) {
return parse_init_system(name) != InitSystem::Unknown;
}
std::vector<InitSystem> all_systems() {
return {InitSystem::Systemd, InitSystem::OpenRC,
InitSystem::S6, InitSystem::Runit, InitSystem::Dinit};
}
std::string_view init_description(InitSystem is) {
switch (is) {
case InitSystem::Systemd:
return "systemd — system and service manager";
case InitSystem::OpenRC:
return "OpenRC — dependency-based init system";
case InitSystem::S6:
return "s6 — s6 supervision suite";
case InitSystem::Runit:
return "runit — supervision suite";
case InitSystem::Dinit:
return "dinit — service manager / init system";
case InitSystem::Unknown:
return "unknown init system";
}
return "unknown init system";
}
InitPaths init_paths(InitSystem is, std::string_view prefix) {
switch (is) {
case InitSystem::Systemd:
return {
.service_dir = std::format("{}etc/systemd/system", prefix),
.enable_cmd = "systemctl enable",
.disable_cmd = "systemctl disable",
};
case InitSystem::OpenRC:
return {
.service_dir = std::format("{}etc/init.d", prefix),
.enable_cmd = "rc-update add",
.disable_cmd = "rc-update del",
};
case InitSystem::S6:
return {
.service_dir = std::format("{}etc/s6/sv", prefix),
.enable_cmd = "s6-rc-bundle-update",
.disable_cmd = "s6-rc-bundle-update",
};
case InitSystem::Runit:
return {
.service_dir = std::format("{}etc/sv", prefix),
.enable_cmd = "ln -sf /etc/sv",
.disable_cmd = "rm -f /var/service",
};
case InitSystem::Dinit:
return {
.service_dir = std::format("{}etc/dinit.d", prefix),
.enable_cmd = "dinitctl enable",
.disable_cmd = "dinitctl disable",
};
case InitSystem::Unknown:
return {};
}
return {};
}
} // namespace kappa::service
+55
View File
@@ -0,0 +1,55 @@
#include "kappa/system/activate.hpp"
#include <filesystem>
#include <format>
#include <fstream>
namespace kappa::system {
ActivateResult write_hostname(const std::string& hostname,
std::string_view prefix) {
if (hostname.empty()) {
return {false, "hostname is empty"};
}
std::filesystem::path path = std::filesystem::path(prefix) / "etc/hostname";
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) {
return {false, std::format("cannot create {}: {}", path.parent_path().string(), ec.message())};
}
std::ofstream out(path);
if (!out) {
return {false, std::format("cannot write {}", path.string())};
}
out << hostname << "\n";
return {true, {}};
}
ActivateResult write_timezone(const std::string& timezone,
std::string_view prefix) {
if (timezone.empty()) {
return {false, "timezone is empty"};
}
// /etc/localtime is a symlink to /usr/share/zoneinfo/{timezone}
std::filesystem::path localtime = std::filesystem::path(prefix) / "etc/localtime";
std::filesystem::path zoneinfo = std::filesystem::path(prefix) / "usr/share/zoneinfo" / timezone;
std::error_code ec;
if (!std::filesystem::exists(zoneinfo, ec)) {
return {false, std::format("timezone data not found: {}", zoneinfo.string())};
}
std::filesystem::create_directories(localtime.parent_path(), ec);
// Remove existing symlink/file if present
std::filesystem::remove(localtime, ec);
std::filesystem::create_symlink(zoneinfo, localtime, ec);
if (ec) {
return {false, std::format("cannot create symlink {}: {}", localtime.string(), ec.message())};
}
return {true, {}};
}
} // namespace kappa::system
+61
View File
@@ -1,4 +1,8 @@
#include "kappa/tools/doctor.hpp"
#include "kappa/boot/types.hpp"
#include "kappa/service/types.hpp"
#include <filesystem>
#include <sys/stat.h>
namespace kappa::tools {
@@ -32,6 +36,17 @@ std::vector<Diagnostic> check_package(const dsl::PackageDef& pkg) {
}
}
for (auto& c : pkg.conflicts) {
if (c.empty()) {
diags.push_back({DiagSeverity::Warning,
"conflict entry has an empty name"});
}
if (c == pkg.name) {
diags.push_back({DiagSeverity::Error,
"package conflicts with itself: '" + c + "'"});
}
}
if (pkg.config_files.empty()) {
diags.push_back({DiagSeverity::Warning,
"no config files defined — package has no runtime configuration"});
@@ -71,8 +86,48 @@ std::vector<Diagnostic> check_config(const dsl::SystemConfig& cfg) {
if (cfg.boot.init.empty()) {
diags.push_back({DiagSeverity::Warning, "boot init system is not specified"});
}
if (!cfg.boot.init.empty()) {
auto is = kappa::service::parse_init_system(cfg.boot.init);
if (is == kappa::service::InitSystem::Unknown) {
diags.push_back({DiagSeverity::Warning,
"boot.init '" + cfg.boot.init + "' is not a recognized init system — supported: systemd, openrc, s6, runit, dinit"});
}
}
if (cfg.boot.init.empty() && !cfg.services.empty()) {
diags.push_back({DiagSeverity::Warning,
std::to_string(cfg.services.size()) + " service(s) defined but no init system configured — set boot.init"});
}
if (cfg.boot.bootloader.empty()) {
diags.push_back({DiagSeverity::Warning, "boot bootloader is not specified"});
} else {
auto bl = kappa::boot::parse_bootloader(cfg.boot.bootloader);
if (bl == kappa::boot::Bootloader::Unknown) {
diags.push_back({DiagSeverity::Warning,
"boot.bootloader '" + cfg.boot.bootloader + "' is not a recognized bootloader — supported: grub, limine"});
}
}
// Validate boot partitions
if (cfg.boot.efi.empty() && cfg.boot.root.empty()) {
diags.push_back({DiagSeverity::Error, "no boot partitions defined (efi or root)"});
} else {
// Check that each specified partition exists
auto check_partition = [&](const std::string& path, const char* label) {
if (path.empty()) return;
std::error_code ec;
if (!std::filesystem::exists(path, ec)) {
diags.push_back({DiagSeverity::Error,
std::string("boot.") + label + " '" + path + "' does not exist"});
} else {
struct stat st;
if (stat(path.c_str(), &st) == 0 && !S_ISBLK(st.st_mode)) {
diags.push_back({DiagSeverity::Warning,
std::string("boot.") + label + " '" + path + "' is not a block device"});
}
}
};
check_partition(cfg.boot.efi, "efi");
check_partition(cfg.boot.root, "root");
check_partition(cfg.boot.swap, "swap");
}
if (cfg.packages.empty()) {
@@ -105,6 +160,12 @@ std::vector<Diagnostic> check_config(const dsl::SystemConfig& cfg) {
+ " assertions defined — run 'kappa validate' to check them"});
}
for (auto& g : cfg.groups) {
if (g.name.empty()) {
diags.push_back({DiagSeverity::Warning, "group has no name"});
}
}
return diags;
}
+69 -23
View File
@@ -27,8 +27,12 @@ static void write_env(std::ostream& os, int d,
static void write_features(std::ostream& os, int d,
const std::unordered_map<std::string, dsl::FeatureDef>& feats) {
if (feats.empty()) { return; }
std::vector<std::string> keys;
for (auto& [k, _] : feats) keys.push_back(k);
std::sort(keys.begin(), keys.end());
os << Indent(d) << "features {\n";
for (auto& [k, f] : feats) {
for (auto& k : keys) {
auto& f = feats.at(k);
if (f.flag.empty() && !f.force) {
os << Indent(d + 1) << k << " = " << (f.enabled ? "true" : "false") << "\n";
} else {
@@ -105,6 +109,15 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
os << "]\n";
}
if (!pkg.conflicts.empty()) {
os << Indent(d) << "conflicts = [";
for (std::size_t i = 0; i < pkg.conflicts.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << pkg.conflicts[i] << '"';
}
os << "]\n";
}
if (!pkg.patches.empty()) {
os << Indent(d) << "patches = [\n";
for (auto& p : pkg.patches) {
@@ -151,27 +164,33 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
os << Indent(d) << "}\n";
}
if (!pkg.service.empty()) {
os << Indent(d) << "service {\n";
for (auto& [init, si] : pkg.service) {
os << Indent(d + 1) << init << " {\n";
if (!si.exec.empty()) { os << Indent(d + 2) << "exec = \"" << si.exec << "\"\n"; }
if (!si.type.empty()) { os << Indent(d + 2) << "type = \"" << si.type << "\"\n"; }
if (!si.user.empty()) { os << Indent(d + 2) << "user = \"" << si.user << "\"\n"; }
if (!si.ports.empty()) {
os << Indent(d + 2) << "ports = [";
for (std::size_t i = 0; i < si.ports.size(); ++i) {
if (!pkg.services.empty()) {
for (auto& ns : pkg.services) {
if (ns.name != "main") {
os << Indent(d) << "service " << ns.name << " {\n";
} else {
os << Indent(d) << "service {\n";
}
if (!ns.exec.empty()) { os << Indent(d + 1) << "exec = \"" << ns.exec << "\"\n"; }
if (!ns.type.empty()) { os << Indent(d + 1) << "type = \"" << ns.type << "\"\n"; }
if (!ns.user.empty()) { os << Indent(d + 1) << "user = \"" << ns.user << "\"\n"; }
if (!ns.ports.empty()) {
os << Indent(d + 1) << "ports = [";
for (std::size_t i = 0; i < ns.ports.size(); ++i) {
if (i > 0) { os << ", "; }
os << si.ports[i];
os << ns.ports[i];
}
os << "]\n";
}
for (auto& [k, v] : si.env) {
os << Indent(d + 2) << k << " = \"" << v << "\"\n";
if (!ns.description.empty()) { os << Indent(d + 1) << "description = \"" << ns.description << "\"\n"; }
if (!ns.after.empty()) { os << Indent(d + 1) << "after = \"" << ns.after << "\"\n"; }
if (!ns.restart.empty()) { os << Indent(d + 1) << "restart = \"" << ns.restart << "\"\n"; }
if (!ns.working_dir.empty()) { os << Indent(d + 1) << "working_dir = \"" << ns.working_dir << "\"\n"; }
for (auto& [k, v] : ns.env) {
os << Indent(d + 1) << k << " = \"" << v << "\"\n";
}
os << Indent(d + 1) << "}\n";
os << Indent(d) << "}\n";
}
os << Indent(d) << "}\n";
}
if (!pkg.assertions.empty()) {
@@ -189,6 +208,7 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
write_phase(os, d, "build", pkg.build);
write_phase(os, d, "check", pkg.check);
write_phase(os, d, "install", pkg.install);
write_phase(os, d, "uninstall", pkg.uninstall);
os << "}\n";
}
@@ -203,13 +223,20 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
os << "]\n\n";
}
if (!cfg.remotes.empty()) {
os << "remotes = [\n";
for (auto& r : cfg.remotes) {
os << " \"" << r << "\",\n";
}
os << "]\n\n";
}
if (!cfg.assertions.empty()) {
os << "assert {\n";
for (auto& a : cfg.assertions) {
os << " \"" << a.message << "\" : "
<< a.field << " " << a.op;
if (!a.value.empty()) { os << " \"" << a.value << '"'; }
os << "\n";
<< a.field << " " << a.op
<< " \"" << a.value << '"' << "\n";
}
os << "}\n\n";
}
@@ -222,8 +249,11 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
write_env(os, 1, s.env);
if (!s.config.empty()) {
os << " config {\n";
for (auto& [k, v] : s.config) {
os << " " << k << " = \"" << v << "\"\n";
std::vector<std::string> cfg_keys;
for (auto& [k, _] : s.config) cfg_keys.push_back(k);
std::sort(cfg_keys.begin(), cfg_keys.end());
for (auto& k : cfg_keys) {
os << " " << k << " = \"" << s.config.at(k) << "\"\n";
}
os << " }\n";
}
@@ -245,8 +275,11 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
write_features(os, 2, p.features);
if (!p.config.empty()) {
os << " config {\n";
for (auto& [k, v] : p.config) {
os << " " << k << " = " << v << "\n";
std::vector<std::string> pcfg_keys;
for (auto& [k, _] : p.config) pcfg_keys.push_back(k);
std::sort(pcfg_keys.begin(), pcfg_keys.end());
for (auto& k : pcfg_keys) {
os << " " << k << " = " << p.config.at(k) << "\n";
}
os << " }\n";
}
@@ -268,6 +301,19 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
os << "}\n\n";
}
if (!cfg.groups.empty()) {
os << "groups {\n";
for (auto& g : cfg.groups) {
os << " " << g.name;
if (g.gid < 0) {
os << " {}\n";
} else {
os << " {\n gid = " << g.gid << "\n }\n";
}
}
os << "}\n\n";
}
write_boot_block(os, 0, cfg.boot);
os << "\n";
+26
View File
@@ -0,0 +1,26 @@
#include "kappa/util.hpp"
#include <algorithm>
#include <cctype>
#include <ranges>
namespace kappa::util {
std::string to_lower(std::string_view sv) {
std::string s(sv);
std::ranges::transform(s, s.begin(),
[](unsigned char c) { return std::tolower(c); });
return s;
}
std::string shell_escape(std::string_view s) {
std::string result;
result.reserve(s.size());
for (char c : s) {
if (c == '"' || c == '\\' || c == '$' || c == '`') {
result += '\\';
}
result += c;
}
return result;
}
}