/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2025-2026, Leafscale, LLC - https://www.leafscale.com Project: zyginit Filename: config.reef Authors: Chris Tusa License: Description: TOML service definition parser and ServiceDef types ******************************************************************************/ module config import encoding.toml import io.file import io.dir import core.str export type ServiceDef // Constants for service type fn SERVICE_TYPE_DAEMON(): int fn SERVICE_TYPE_ONESHOT(): int fn SERVICE_TYPE_TASK(): int // Constants for restart policy fn RESTART_ALWAYS(): int fn RESTART_FAILURE(): int fn RESTART_NEVER(): int // Constants for stop method fn STOP_METHOD_CONTRACT(): int fn STOP_METHOD_EXEC(): int // Constants for runlevel mode (which runlevel(s) include this service) fn RUNLEVEL_MULTI(): int fn RUNLEVEL_SINGLE(): int fn RUNLEVEL_ALWAYS(): int // Service definition constructor fn new_service_def(): ServiceDef // Accessors fn svc_name(svc: ServiceDef): string fn svc_description(svc: ServiceDef): string fn svc_type(svc: ServiceDef): int fn svc_start_cmd(svc: ServiceDef): string fn svc_stop_cmd(svc: ServiceDef): string fn svc_working_dir(svc: ServiceDef): string fn svc_environment(svc: ServiceDef): [string] fn svc_environment_count(svc: ServiceDef): int fn svc_user(svc: ServiceDef): string fn svc_group(svc: ServiceDef): string fn svc_stop_method(svc: ServiceDef): int fn svc_stop_signal(svc: ServiceDef): string fn svc_stop_timeout(svc: ServiceDef): int fn svc_requires(svc: ServiceDef): [string] fn svc_requires_count(svc: ServiceDef): int fn svc_after(svc: ServiceDef): [string] fn svc_after_count(svc: ServiceDef): int fn svc_conflicts(svc: ServiceDef): [string] fn svc_conflicts_count(svc: ServiceDef): int fn svc_contract_param(svc: ServiceDef): [string] fn svc_contract_param_count(svc: ServiceDef): int fn svc_contract_fatal(svc: ServiceDef): [string] fn svc_contract_fatal_count(svc: ServiceDef): int fn svc_restart_policy(svc: ServiceDef): int fn svc_restart_delay(svc: ServiceDef): int fn svc_max_retries(svc: ServiceDef): int fn svc_runlevel_mode(svc: ServiceDef): int fn svc_cond_exists_file(svc: ServiceDef): string fn svc_cond_exists_file_any(svc: ServiceDef): [string] fn svc_cond_exists_file_any_count(svc: ServiceDef): int fn svc_cond_command(svc: ServiceDef): string // Human-readable names for type/policy constants fn service_type_name(t: int): string fn restart_policy_name(p: int): string fn stop_method_name(m: int): string fn runlevel_mode_name(m: int): string // Parsing // parse_service: parse a service.toml file. `name` is taken from the // enclosing directory name (not from the TOML). `svc_dir` is the // directory containing the TOML, used to resolve "./" relative paths. fn parse_service(filepath: string, name: string, svc_dir: string, svc: ServiceDef): bool fn scan_enabled(config_dir: string, names: [string], max_names: int): int fn load_enabled_services(config_dir: string, services: [ServiceDef], max_services: int): int // Inline array helper (exported for testing) fn parse_toml_array(value: string, result: [string], max_items: int): int end export // FFI: helpers.c — resolve a symlink or path to its canonical absolute form. // Returns the resolved path, or "" on any error (dangling link, permission // denied, path does not exist, etc.). extern "C" fn zyginit_readlink_resolved(path: string): string // ============================================================================ // Constants // ============================================================================ fn SERVICE_TYPE_DAEMON(): int return 1 end SERVICE_TYPE_DAEMON fn SERVICE_TYPE_ONESHOT(): int return 2 end SERVICE_TYPE_ONESHOT fn SERVICE_TYPE_TASK(): int return 3 end SERVICE_TYPE_TASK fn RESTART_ALWAYS(): int return 1 end RESTART_ALWAYS fn RESTART_FAILURE(): int return 2 end RESTART_FAILURE fn RESTART_NEVER(): int return 3 end RESTART_NEVER fn STOP_METHOD_CONTRACT(): int return 1 end STOP_METHOD_CONTRACT fn STOP_METHOD_EXEC(): int return 2 end STOP_METHOD_EXEC // Runlevel modes — which runlevel(s) include this service in the active // set. Default is MULTI, matching legacy zyginit behavior (services run // in multi-user mode, are stopped on transitions to single-user). fn RUNLEVEL_MULTI(): int return 0 end RUNLEVEL_MULTI fn RUNLEVEL_SINGLE(): int return 1 end RUNLEVEL_SINGLE fn RUNLEVEL_ALWAYS(): int return 2 end RUNLEVEL_ALWAYS // ============================================================================ // ServiceDef type // ============================================================================ type ServiceDef = struct name: string description: string service_type: int start_cmd: string stop_cmd: string working_dir: string environment: [string] environment_count: int user: string group: string stop_method: int stop_signal: string stop_timeout: int requires: [string] requires_count: int after: [string] after_count: int conflicts: [string] conflicts_count: int contract_param: [string] contract_param_count: int contract_fatal: [string] contract_fatal_count: int restart_policy: int restart_delay: int max_retries: int // Runlevel mode: RUNLEVEL_MULTI (default), RUNLEVEL_SINGLE, or // RUNLEVEL_ALWAYS. Determines whether the service is in the active // set for single-user mode, multi-user mode, or both. runlevel_mode: int // [condition] block fields — all optional, default to empty/zero. cond_exists_file: string cond_exists_file_any: [string] cond_exists_file_any_count: int cond_command: string end ServiceDef fn new_service_def(): ServiceDef return ServiceDef{ name: "", description: "", service_type: SERVICE_TYPE_DAEMON(), start_cmd: "", stop_cmd: "", working_dir: "", environment: new [string](16), environment_count: 0, user: "", group: "", stop_method: STOP_METHOD_CONTRACT(), stop_signal: "TERM", stop_timeout: 60, requires: new [string](16), requires_count: 0, after: new [string](16), after_count: 0, conflicts: new [string](16), conflicts_count: 0, contract_param: new [string](8), contract_param_count: 0, contract_fatal: new [string](8), contract_fatal_count: 0, restart_policy: RESTART_FAILURE(), restart_delay: 5, max_retries: 3, runlevel_mode: RUNLEVEL_MULTI(), cond_exists_file: "", cond_exists_file_any: new [string](16), cond_exists_file_any_count: 0, cond_command: "" } end new_service_def // ============================================================================ // Accessors // ============================================================================ fn svc_name(svc: ServiceDef): string return svc.name end svc_name fn svc_description(svc: ServiceDef): string return svc.description end svc_description fn svc_type(svc: ServiceDef): int return svc.service_type end svc_type fn svc_start_cmd(svc: ServiceDef): string return svc.start_cmd end svc_start_cmd fn svc_stop_cmd(svc: ServiceDef): string return svc.stop_cmd end svc_stop_cmd fn svc_working_dir(svc: ServiceDef): string return svc.working_dir end svc_working_dir fn svc_environment(svc: ServiceDef): [string] return svc.environment end svc_environment fn svc_environment_count(svc: ServiceDef): int return svc.environment_count end svc_environment_count fn svc_user(svc: ServiceDef): string return svc.user end svc_user fn svc_group(svc: ServiceDef): string return svc.group end svc_group fn svc_stop_method(svc: ServiceDef): int return svc.stop_method end svc_stop_method fn svc_stop_signal(svc: ServiceDef): string return svc.stop_signal end svc_stop_signal fn svc_stop_timeout(svc: ServiceDef): int return svc.stop_timeout end svc_stop_timeout fn svc_requires(svc: ServiceDef): [string] return svc.requires end svc_requires fn svc_requires_count(svc: ServiceDef): int return svc.requires_count end svc_requires_count fn svc_after(svc: ServiceDef): [string] return svc.after end svc_after fn svc_after_count(svc: ServiceDef): int return svc.after_count end svc_after_count fn svc_conflicts(svc: ServiceDef): [string] return svc.conflicts end svc_conflicts fn svc_conflicts_count(svc: ServiceDef): int return svc.conflicts_count end svc_conflicts_count fn svc_contract_param(svc: ServiceDef): [string] return svc.contract_param end svc_contract_param fn svc_contract_param_count(svc: ServiceDef): int return svc.contract_param_count end svc_contract_param_count fn svc_contract_fatal(svc: ServiceDef): [string] return svc.contract_fatal end svc_contract_fatal fn svc_contract_fatal_count(svc: ServiceDef): int return svc.contract_fatal_count end svc_contract_fatal_count fn svc_restart_policy(svc: ServiceDef): int return svc.restart_policy end svc_restart_policy fn svc_restart_delay(svc: ServiceDef): int return svc.restart_delay end svc_restart_delay fn svc_max_retries(svc: ServiceDef): int return svc.max_retries end svc_max_retries fn svc_runlevel_mode(svc: ServiceDef): int return svc.runlevel_mode end svc_runlevel_mode fn svc_cond_exists_file(svc: ServiceDef): string return svc.cond_exists_file end svc_cond_exists_file fn svc_cond_exists_file_any(svc: ServiceDef): [string] return svc.cond_exists_file_any end svc_cond_exists_file_any fn svc_cond_exists_file_any_count(svc: ServiceDef): int return svc.cond_exists_file_any_count end svc_cond_exists_file_any_count fn svc_cond_command(svc: ServiceDef): string return svc.cond_command end svc_cond_command // ============================================================================ // Human-readable names // ============================================================================ fn service_type_name(t: int): string if t == SERVICE_TYPE_DAEMON() return "daemon" elif t == SERVICE_TYPE_ONESHOT() return "oneshot" elif t == SERVICE_TYPE_TASK() return "task" end if return "unknown" end service_type_name fn restart_policy_name(p: int): string if p == RESTART_ALWAYS() return "always" elif p == RESTART_FAILURE() return "failure" elif p == RESTART_NEVER() return "never" end if return "unknown" end restart_policy_name fn stop_method_name(m: int): string if m == STOP_METHOD_CONTRACT() return "contract" elif m == STOP_METHOD_EXEC() return "exec" end if return "unknown" end stop_method_name fn runlevel_mode_name(m: int): string if m == RUNLEVEL_MULTI() return "multi" elif m == RUNLEVEL_SINGLE() return "single" elif m == RUNLEVEL_ALWAYS() return "always" end if return "unknown" end runlevel_mode_name // Parse runlevel mode string to constant fn parse_runlevel_mode(mode_str: string): int if mode_str == "multi" return RUNLEVEL_MULTI() elif mode_str == "single" return RUNLEVEL_SINGLE() elif mode_str == "always" return RUNLEVEL_ALWAYS() end if // Unknown/empty — default to multi (legacy behavior) return RUNLEVEL_MULTI() end parse_runlevel_mode // ============================================================================ // Parsing helpers // ============================================================================ // Parse service type string to constant fn parse_service_type(type_str: string): int if type_str == "daemon" return SERVICE_TYPE_DAEMON() elif type_str == "oneshot" return SERVICE_TYPE_ONESHOT() elif type_str == "task" return SERVICE_TYPE_TASK() end if if type_str == "transient" println("zyginit: warning: type='transient' is deprecated in 0.2.0 — treating as 'task' (rename in your TOML)") return SERVICE_TYPE_TASK() end if return SERVICE_TYPE_DAEMON() end parse_service_type // Parse restart policy string to constant fn parse_restart_policy(policy_str: string): int if policy_str == "always" return RESTART_ALWAYS() elif policy_str == "failure" return RESTART_FAILURE() elif policy_str == "never" return RESTART_NEVER() end if return RESTART_FAILURE() end parse_restart_policy // Parse stop method string to constant fn parse_stop_method(method_str: string): int if method_str == "contract" return STOP_METHOD_CONTRACT() elif method_str == "exec" return STOP_METHOD_EXEC() end if return STOP_METHOD_CONTRACT() end parse_stop_method // Parse a TOML inline array like ["a", "b", "c"] into a string array. // Returns the number of items parsed. // Adapted from Coral's parse_toml_array pattern. fn parse_toml_array(value: string, result: [string], max_items: int): int let len = str.length(value) if len < 2 return 0 end if if value[0] != '[' return 0 end if mut count = 0 mut i = 1 mut in_string = false mut item_start = 0 while i < len and count < max_items let c = value[i] if c == '"' and not in_string in_string = true item_start = i + 1 elif c == '"' and in_string let item_len = i - item_start if item_len > 0 result[count] = str.substring(value, item_start, item_len) count = count + 1 end if in_string = false elif c == ']' and not in_string return count end if i = i + 1 end while return count end parse_toml_array // ============================================================================ // Service parsing // ============================================================================ // Resolve a path that may start with "./" to an absolute path using // the service's directory as the base. Absolute paths (starting with "/") // and empty strings are returned unchanged. "./foo" becomes // "/foo". This allows service.toml to reference companion // scripts as "./start.sh" without hard-coding the install path. fn resolve_exec_path(raw: string, svc_dir: string): string let raw_len = str.length(raw) if raw_len == 0 return raw end if // Absolute paths pass through unchanged if raw[0] == '/' return raw end if // "./" prefix: strip the prefix and join with svc_dir if raw_len >= 2 if raw[0] == '.' and raw[1] == '/' let rel = str.substring(raw, 2, raw_len - 2) return str.concat(str.concat(svc_dir, "/"), rel) end if end if // No prefix — return as-is (let exec find it via PATH) return raw end resolve_exec_path // Parse a single service.toml file into a ServiceDef. // `name` — service name derived from the enclosing directory name. // The TOML need not (and should not) contain service.name. // `svc_dir` — absolute path to the directory containing this service.toml; // used to resolve "./"-prefixed exec paths. // Returns true on success. fn parse_service(filepath: string, name: string, svc_dir: string, svc: ServiceDef): bool if not file.fileExists(filepath) return false end if let content = file.readFile(filepath) if str.length(content) == 0 return false end if let keys = toml.toml_alloc_keys() let vals = toml.toml_alloc_values() let count = toml.toml_parse(content, keys, vals) if count == 0 return false end if // Service name comes from the directory name, not from a TOML field. svc.name = name // [service] section — all optional (type defaults to daemon) if toml.toml_has_key(keys, vals, count, "service.description") svc.description = toml.toml_get(keys, vals, count, "service.description") end if if toml.toml_has_key(keys, vals, count, "service.type") svc.service_type = parse_service_type(toml.toml_get(keys, vals, count, "service.type")) end if // [exec] section — start is required if not toml.toml_has_key(keys, vals, count, "exec.start") return false end if svc.start_cmd = resolve_exec_path( toml.toml_get(keys, vals, count, "exec.start"), svc_dir) if toml.toml_has_key(keys, vals, count, "exec.stop") svc.stop_cmd = resolve_exec_path( toml.toml_get(keys, vals, count, "exec.stop"), svc_dir) end if if toml.toml_has_key(keys, vals, count, "exec.working_directory") svc.working_dir = toml.toml_get(keys, vals, count, "exec.working_directory") end if if toml.toml_has_key(keys, vals, count, "exec.environment") let env_str = toml.toml_get(keys, vals, count, "exec.environment") svc.environment_count = parse_toml_array(env_str, svc.environment, 16) end if if toml.toml_has_key(keys, vals, count, "exec.user") svc.user = toml.toml_get(keys, vals, count, "exec.user") end if if toml.toml_has_key(keys, vals, count, "exec.group") svc.group = toml.toml_get(keys, vals, count, "exec.group") end if // [stop] section — all optional with defaults if toml.toml_has_key(keys, vals, count, "stop.method") svc.stop_method = parse_stop_method(toml.toml_get(keys, vals, count, "stop.method")) end if if toml.toml_has_key(keys, vals, count, "stop.signal") svc.stop_signal = toml.toml_get(keys, vals, count, "stop.signal") end if if toml.toml_has_key(keys, vals, count, "stop.timeout") svc.stop_timeout = toml.toml_get_int(keys, vals, count, "stop.timeout") end if // [dependencies] section — inline arrays if toml.toml_has_key(keys, vals, count, "dependencies.requires") let req_str = toml.toml_get(keys, vals, count, "dependencies.requires") svc.requires_count = parse_toml_array(req_str, svc.requires, 16) end if if toml.toml_has_key(keys, vals, count, "dependencies.after") let after_str = toml.toml_get(keys, vals, count, "dependencies.after") svc.after_count = parse_toml_array(after_str, svc.after, 16) end if if toml.toml_has_key(keys, vals, count, "dependencies.conflicts") let conf_str = toml.toml_get(keys, vals, count, "dependencies.conflicts") svc.conflicts_count = parse_toml_array(conf_str, svc.conflicts, 16) end if // [contract] section — inline arrays if toml.toml_has_key(keys, vals, count, "contract.param") let param_str = toml.toml_get(keys, vals, count, "contract.param") svc.contract_param_count = parse_toml_array(param_str, svc.contract_param, 8) end if if toml.toml_has_key(keys, vals, count, "contract.fatal") let fatal_str = toml.toml_get(keys, vals, count, "contract.fatal") svc.contract_fatal_count = parse_toml_array(fatal_str, svc.contract_fatal, 8) end if // [restart] section — all optional with defaults if toml.toml_has_key(keys, vals, count, "restart.on") svc.restart_policy = parse_restart_policy(toml.toml_get(keys, vals, count, "restart.on")) end if if toml.toml_has_key(keys, vals, count, "restart.delay") svc.restart_delay = toml.toml_get_int(keys, vals, count, "restart.delay") end if if toml.toml_has_key(keys, vals, count, "restart.max_retries") svc.max_retries = toml.toml_get_int(keys, vals, count, "restart.max_retries") end if // [runlevel] section — optional, default mode="multi" if toml.toml_has_key(keys, vals, count, "runlevel.mode") svc.runlevel_mode = parse_runlevel_mode( toml.toml_get(keys, vals, count, "runlevel.mode")) end if // [condition] section — all optional if toml.toml_has_key(keys, vals, count, "condition.exists_file") svc.cond_exists_file = toml.toml_get(keys, vals, count, "condition.exists_file") end if if toml.toml_has_key(keys, vals, count, "condition.exists_file_any") let any_str = toml.toml_get(keys, vals, count, "condition.exists_file_any") svc.cond_exists_file_any_count = parse_toml_array(any_str, svc.cond_exists_file_any, 16) end if if toml.toml_has_key(keys, vals, count, "condition.command") svc.cond_command = toml.toml_get(keys, vals, count, "condition.command") end if return true end parse_service // ============================================================================ // Service scanning // ============================================================================ // Scan the enabled.d/ directory and return the names of enabled services. // // In the 0.2.x layout each entry in enabled.d/ is a symlink whose name is // the service name and whose target is the service's directory inside // services/. For example: // // enabled.d/sshd -> ../services/sshd/ // // We resolve every symlink via zyginit_readlink_resolved (realpath) and // reject entries that do not resolve to a path under // /services/. This prevents a symlink escaping the config // tree from loading an arbitrary TOML file. // // Returns the number of names written into `names`. fn scan_enabled(config_dir: string, names: [string], max_names: int): int let enabled_dir = str.concat(config_dir, "/enabled.d") let services_prefix = str.concat(config_dir, "/services/") let prefix_len = str.length(services_prefix) if not dir.dir_exists(enabled_dir) return 0 end if let entries = new [string](128) let entry_count = dir.list_dir(enabled_dir, entries, 128) mut name_count = 0 mut i = 0 while i < entry_count and name_count < max_names let entry = entries[i] let link_path = str.concat(str.concat(enabled_dir, "/"), entry) let target = zyginit_readlink_resolved(link_path) // Reject dangling symlinks (target == "") if str.length(target) == 0 println("zyginit: enabled.d/" + entry + ": dangling symlink — ignoring") else // Reject symlinks that escape the services/ subtree. // Inline starts-with check: compare the first prefix_len chars. let target_len = str.length(target) mut has_prefix = false if target_len >= prefix_len if str.substring(target, 0, prefix_len) == services_prefix has_prefix = true end if end if if not has_prefix println("zyginit: enabled.d/" + entry + ": target " + target + " is outside services/ — ignoring") else names[name_count] = entry name_count = name_count + 1 end if end if i = i + 1 end while return name_count end scan_enabled // Load all enabled services from config_dir. // // Walks /services/ to find enabled services (via scan_enabled), // then for each name locates /services//service.toml, // parses it, and loads it into the services array. // // If /services/ does not exist, logs a fatal-tier message and // returns 0. The caller (main.reef) treats a 0 count as "nothing to do" // and should handle this as an error condition. // // Returns the number of services loaded, or -1 if the services/ directory // does not exist (FATAL configuration error — operator must migrate first). fn load_enabled_services(config_dir: string, services: [ServiceDef], max_services: int): int let services_root = str.concat(config_dir, "/services") if not dir.dir_exists(services_root) println("zyginit: FATAL: services/ directory not found at " + services_root) println("zyginit: run scripts/migrate-layout.sh to convert a 0.1.x tree") return 0 - 1 end if let names = new [string](128) let name_count = scan_enabled(config_dir, names, 128) mut loaded = 0 mut i = 0 while i < name_count and loaded < max_services let name = names[i] let svc_dir = str.concat(str.concat(services_root, "/"), name) let toml_path = str.concat(svc_dir, "/service.toml") if not file.fileExists(toml_path) println("zyginit: services/" + name + "/ missing service.toml — skipping") else mut svc = new_service_def() if parse_service(toml_path, name, svc_dir, svc) services[loaded] = svc loaded = loaded + 1 else println("zyginit: services/" + name + "/service.toml parse error — skipping") end if end if i = i + 1 end while return loaded end load_enabled_services end module