From 664c75a9c9e68e51ccdaed5561251571a703813c Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 28 Sep 2023 16:35:41 +0200 Subject: [PATCH] nd: add a persistent configuration support the config will be used by the daemon to store user choices across reboots. closes https://git.qcode.ch/nakamochi/ndg/issues/6 --- src/nd.zig | 31 ++++- src/nd/Config.zig | 306 ++++++++++++++++++++++++++++++++++++++++++++++ src/nd/Daemon.zig | 47 +++++-- src/test.zig | 40 ++++++ 4 files changed, 410 insertions(+), 14 deletions(-) create mode 100644 src/nd/Config.zig diff --git a/src/nd.zig b/src/nd.zig index befdf26..5170141 100644 --- a/src/nd.zig +++ b/src/nd.zig @@ -17,7 +17,7 @@ const stderr = std.io.getStdErr().writer(); /// prints usage help text to stderr. fn usage(prog: []const u8) !void { try stderr.print( - \\usage: {s} -gui path/to/ngui -gui-user username -wpa path + \\usage: {[prog]s} -gui path/to/ngui -gui-user username -wpa path [-conf {[confpath]s}] \\ \\nd is a short for nakamochi daemon. \\the daemon executes ngui as a child process and runs until @@ -25,16 +25,21 @@ fn usage(prog: []const u8) !void { \\ \\nd logs messages to stderr. \\ - , .{prog}); + , .{ .prog = prog, .confpath = NdArgs.defaultConf }); } /// nd program flags. see usage. const NdArgs = struct { + conf: ?[:0]const u8 = null, gui: ?[:0]const u8 = null, gui_user: ?[:0]const u8 = null, wpa: ?[:0]const u8 = null, + /// default path for nd config file, read or created during startup. + const defaultConf = "/home/uiuser/conf.json"; + fn deinit(self: @This(), allocator: std.mem.Allocator) void { + if (self.conf) |p| allocator.free(p); if (self.gui) |p| allocator.free(p); if (self.gui_user) |p| allocator.free(p); if (self.wpa) |p| allocator.free(p); @@ -51,12 +56,18 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs { var lastarg: enum { none, + conf, gui, gui_user, wpa, } = .none; while (args.next()) |a| { switch (lastarg) { + .conf => { + flags.conf = try gpa.dupeZ(u8, a); + lastarg = .none; + continue; + }, .gui => { flags.gui = try gpa.dupeZ(u8, a); lastarg = .none; @@ -80,6 +91,8 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs { } else if (std.mem.eql(u8, a, "-v")) { try stderr.print("{any}\n", .{buildopts.semver}); std.process.exit(0); + } else if (std.mem.eql(u8, a, "-conf")) { + lastarg = .conf; } else if (std.mem.eql(u8, a, "-gui")) { lastarg = .gui; } else if (std.mem.eql(u8, a, "-gui-user")) { @@ -91,11 +104,14 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs { return error.UnknownArgName; } } - if (lastarg != .none) { logger.err("invalid arg: {s} requires a value", .{@tagName(lastarg)}); return error.MissinArgValue; } + + if (flags.conf == null) { + flags.conf = NdArgs.defaultConf; + } if (flags.gui == null) { logger.err("missing -gui arg", .{}); return error.MissingGuiFlag; @@ -130,6 +146,7 @@ pub fn main() !void { logger.err("memory leaks detected", .{}); }; const gpa = gpa_state.allocator(); + // parse program args first thing and fail fast if invalid const args = try parseArgs(gpa); defer args.deinit(gpa); @@ -179,7 +196,13 @@ pub fn main() !void { return err; }; - var nd = try Daemon.init(gpa, uireader, uiwriter, args.wpa.?); + var nd = try Daemon.init(.{ + .allocator = gpa, + .confpath = args.conf.?, + .uir = uireader, + .uiw = uiwriter, + .wpa = args.wpa.?, + }); defer nd.deinit(); try nd.start(); diff --git a/src/nd/Config.zig b/src/nd/Config.zig new file mode 100644 index 0000000..7a68052 --- /dev/null +++ b/src/nd/Config.zig @@ -0,0 +1,306 @@ +//! ndg persistent configuration, loaded from and stored to disk in JSON format. +//! the structure is defined in `Data`. + +const std = @import("std"); + +const logger = std.log.scoped(.config); + +// default values +const SYSUPDATES_CRON_SCRIPT_PATH = "/etc/cron.hourly/sysupdate"; +const SYSUPDATES_RUN_SCRIPT_NAME = "update.sh"; +const SYSUPDATES_RUN_SCRIPT_PATH = "/ssd/sysupdates/" ++ SYSUPDATES_RUN_SCRIPT_NAME; + +arena: *std.heap.ArenaAllocator, // data is allocated here +confpath: []const u8, // fs path to where data is persisted + +mu: std.Thread.RwLock = .{}, +data: Data, + +/// top struct stored on disk. +/// access with `safeReadOnly` or lock/unlock `mu`. +pub const Data = struct { + syschannel: SysupdatesChannel, + syscronscript: []const u8, + sysrunscript: []const u8, +}; + +/// enums must match git branches in https://git.qcode.ch/nakamochi/sysupdates. +pub const SysupdatesChannel = enum { + master, // stable + dev, // edge +}; + +const Config = @This(); + +/// confpath must outlive returned Config instance. +pub fn init(allocator: std.mem.Allocator, confpath: []const u8) !Config { + var arena = try allocator.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(allocator); + errdefer { + arena.deinit(); + allocator.destroy(arena); + } + return .{ + .arena = arena, + .data = try initData(arena.allocator(), confpath), + .confpath = confpath, + }; +} + +pub fn deinit(self: Config) void { + const allocator = self.arena.child_allocator; + self.arena.deinit(); + allocator.destroy(self.arena); +} + +fn initData(allocator: std.mem.Allocator, filepath: []const u8) !Data { + const maxsize: usize = 1 << 20; // 1Mb JSON conf file size should be more than enough + const bytes = std.fs.cwd().readFileAlloc(allocator, filepath, maxsize) catch |err| switch (err) { + error.FileNotFound => return inferData(), + else => return err, + }; + defer allocator.free(bytes); + const jopt = std.json.ParseOptions{ .ignore_unknown_fields = true, .allocate = .alloc_always }; + return std.json.parseFromSliceLeaky(Data, allocator, bytes, jopt) catch |err| { + logger.err("initData: {any}", .{err}); + return error.BadConfigSyntax; + }; +} + +fn inferData() Data { + return .{ + .syschannel = inferSysupdatesChannel(SYSUPDATES_CRON_SCRIPT_PATH), + .syscronscript = SYSUPDATES_CRON_SCRIPT_PATH, + .sysrunscript = SYSUPDATES_RUN_SCRIPT_PATH, + }; +} + +fn inferSysupdatesChannel(cron_script_path: []const u8) SysupdatesChannel { + var buf: [1024]u8 = undefined; + const bytes = std.fs.cwd().readFile(cron_script_path, &buf) catch return .master; + var it = std.mem.tokenizeScalar(u8, bytes, '\n'); + // looking for "/ssd/sysupdates/update.sh ?" where may be in quotes + const needle = SYSUPDATES_RUN_SCRIPT_NAME; + while (it.next()) |line| { + if (std.mem.indexOf(u8, line, needle)) |i| { + var s = line[i + needle.len ..]; + s = std.mem.trim(u8, s, " \n'\""); + return std.meta.stringToEnum(SysupdatesChannel, s) orelse .master; + } + } + return .master; +} + +/// calls F while holding a readonly lock and passes on F's result as is. +pub fn safeReadOnly(self: *Config, comptime F: anytype) @typeInfo(@TypeOf(F)).Fn.return_type.? { + self.mu.lockShared(); + defer self.mu.unlockShared(); + return F(self.data); +} + +/// stores current `Config.data` to disk, into `Config.confpath`. +pub fn dump(self: *Config) !void { + self.mu.lock(); + defer self.mu.unlock(); + return self.dumpUnguarded(); +} + +fn dumpUnguarded(self: Config) !void { + const allocator = self.arena.child_allocator; + const opt = .{ .mode = 0o600 }; + const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), self.confpath, opt); + defer file.destroy(); + try std.json.stringify(self.data, .{ .whitespace = .indent_2 }, file.writer()); + try file.finish(); +} + +/// when run is set, executes the update after changing the channel. +/// executing an update may terminate and start a new nd+ngui instance. +pub fn switchSysupdates(self: *Config, chan: SysupdatesChannel, opt: struct { run: bool }) !void { + self.mu.lock(); + defer self.mu.unlock(); + + self.data.syschannel = chan; + try self.dumpUnguarded(); + + try self.genSysupdatesCronScript(); + if (opt.run) { + try runSysupdates(self.arena.child_allocator, self.data.syscronscript); + } +} + +/// caller must hold self.mu. +fn genSysupdatesCronScript(self: Config) !void { + if (self.data.sysrunscript.len == 0) { + return error.NoSysRunScriptPath; + } + const allocator = self.arena.child_allocator; + const opt = .{ .mode = 0o755 }; + const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), self.data.syscronscript, opt); + defer file.destroy(); + + const script = + \\#!/bin/sh + \\exec {[path]s} "{[chan]s}" + ; + try std.fmt.format(file.writer(), script, .{ + .path = self.data.sysrunscript, + .chan = @tagName(self.data.syschannel), + }); + try file.finish(); +} + +/// the scriptpath is typically the cronjob script, not a SYSUPDATES_RUN_SCRIPT +/// because the latter requires command args which is what cron script does. +fn runSysupdates(allocator: std.mem.Allocator, scriptpath: []const u8) !void { + const res = try std.ChildProcess.exec(.{ .allocator = allocator, .argv = &.{scriptpath} }); + defer { + allocator.free(res.stdout); + allocator.free(res.stderr); + } + switch (res.term) { + .Exited => |code| if (code != 0) { + logger.err("runSysupdates: {s} exit code = {d}; stderr: {s}", .{ scriptpath, code, res.stderr }); + return error.RunSysupdatesBadExit; + }, + else => { + logger.err("runSysupdates: {s} term = {any}", .{ scriptpath, res.term }); + return error.RunSysupdatesBadTerm; + }, + } +} + +test "init existing" { + const t = std.testing; + const tt = @import("../test.zig"); + + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + try tmp.dir.writeFile("conf.json", + \\{ + \\"syschannel": "dev", + \\"syscronscript": "/cron/sysupdates.sh", + \\"sysrunscript": "/sysupdates/run.sh" + \\} + ); + const conf = try init(t.allocator, try tmp.join(&.{"conf.json"})); + defer conf.deinit(); + try t.expectEqual(SysupdatesChannel.dev, conf.data.syschannel); + try t.expectEqualStrings("/cron/sysupdates.sh", conf.data.syscronscript); + try t.expectEqualStrings("/sysupdates/run.sh", conf.data.sysrunscript); +} + +test "init null" { + const t = std.testing; + + const conf = try init(t.allocator, "/non/existent/config/file"); + defer conf.deinit(); + try t.expectEqual(SysupdatesChannel.master, conf.data.syschannel); + try t.expectEqualStrings(SYSUPDATES_CRON_SCRIPT_PATH, conf.data.syscronscript); + try t.expectEqualStrings(SYSUPDATES_RUN_SCRIPT_PATH, conf.data.sysrunscript); +} + +test "dump" { + const t = std.testing; + const tt = @import("../test.zig"); + + // the arena used only for the config instance. + // purposefully skip arena deinit - expecting no mem leaks in conf usage here. + var conf_arena = std.heap.ArenaAllocator.init(t.allocator); + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + + const confpath = try tmp.join(&.{"conf.json"}); + var conf = Config{ + .arena = &conf_arena, + .confpath = confpath, + .data = .{ + .syschannel = .master, + .syscronscript = "cronscript.sh", + .sysrunscript = "runscript.sh", + }, + }; + // purposefully skip conf.deinit() - expecting no leaking allocations in conf.dump. + try conf.dump(); + + const parsed = try testLoadConfigData(confpath); + defer parsed.deinit(); + try t.expectEqual(SysupdatesChannel.master, parsed.value.syschannel); + try t.expectEqualStrings("cronscript.sh", parsed.value.syscronscript); + try t.expectEqualStrings("runscript.sh", parsed.value.sysrunscript); +} + +test "switch sysupdates and infer" { + const t = std.testing; + const tt = @import("../test.zig"); + + // the arena used only for the config instance. + // purposefully skip arena deinit - expecting no mem leaks in conf usage here. + var conf_arena = std.heap.ArenaAllocator.init(t.allocator); + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + + try tmp.dir.writeFile("conf.json", ""); + const confpath = try tmp.join(&.{"conf.json"}); + const cronscript = try tmp.join(&.{"cronscript.sh"}); + var conf = Config{ + .arena = &conf_arena, + .confpath = confpath, + .data = .{ + .syschannel = .master, + .syscronscript = cronscript, + .sysrunscript = SYSUPDATES_RUN_SCRIPT_PATH, + }, + }; + // purposefully skip conf.deinit() - expecting no leaking allocations. + + try conf.switchSysupdates(.dev, .{ .run = false }); + const parsed = try testLoadConfigData(confpath); + defer parsed.deinit(); + try t.expectEqual(SysupdatesChannel.dev, parsed.value.syschannel); + try t.expectEqual(SysupdatesChannel.dev, inferSysupdatesChannel(cronscript)); +} + +test "switch sysupdates with .run=true" { + const t = std.testing; + const tt = @import("../test.zig"); + + // no arena deinit: expecting Config to + var conf_arena = try std.testing.allocator.create(std.heap.ArenaAllocator); + conf_arena.* = std.heap.ArenaAllocator.init(std.testing.allocator); + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + + const runscript = "runscript.sh"; + try tmp.dir.writeFile(runscript, + \\#!/bin/sh + \\printf "$1" > "$(dirname "$0")/success" + ); + { + const file = try tmp.dir.openFile(runscript, .{}); + defer file.close(); + try file.chmod(0o755); + } + var conf = Config{ + .arena = conf_arena, + .confpath = try tmp.join(&.{"conf.json"}), + .data = .{ + .syschannel = .master, + .syscronscript = try tmp.join(&.{"cronscript.sh"}), + .sysrunscript = try tmp.join(&.{runscript}), + }, + }; + defer conf.deinit(); + + try conf.switchSysupdates(.dev, .{ .run = true }); + var buf: [10]u8 = undefined; + try t.expectEqualStrings("dev", try tmp.dir.readFile("success", &buf)); +} + +fn testLoadConfigData(path: []const u8) !std.json.Parsed(Data) { + const allocator = std.testing.allocator; + const bytes = try std.fs.cwd().readFileAlloc(allocator, path, 1 << 20); + defer allocator.free(bytes); + const jopt = .{ .ignore_unknown_fields = true, .allocate = .alloc_always }; + return try std.json.parseFromSlice(Data, allocator, bytes, jopt); +} diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index 2a91ef2..144866c 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -17,6 +17,7 @@ const time = std.time; const bitcoindrpc = @import("../bitcoindrpc.zig"); const comm = @import("../comm.zig"); +const Config = @import("Config.zig"); const lndhttp = @import("../lndhttp.zig"); const network = @import("network.zig"); const screen = @import("../ui/screen.zig"); @@ -26,6 +27,7 @@ const types = @import("../types.zig"); const logger = std.log.scoped(.daemon); allocator: mem.Allocator, +conf: Config, uireader: std.fs.File.Reader, // ngui stdout uiwriter: std.fs.File.Writer, // ngui stdin wpa_ctrl: types.WpaControl, // guarded by mu once start'ed @@ -68,24 +70,36 @@ services: []SysService = &.{}, const Daemon = @This(); +const InitOpt = struct { + allocator: std.mem.Allocator, + confpath: []const u8, + uir: std.fs.File.Reader, + uiw: std.fs.File.Writer, + wpa: [:0]const u8, +}; + /// initializes a daemon instance using the provided GUI stdout reader and stdin writer, /// and a filesystem path to WPA control socket. /// callers must deinit when done. -pub fn init(a: std.mem.Allocator, r: std.fs.File.Reader, w: std.fs.File.Writer, wpa: [:0]const u8) !Daemon { - var svlist = std.ArrayList(SysService).init(a); +pub fn init(opt: InitOpt) !Daemon { + var svlist = std.ArrayList(SysService).init(opt.allocator); errdefer { for (svlist.items) |*sv| sv.deinit(); svlist.deinit(); } // the order is important. when powering off, the services are shut down // in the same order appended here. - try svlist.append(SysService.init(a, "lnd", .{ .stop_wait_sec = 600 })); - try svlist.append(SysService.init(a, "bitcoind", .{ .stop_wait_sec = 600 })); + try svlist.append(SysService.init(opt.allocator, "lnd", .{ .stop_wait_sec = 600 })); + try svlist.append(SysService.init(opt.allocator, "bitcoind", .{ .stop_wait_sec = 600 })); + + const conf = try Config.init(opt.allocator, opt.confpath); + errdefer conf.deinit(); return .{ - .allocator = a, - .uireader = r, - .uiwriter = w, - .wpa_ctrl = try types.WpaControl.open(wpa), + .allocator = opt.allocator, + .conf = conf, + .uireader = opt.uir, + .uiwriter = opt.uiw, + .wpa_ctrl = try types.WpaControl.open(opt.wpa), .state = .stopped, .services = try svlist.toOwnedSlice(), // send a network report right at start without wifi scan to make it faster. @@ -104,6 +118,7 @@ pub fn init(a: std.mem.Allocator, r: std.fs.File.Reader, w: std.fs.File.Writer, /// releases all associated resources. /// the daemon must be stop'ed and wait'ed before deiniting. pub fn deinit(self: *Daemon) void { + defer self.conf.deinit(); self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err}); for (self.services) |*sv| { sv.deinit(); @@ -725,7 +740,13 @@ test "start-stop" { const t = std.testing; const pipe = try types.IoPipe.create(); - var daemon = try Daemon.init(t.allocator, pipe.reader(), pipe.writer(), "/dev/null"); + var daemon = try Daemon.init(.{ + .allocator = t.allocator, + .confpath = "/unused.json", + .uir = pipe.reader(), + .uiw = pipe.writer(), + .wpa = "/dev/null", + }); daemon.want_network_report = false; daemon.want_bitcoind_report = false; daemon.want_lnd_report = false; @@ -770,7 +791,13 @@ test "start-poweroff" { const gui_stdin = try types.IoPipe.create(); const gui_stdout = try types.IoPipe.create(); const gui_reader = gui_stdin.reader(); - var daemon = try Daemon.init(arena, gui_stdout.reader(), gui_stdin.writer(), "/dev/null"); + var daemon = try Daemon.init(.{ + .allocator = arena, + .confpath = "/unused.json", + .uir = gui_stdout.reader(), + .uiw = gui_stdin.writer(), + .wpa = "/dev/null", + }); daemon.want_network_report = false; daemon.want_bitcoind_report = false; daemon.want_lnd_report = false; diff --git a/src/test.zig b/src/test.zig index f158045..b885375 100644 --- a/src/test.zig +++ b/src/test.zig @@ -43,6 +43,46 @@ fn initGlobalFn() void { comm.initPipe(global_gpa, pipe); } +pub const TempDir = struct { + abspath: []const u8, + dir: std.fs.Dir, + + tmp: std.testing.TmpDir, + arena: *std.heap.ArenaAllocator, + + pub fn create() !TempDir { + var arena = try std.testing.allocator.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(std.testing.allocator); + errdefer { + arena.deinit(); + std.testing.allocator.destroy(arena); + } + var tmp = std.testing.tmpDir(.{}); + errdefer tmp.cleanup(); + return .{ + .abspath = try tmp.dir.realpathAlloc(arena.allocator(), "."), + .dir = tmp.dir, + .tmp = tmp, + .arena = arena, + }; + } + + pub fn cleanup(self: *TempDir) void { + self.tmp.cleanup(); + const allocator = self.arena.child_allocator; + self.arena.deinit(); + allocator.destroy(self.arena); + } + + pub fn join(self: *TempDir, paths: []const []const u8) ![]const u8 { + var list = std.ArrayList([]const u8).init(self.arena.child_allocator); + defer list.deinit(); + try list.append(self.abspath); + try list.appendSlice(paths); + return std.fs.path.join(self.arena.allocator(), list.items); + } +}; + /// TestTimer always reports the same fixed value. pub const TestTimer = struct { value: u64,