nd: add a persistent configuration support
the config will be used by the daemon to store user choices across reboots. closes #6pull/28/head
parent
aca7eb1165
commit
664c75a9c9
@ -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);
|
||||
}
|
Reference in New Issue