nd: add a persistent configuration support

the config will be used by the daemon to store user choices across
reboots.

closes #6
pull/28/head
alex 1 year ago
parent aca7eb1165
commit 664c75a9c9
Signed by: x1ddos
GPG Key ID: FDEFB4A63CBD8460

@ -17,7 +17,7 @@ const stderr = std.io.getStdErr().writer();
/// prints usage help text to stderr. /// prints usage help text to stderr.
fn usage(prog: []const u8) !void { fn usage(prog: []const u8) !void {
try stderr.print( 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. \\nd is a short for nakamochi daemon.
\\the daemon executes ngui as a child process and runs until \\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. \\nd logs messages to stderr.
\\ \\
, .{prog}); , .{ .prog = prog, .confpath = NdArgs.defaultConf });
} }
/// nd program flags. see usage. /// nd program flags. see usage.
const NdArgs = struct { const NdArgs = struct {
conf: ?[:0]const u8 = null,
gui: ?[:0]const u8 = null, gui: ?[:0]const u8 = null,
gui_user: ?[:0]const u8 = null, gui_user: ?[:0]const u8 = null,
wpa: ?[: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 { 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) |p| allocator.free(p);
if (self.gui_user) |p| allocator.free(p); if (self.gui_user) |p| allocator.free(p);
if (self.wpa) |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 { var lastarg: enum {
none, none,
conf,
gui, gui,
gui_user, gui_user,
wpa, wpa,
} = .none; } = .none;
while (args.next()) |a| { while (args.next()) |a| {
switch (lastarg) { switch (lastarg) {
.conf => {
flags.conf = try gpa.dupeZ(u8, a);
lastarg = .none;
continue;
},
.gui => { .gui => {
flags.gui = try gpa.dupeZ(u8, a); flags.gui = try gpa.dupeZ(u8, a);
lastarg = .none; lastarg = .none;
@ -80,6 +91,8 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs {
} else if (std.mem.eql(u8, a, "-v")) { } else if (std.mem.eql(u8, a, "-v")) {
try stderr.print("{any}\n", .{buildopts.semver}); try stderr.print("{any}\n", .{buildopts.semver});
std.process.exit(0); std.process.exit(0);
} else if (std.mem.eql(u8, a, "-conf")) {
lastarg = .conf;
} else if (std.mem.eql(u8, a, "-gui")) { } else if (std.mem.eql(u8, a, "-gui")) {
lastarg = .gui; lastarg = .gui;
} else if (std.mem.eql(u8, a, "-gui-user")) { } else if (std.mem.eql(u8, a, "-gui-user")) {
@ -91,11 +104,14 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs {
return error.UnknownArgName; return error.UnknownArgName;
} }
} }
if (lastarg != .none) { if (lastarg != .none) {
logger.err("invalid arg: {s} requires a value", .{@tagName(lastarg)}); logger.err("invalid arg: {s} requires a value", .{@tagName(lastarg)});
return error.MissinArgValue; return error.MissinArgValue;
} }
if (flags.conf == null) {
flags.conf = NdArgs.defaultConf;
}
if (flags.gui == null) { if (flags.gui == null) {
logger.err("missing -gui arg", .{}); logger.err("missing -gui arg", .{});
return error.MissingGuiFlag; return error.MissingGuiFlag;
@ -130,6 +146,7 @@ pub fn main() !void {
logger.err("memory leaks detected", .{}); logger.err("memory leaks detected", .{});
}; };
const gpa = gpa_state.allocator(); const gpa = gpa_state.allocator();
// parse program args first thing and fail fast if invalid // parse program args first thing and fail fast if invalid
const args = try parseArgs(gpa); const args = try parseArgs(gpa);
defer args.deinit(gpa); defer args.deinit(gpa);
@ -179,7 +196,13 @@ pub fn main() !void {
return err; 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(); defer nd.deinit();
try nd.start(); try nd.start();

@ -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 <channel>?" where <channel> 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);
}

@ -17,6 +17,7 @@ const time = std.time;
const bitcoindrpc = @import("../bitcoindrpc.zig"); const bitcoindrpc = @import("../bitcoindrpc.zig");
const comm = @import("../comm.zig"); const comm = @import("../comm.zig");
const Config = @import("Config.zig");
const lndhttp = @import("../lndhttp.zig"); const lndhttp = @import("../lndhttp.zig");
const network = @import("network.zig"); const network = @import("network.zig");
const screen = @import("../ui/screen.zig"); const screen = @import("../ui/screen.zig");
@ -26,6 +27,7 @@ const types = @import("../types.zig");
const logger = std.log.scoped(.daemon); const logger = std.log.scoped(.daemon);
allocator: mem.Allocator, allocator: mem.Allocator,
conf: Config,
uireader: std.fs.File.Reader, // ngui stdout uireader: std.fs.File.Reader, // ngui stdout
uiwriter: std.fs.File.Writer, // ngui stdin uiwriter: std.fs.File.Writer, // ngui stdin
wpa_ctrl: types.WpaControl, // guarded by mu once start'ed wpa_ctrl: types.WpaControl, // guarded by mu once start'ed
@ -68,24 +70,36 @@ services: []SysService = &.{},
const Daemon = @This(); 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, /// initializes a daemon instance using the provided GUI stdout reader and stdin writer,
/// and a filesystem path to WPA control socket. /// and a filesystem path to WPA control socket.
/// callers must deinit when done. /// 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 { pub fn init(opt: InitOpt) !Daemon {
var svlist = std.ArrayList(SysService).init(a); var svlist = std.ArrayList(SysService).init(opt.allocator);
errdefer { errdefer {
for (svlist.items) |*sv| sv.deinit(); for (svlist.items) |*sv| sv.deinit();
svlist.deinit(); svlist.deinit();
} }
// the order is important. when powering off, the services are shut down // the order is important. when powering off, the services are shut down
// in the same order appended here. // in the same order appended here.
try svlist.append(SysService.init(a, "lnd", .{ .stop_wait_sec = 600 })); try svlist.append(SysService.init(opt.allocator, "lnd", .{ .stop_wait_sec = 600 }));
try svlist.append(SysService.init(a, "bitcoind", .{ .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 .{ return .{
.allocator = a, .allocator = opt.allocator,
.uireader = r, .conf = conf,
.uiwriter = w, .uireader = opt.uir,
.wpa_ctrl = try types.WpaControl.open(wpa), .uiwriter = opt.uiw,
.wpa_ctrl = try types.WpaControl.open(opt.wpa),
.state = .stopped, .state = .stopped,
.services = try svlist.toOwnedSlice(), .services = try svlist.toOwnedSlice(),
// send a network report right at start without wifi scan to make it faster. // 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. /// releases all associated resources.
/// the daemon must be stop'ed and wait'ed before deiniting. /// the daemon must be stop'ed and wait'ed before deiniting.
pub fn deinit(self: *Daemon) void { pub fn deinit(self: *Daemon) void {
defer self.conf.deinit();
self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err}); self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err});
for (self.services) |*sv| { for (self.services) |*sv| {
sv.deinit(); sv.deinit();
@ -725,7 +740,13 @@ test "start-stop" {
const t = std.testing; const t = std.testing;
const pipe = try types.IoPipe.create(); 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_network_report = false;
daemon.want_bitcoind_report = false; daemon.want_bitcoind_report = false;
daemon.want_lnd_report = false; daemon.want_lnd_report = false;
@ -770,7 +791,13 @@ test "start-poweroff" {
const gui_stdin = try types.IoPipe.create(); const gui_stdin = try types.IoPipe.create();
const gui_stdout = try types.IoPipe.create(); const gui_stdout = try types.IoPipe.create();
const gui_reader = gui_stdin.reader(); 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_network_report = false;
daemon.want_bitcoind_report = false; daemon.want_bitcoind_report = false;
daemon.want_lnd_report = false; daemon.want_lnd_report = false;

@ -43,6 +43,46 @@ fn initGlobalFn() void {
comm.initPipe(global_gpa, pipe); 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. /// TestTimer always reports the same fixed value.
pub const TestTimer = struct { pub const TestTimer = struct {
value: u64, value: u64,