summaryrefslogtreecommitdiffstats
path: root/aconf
diff options
context:
space:
mode:
Diffstat (limited to 'aconf')
-rw-r--r--aconf/error.lua99
-rw-r--r--aconf/init.lua25
-rw-r--r--aconf/loader.lua21
-rw-r--r--aconf/model/aaa.lua88
-rw-r--r--aconf/model/binary.lua50
-rw-r--r--aconf/model/combination.lua50
-rw-r--r--aconf/model/field.lua382
-rw-r--r--aconf/model/init.lua279
-rw-r--r--aconf/model/model.lua228
-rw-r--r--aconf/model/net.lua203
-rw-r--r--aconf/model/node.lua379
-rw-r--r--aconf/model/permission.lua25
-rw-r--r--aconf/model/root.lua89
-rw-r--r--aconf/model/service.lua36
-rw-r--r--aconf/model/set.lua56
-rw-r--r--aconf/model/time.lua63
-rw-r--r--aconf/modules/demo-awall.lua149
-rw-r--r--aconf/modules/demo-generic.lua14
-rw-r--r--aconf/modules/network.lua281
-rw-r--r--aconf/modules/openssh.lua28
-rw-r--r--aconf/object.lua68
-rw-r--r--aconf/path.lua119
-rw-r--r--aconf/persistence/backends/augeas.lua273
-rw-r--r--aconf/persistence/backends/files.lua107
-rw-r--r--aconf/persistence/backends/json.lua79
-rw-r--r--aconf/persistence/backends/null.lua10
-rw-r--r--aconf/persistence/backends/service.lua32
-rw-r--r--aconf/persistence/backends/volatile.lua46
-rw-r--r--aconf/persistence/defer.lua47
-rw-r--r--aconf/persistence/init.lua113
-rw-r--r--aconf/persistence/util.lua27
-rw-r--r--aconf/transaction/base.lua225
-rw-r--r--aconf/transaction/init.lua126
-rw-r--r--aconf/util.lua103
34 files changed, 3920 insertions, 0 deletions
diff --git a/aconf/error.lua b/aconf/error.lua
new file mode 100644
index 0000000..165bb46
--- /dev/null
+++ b/aconf/error.lua
@@ -0,0 +1,99 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local object = require('aconf.object')
+local class = object.class
+
+local util = require('aconf.util')
+
+local json = require('cjson')
+
+
+local ErrorTable = class()
+
+function ErrorTable:init() self.errors = {} end
+
+function ErrorTable:success() return not next(self.errors) end
+
+function ErrorTable:raise()
+ if not self:success() then error(json.encode(self.errors)) end
+end
+
+
+local ErrorList = class(ErrorTable)
+
+function ErrorList:init(label)
+ object.super(self, ErrorList):init()
+ self.label = label
+end
+
+function ErrorList:insert(msg)
+ table.insert(util.setdefault(self.errors, self.label, {}), msg)
+end
+
+
+M.ErrorDict = class(ErrorTable)
+
+function M.ErrorDict:collect(func, ...)
+ local function pack(success, ...)
+ local arg = {...}
+ return success, success and arg or arg[1]
+ end
+
+ local arg = {...}
+ local success, res = pack(
+ xpcall(
+ function() return func(unpack(arg)) end,
+ function(err)
+ local _, _, data = err:find('.-: (.+)')
+ local success, res = pcall(json.decode, data)
+ if success and type(res) == 'table' then return res end
+ return data..'\n'..debug.traceback()
+ end
+ )
+ )
+
+ if success then return unpack(res) end
+
+ if type(res) == 'table' then
+ for label, errors in pairs(res) do
+ for _, err in ipairs(errors) do
+ table.insert(util.setdefault(self.errors, label, {}), err)
+ end
+ end
+ else error(res) end
+end
+
+
+function M.raise(label, msg)
+ local err = ErrorList(label)
+ err:insert(msg)
+ err:raise()
+end
+
+function M.relabel(label, ...)
+ local err = M.ErrorDict()
+ local res = {err:collect(...)}
+ if err:success() then return unpack(res) end
+
+ elist = ErrorList(label)
+ for lbl, el in pairs(err.errors) do
+ for _, e in ipairs(el) do elist:insert(lbl..': '..e) end
+ end
+ elist:raise()
+end
+
+function M.call(...)
+ local err = M.ErrorDict()
+ local res = {err:collect(...)}
+ if err:success() then return true, unpack(res) end
+ if err.errors.system then error(err.errors.system[1]) end
+ return false, err.errors
+end
+
+
+return M
diff --git a/aconf/init.lua b/aconf/init.lua
new file mode 100644
index 0000000..9a4e0b5
--- /dev/null
+++ b/aconf/init.lua
@@ -0,0 +1,25 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+M.model = require('aconf.model')
+
+require('aconf.model.aaa')
+local mods = require('aconf.loader')('modules')
+
+M.call = require('aconf.error').call
+M.object = require('aconf.object')
+M.path = require('aconf.path')
+M.start_txn = require('aconf.transaction')
+
+local txn = M.start_txn()
+for _, rv in pairs(mods) do if type(rv) == 'function' then rv(txn) end end
+txn:commit()
+
+local def_store = require('aconf.persistence.defer')
+function M.commit() def_store:commit() end
+
+return M
diff --git a/aconf/loader.lua b/aconf/loader.lua
new file mode 100644
index 0000000..8c58adc
--- /dev/null
+++ b/aconf/loader.lua
@@ -0,0 +1,21 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local pth = require('aconf.path')
+
+local posix = require('posix')
+local stringy = require('stringy')
+
+return function(subdir)
+ local comps = pth.split('aconf/'..subdir)
+ local res = {}
+ for _, modfile in ipairs(posix.dir(pth.join(unpack(comps)))) do
+ if stringy.endswith(modfile, '.lua') then
+ local name = modfile:sub(1, -5)
+ res[name] = require(table.concat(comps, '.')..'.'..name)
+ end
+ end
+ return res
+ end
diff --git a/aconf/model/aaa.lua b/aconf/model/aaa.lua
new file mode 100644
index 0000000..e324381
--- /dev/null
+++ b/aconf/model/aaa.lua
@@ -0,0 +1,88 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = require('aconf.model')
+local object = require('aconf.object')
+
+local digest = require('crypto').digest
+
+
+local Role = M.new()
+Role.permissions = M.Set{type=M.Reference{scope='../../../permissions'}}
+
+
+local function hash_password(algorithm, salt, password)
+ return algorithm..'$'..salt..'$'..digest(algorithm, salt..password)
+end
+
+local hash_pattern = '^(%w+)%$(%w+)%$%x+$'
+
+
+local Password = object.class(M.String)
+
+function Password:init() object.super(self, Password):init{detail=true} end
+
+function Password:normalize(context, value)
+ if value:find(hash_pattern) then return value end
+
+ local salt = ''
+ for i = 1,12 do
+ local c = math.random(48, 109)
+ if c > 57 then c = c + 7 end
+ if c > 90 then c = c + 6 end
+ salt = salt..string.char(c)
+ end
+ return hash_password('sha256', salt, value)
+end
+
+
+local User = M.new()
+User.password = Password
+User.real_name = M.String
+User.superuser = M.Boolean{default=false}
+User.roles = M.Set{type=M.Reference{scope='../../../roles'}}
+
+function User:check_password(password)
+ if not self.password then return false end
+ local _, _, algorithm, salt = self.password:find(hash_pattern)
+ if not salt then return false end
+ return hash_password(algorithm, salt, password) == self.password
+end
+
+function User:check_permission(permission)
+ -- TODO audit trail
+ print('check permission', permission)
+
+ if self.superuser then return true end
+
+ assert(getmetatable(self).txn:fetch('/auth/permissions')[permission])
+
+ for _, role in M.node.pairs(self.roles, true) do
+ for _, p in M.node.pairs(role.permissions, true) do
+ if p == permission then return true end
+ end
+ end
+ return false
+end
+
+
+local Authentication = M.new()
+Authentication.users = M.Collection{type=User}
+Authentication.roles = M.Collection{type=Role}
+Authentication.permissions = M.Set{
+ type=M.String,
+ addr='/volatile/aaa/permissions'
+}
+
+M.register(
+ 'auth',
+ Authentication,
+ {
+ addr='/json'..require('posix').getcwd()..'/config/aaa.json',
+ ui_name='Authentication'
+ }
+)
+
+M.permission.defaults('/auth')
diff --git a/aconf/model/binary.lua b/aconf/model/binary.lua
new file mode 100644
index 0000000..fcff3ea
--- /dev/null
+++ b/aconf/model/binary.lua
@@ -0,0 +1,50 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local object = require('aconf.object')
+local class = object.class
+local super = object.super
+
+
+local b64 = require('b64')
+
+local magic = require('magic')
+magic = magic.open(magic.MIME_TYPE)
+magic:load()
+
+
+M.Data = class()
+
+function M.Data:init(path, data)
+ self.path = path
+ self.data = data
+ self.type = magic:buffer(data)
+end
+
+function M.Data:encode()
+ return 'data:'..self.type..';base64,'..b64.encode(self.data)
+end
+
+
+M.Audio = class(require('aconf.model.field').Field)
+
+function M.Audio:init(params)
+ super(self, M.Audio):init(params)
+ self.dtype = 'binary'
+ self.widget = 'audio'
+end
+
+function M.Audio:load(context)
+ local value = super(self, M.Audio):load(context)
+ return type(value) == 'string' and M.Data(context.path, value) or value
+end
+
+-- not yet implemented
+function M.Audio:save(context, value) end
+
+
+return M
diff --git a/aconf/model/combination.lua b/aconf/model/combination.lua
new file mode 100644
index 0000000..cc145c3
--- /dev/null
+++ b/aconf/model/combination.lua
@@ -0,0 +1,50 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local err = require('aconf.error')
+local raise = err.raise
+
+local fld = require('aconf.model.field')
+local String = fld.String
+
+local to_field = require('aconf.model.model').to_field
+
+local object = require('aconf.object')
+local class = object.class
+local super = object.super
+
+
+local stringy = require('stringy')
+
+
+M.Range = class(String)
+
+function M.Range:init(params)
+ super(self, M.Range):init(params)
+ if not self.type then self.type = fld.Integer end
+end
+
+function M.Range:validate(context, value)
+ local comps = stringy.split(value, '-')
+ if #comps > 2 then raise(context.path, 'Invalid range') end
+ for _, v in ipairs(comps) do to_field(self.type):_validate(context, v) end
+end
+
+
+M.Union = class(String)
+
+function M.Union:validate(context, value)
+ super(self, M.Union):validate(context, value)
+ for _, tpe in ipairs(self.types) do
+ local field = to_field(tpe)
+ if err.call(field.validate, field, context, value) then return end
+ end
+ raise(context.path, self.error or 'Invalid value')
+end
+
+
+return M
diff --git a/aconf/model/field.lua b/aconf/model/field.lua
new file mode 100644
index 0000000..80f08c5
--- /dev/null
+++ b/aconf/model/field.lua
@@ -0,0 +1,382 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local err = require('aconf.error')
+local raise = err.raise
+
+local node = require('aconf.model.node')
+
+local object = require('aconf.object')
+local class = object.class
+local super = object.super
+
+local util = require('aconf.util')
+local map = util.map
+local setdefaults = util.setdefaults
+
+
+M.Member = class()
+
+function M.Member:init(params)
+ for k, v in pairs(params or {}) do
+ if self[k] == nil then self[k] = v end
+ end
+end
+
+function M.Member:auto_ui_name(name)
+ if not name then return end
+ if type(name) ~= 'string' then return tostring(name) end
+ local res = (name:sub(1, 1):upper()..name:sub(2)):gsub('-', ' ')
+ return res
+end
+
+function M.Member:meta(context)
+ return {
+ name=self.name,
+ description=self.description,
+ ['ui-name']=self.ui_name or self:auto_ui_name(self.name)
+ }
+end
+
+
+function M.normalize_name(name) return name:gsub('_', '-') end
+
+function M.conv_filter(filter)
+ if not filter then return end
+ local res = {}
+ for k, v in pairs(filter) do
+ res[M.normalize_name(k)] = type(v) == 'table' and v or {v}
+ end
+ return res
+end
+
+
+M.Field = class(M.Member)
+
+function M.Field:init(params)
+ if not params then params = {} end
+ setdefaults(
+ params, {addr=params.compute and node.null_addr or nil, visible=true}
+ )
+
+ super(self, M.Field):init(params)
+
+ for _, param in ipairs{'compute', 'store', 'editable'} do
+ local func = self[param]
+ if type(func) == 'string' then
+ self[param] = function(self, obj, ...) return obj[func](obj, ...) end
+ end
+ end
+
+ self.condition = M.conv_filter(self.condition)
+
+ if self.choice then
+ self.choice = map(
+ function(choice)
+ if type(choice) ~= 'table' then choice = {choice} end
+ for i, k in ipairs{'value', 'ui-value'} do
+ if choice[i] then
+ assert(not choice[k])
+ choice[k] = choice[i]
+ choice[i] = nil
+ end
+ end
+ return setdefaults(
+ choice,
+ {
+ be_value=choice.value,
+ enabled=true,
+ ['ui-value']=self:auto_ui_name(choice.value)
+ }
+ )
+ end,
+ self.choice
+ )
+ end
+
+ if not self.widget then
+ self.widget = self.choice and 'combobox' or 'field'
+ end
+end
+
+function M.Field:_editable(context)
+ if self.editable == nil then
+ if self.store or not self.compute then return true end
+ if self.compute then return self:_compute(context) == nil end
+ return false
+ end
+
+ if type(self.editable) == 'function' then
+ return self:editable(context.parent) and true or false
+ end
+
+ return self.editable
+end
+
+function M.Field:_choice(context) return self.choice end
+
+function M.Field:meta(context)
+ assert(self.dtype)
+ local choice = self:_choice(context)
+ return util.update(
+ super(self, M.Field):meta(context),
+ {
+ type=self.dtype,
+ visible=self.visible,
+ editable=self:_editable(context),
+ condition=self.condition,
+ required=self.required,
+ default=self.default,
+ choice=choice and map(
+ function(ch)
+ ch = util.copy(ch)
+ ch.be_value = nil
+ return ch
+ end,
+ choice
+ ),
+ widget=self.widget,
+ detail=self.detail
+ }
+ )
+end
+
+function M.Field:topology(context)
+ return {
+ {path=context.path, addr=context.addr, type=self.dtype}
+ }
+end
+
+function M.Field:load(context)
+ if not context.txn then return setmetatable({}, context) end
+ local value = self:_load(context)
+ if value == nil and self.compute then value = self:_compute(context) end
+ if value == nil then return self.default end
+ return value
+end
+
+function M.Field:_compute(context)
+ return self:compute(context.parent, context.txn)
+end
+
+function M.Field:_load(context) return context.txn:get(context.addr) end
+
+function M.Field:_validate(context, value)
+ if value == nil then
+ self:check_required(context)
+ return
+ end
+
+ value = self:normalize(context, value)
+
+ local committing = context.txn:committing()
+ local choice = self:_choice(context)
+ local be_value
+ if choice then
+ for _, ch in ipairs(choice) do
+ if ch.value == value and (committing or ch.enabled) then
+ be_value = ch.be_value
+ break
+ end
+ end
+ if be_value == nil then raise(context.path, 'Invalid value') end
+ end
+
+ self:validate(context, value)
+
+ if choice then return be_value end
+ return value
+end
+
+function M.Field:check_editable(context)
+ if not self:_editable(context) then
+ raise(context.path, 'Is not editable')
+ end
+end
+
+function M.Field:check_required(context)
+ if self.required then raise(context.path, 'Required value not set') end
+end
+
+function M.Field:normalize(context, value) return value end
+
+function M.Field:validate(context, value) end
+
+function M.Field:save(context, value)
+ self:check_editable(context)
+ if self.store then self:store(context.parent, value, context.txn)
+ else self:_save(context, self:_validate(context, value)) end
+end
+
+function M.Field:_save(context, value) context.txn:set(context.addr, value) end
+
+function M.Field:validate_saved(context)
+ if self:_editable(context) then self:save(context, self:load(context)) end
+end
+
+
+local Primitive = class(M.Field)
+
+function Primitive:_load(context)
+ local value = super(self, Primitive):_load(context)
+ if value == nil then return end
+
+ local choice = self:_choice(context)
+ if not choice then return value end
+
+ for _, ch in ipairs(choice) do
+ if ch.be_value == value then return ch.value end
+ end
+ assert(false)
+end
+
+function Primitive:validate(context, value)
+ local t = self.dtype
+ if type(value) ~= t then raise(context.path, 'Not a '..t) end
+end
+
+
+M.String = class(Primitive)
+
+function M.String:init(params)
+ super(self, M.String):init(params)
+ self.dtype = 'string'
+end
+
+function M.String:validate(context, value)
+ super(self, M.String):validate(context, value)
+ if self['max-length'] and value:len() > self['max-length'] then
+ raise(context.path, 'Maximum length exceeded')
+ end
+ if self.pattern and not value:match('^'..self.pattern..'$') then
+ raise(context.path, 'Invalid value')
+ end
+end
+
+function M.String:meta(context)
+ local res = super(self, M.String):meta(context)
+ res['max-length'] = self['max-length']
+ return res
+end
+
+
+M.Number = class(Primitive)
+
+function M.Number:init(params)
+ super(self, M.Number):init(params)
+ self.dtype = 'number'
+end
+
+function M.Number:normalize(context, value)
+ return value and tonumber(value) or value
+end
+
+function M.Number:validate(context, value)
+ super(self, M.Number):validate(context, value)
+ if self.min and value < self.min then
+ raise(context.path, 'Minimum value is '..self.min)
+ end
+ if self.max and value > self.max then
+ raise(context.path, 'Maximum value is '..self.max)
+ end
+end
+
+
+M.Integer = class(M.Number)
+
+function M.Integer:validate(context, value)
+ super(self, M.Integer):validate(context, value)
+ if math.floor(value) ~= value then raise(context.path, 'Not an integer') end
+end
+
+
+M.Boolean = class(Primitive)
+
+function M.Boolean:init(params)
+ super(self, M.Boolean):init(params)
+ self.dtype = 'boolean'
+ self.widget = 'checkbox'
+end
+
+
+M.TreeNode = class(M.Field)
+
+function M.TreeNode:init(params)
+ super(self, M.TreeNode):init(
+ setdefaults(params, {detail=true, widget='link'})
+ )
+end
+
+function M.TreeNode:topology(context)
+ local res = super(self, M.TreeNode):topology(context)
+ res[1].type = 'table'
+ util.extend(res, node.topology(self:load(context, {create=true})))
+ return res
+end
+
+function M.TreeNode:load(context, options)
+ if context.txn and not (
+ util.setdefault(
+ options or {}, 'create', self.create
+ ) or self:_load(context)
+ ) then return end
+ return self.itype(context, self.iparams)
+end
+
+function M.TreeNode:save(context, value)
+ local path = context.path
+
+ if value == path then return end
+ if type(value) == 'string' then value = context.txn:fetch(value) end
+ if object.isinstance(value, node.TreeNode) and node.path(value) == path then
+ return
+ end
+
+ local check = value ~= nil and next(value) ~= nil
+ if not check then
+ local old = self:_load(context)
+ if old and next(old) ~= nil then check = true end
+ end
+ if check then self:check_editable(context) end
+
+ self:_save(context)
+
+ if value then
+ if type(value) ~= 'table' then
+ raise(path, 'Cannot assign primitive value')
+ end
+
+ self:_save(context, {})
+ local new = self:load(context, {create=true})
+
+ local errors = err.ErrorDict()
+ for k, v in node.pairs(value) do
+ errors:collect(self.save_member, new, k, v)
+ end
+ errors:raise()
+ end
+end
+
+function M.TreeNode.save_member(node, k, v) node[k] = v end
+
+function M.TreeNode:validate_saved(context)
+ if self:load(context) == nil then self:check_required(context) end
+end
+
+
+M.Model = class(M.TreeNode)
+
+function M.Model:init(params)
+ super(self, M.Model):init(params)
+
+ assert(self.model)
+ self.itype = self.model
+ self.dtype = 'model'
+end
+
+
+return M
diff --git a/aconf/model/init.lua b/aconf/model/init.lua
new file mode 100644
index 0000000..c0b5998
--- /dev/null
+++ b/aconf/model/init.lua
@@ -0,0 +1,279 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+M.error = require('aconf.error')
+local raise = M.error.raise
+local relabel = M.error.relabel
+
+M.binary = require('aconf.model.binary')
+
+local combination = require('aconf.model.combination')
+M.Union = combination.Union
+M.Range = combination.Range
+
+local fld = require('aconf.model.field')
+local Field = fld.Field
+M.Boolean = fld.Boolean
+M.Integer = fld.Integer
+M.Number = fld.Number
+M.String = fld.String
+
+local model = require('aconf.model.model')
+M.Action = model.Action
+M.new = model.new
+local to_field = model.to_field
+
+M.net = require('aconf.model.net')
+
+local node = require('aconf.model.node')
+M.node = {}
+for _, m in ipairs{
+ 'List',
+ 'Set',
+ 'TreeNode',
+ 'contains',
+ 'has_permission',
+ 'insert',
+ 'meta',
+ 'mmeta',
+ 'name',
+ 'parent',
+ 'path',
+ 'pairs',
+ 'ipairs'
+} do M.node[m] = node[m] end
+
+M.permission = require('aconf.model.permission')
+M.register = require('aconf.model.root').register
+M.service = require('aconf.model.service')
+M.node.Set = require('aconf.model.set').Set
+M.time = require('aconf.model.time')
+
+local object = require('aconf.object')
+local class = object.class
+local isinstance = object.isinstance
+local super = object.super
+
+M.path = require('aconf.path')
+local store = require('aconf.persistence')
+local def_store = require('aconf.persistence.defer')
+
+local util = require('aconf.util')
+local setdefault = util.setdefault
+local update = util.update
+
+
+local stringy = require('stringy')
+
+
+M.Reference = class(Field)
+
+function M.Reference:init(params)
+ super(self, M.Reference):init(
+ util.setdefaults(
+ params, {on_delete='restrict', scope='/', widget='reference'}
+ )
+ )
+ self.dtype = 'reference'
+ self.dereference = true
+ self.filter = fld.conv_filter(self.filter)
+end
+
+function M.Reference:topology(context)
+ local res = super(self, M.Reference):topology(context)
+ res[1].scope = self.scope
+ return res
+end
+
+function M.Reference:abs_scope(context)
+ return M.path.to_absolute(self.scope, node.path(context.parent))
+end
+
+-- assume one-level refs for now
+function M.Reference:_choice(context)
+ local res = {}
+
+ local txn = context.txn
+ local obj = relabel('system', txn.fetch, txn, self:abs_scope(context))
+ assert(isinstance(obj, node.Collection))
+
+ for k, v in node.pairs(obj) do
+ local ch = {enabled=true}
+
+ if isinstance(v, node.TreeNode) then
+ ch.ref = node.path(v)
+ if M.path.is_subordinate(context.path, ch.ref) then ch = nil end
+ if ch then
+ ch['ui-value'] = M.path.name(ch.ref)
+ ch.be_value = M.path.escape(ch['ui-value'])
+ ch.value = self.dereference and ch.ref or ch.be_value
+ if self.filter then
+ assert(isinstance(v, model.Model))
+ if not node.match(v, self.filter) then ch.enabled = false end
+ end
+ end
+
+ else
+ local ep = M.path.escape(v)
+ update(ch, {be_value=ep, value=ep, ['ui-value']=v})
+ end
+
+ if ch then table.insert(res, ch) end
+ end
+
+ return res
+end
+
+function M.Reference:meta(context)
+ return update(
+ super(self, M.Reference):meta(context),
+ {scope=self:abs_scope(context), dynamic=self.filter and true or false}
+ )
+end
+
+function M.Reference:follow(context, value)
+ return context.txn:fetch(M.path.rawjoin(self:abs_scope(context), value))
+end
+
+function M.Reference:load(context, options)
+ local ref = super(self, M.Reference):load(context)
+ return (
+ setdefault(
+ options or {}, 'dereference', self.dereference
+ ) and context.txn and ref
+ ) and self:follow(context, ref) or ref
+end
+
+function M.Reference:normalize(context, value)
+ if isinstance(value, node.TreeNode) then value = node.path(value) end
+
+ local path = context.path
+ if type(value) ~= 'string' then raise(path, 'Path name must be string') end
+
+ local rel = value
+
+ if M.path.is_absolute(rel) then
+ local scope = self:abs_scope(context)
+ local prefix = scope..'/'
+ if not stringy.startswith(rel, prefix) then
+ raise(path, 'Reference out of scope ('..scope..')')
+ end
+ rel = rel:sub(prefix:len() + 1, -1)
+ end
+
+ -- TODO check instance type
+ relabel(path, self.follow, self, context, rel)
+
+ return self.dereference and value or rel
+end
+
+function M.Reference:deleted(context, addr)
+ local target = self:load(context, {dereference=true})
+
+ if target and node.addr(target) == addr then
+ local policy = self.on_delete
+
+ if policy == 'restrict' then
+ -- TODO raise error for the target object
+ raise(context.path, 'Refers to '..addr)
+ end
+
+ local parent = context.parent
+ local path = context.path
+
+ if policy == 'cascade' then
+ path = node.path(parent)
+ parent = node.parent(parent)
+ else assert(policy == 'set-null') end
+
+ node.save(parent, M.path.name(path))
+ end
+end
+
+
+M.Model = fld.Model
+
+
+M.Collection = class(fld.TreeNode)
+
+function M.Collection:init(params, itype)
+ if params.create == nil then params.create = true end
+ super(self, M.Collection):init(params)
+
+ assert(self.type)
+ self.itype = itype or node.Collection
+ self.iparams = {
+ destroy=self.destroy,
+ editable=self:_editable(),
+ key=self.key,
+ layout=self.layout,
+ required=self.required,
+ ui_member=self.ui_member
+ }
+
+ self.dtype = 'collection'
+end
+
+function M.Collection:auto_ui_name(name)
+ if not name then return end
+ if name:sub(-1, -1) ~= 's' then name = name..'s' end
+ return super(self, M.Collection):auto_ui_name(name)
+end
+
+function M.Collection:load(context, options)
+ if not self.iparams.field then
+ self.iparams.field = to_field(self.type)
+ if isinstance(self.iparams.field, fld.Model) then
+ setdefault(self.iparams, 'layout', 'tabular')
+ end
+ end
+ return super(self, M.Collection):load(context, options)
+end
+
+
+M.List = class(M.Collection)
+function M.List:init(params) super(self, M.List):init(params, node.List) end
+
+
+M.Set = class(M.Collection)
+function M.Set:init(params)
+ if not params.widget and isinstance(params.type, M.Reference) then
+ params.widget = 'checkboxes'
+ end
+ super(self, M.Set):init(params, M.node.Set)
+end
+function M.Set.save_member(tn, k, v) node.insert(tn, v) end
+
+
+-- experimental
+M.Mixed = class(M.Collection)
+
+function M.Mixed:init(params)
+ params.type = M.Mixed
+ super(self, M.Mixed):init(params, node.Mixed)
+ self.pfield = Field()
+end
+
+function M.Mixed:topology(context) return {} end
+
+function M.Mixed:load(context)
+ local value = self.pfield:load(context)
+ if type(value) == 'table' then return super(self, M.Mixed):load(context) end
+ return value
+end
+
+function M.Mixed:save(context, value)
+ if type(value) == 'table' then super(self, M.Mixed):save(context, value)
+ else self.pfield:save(context, value) end
+end
+
+
+function M.trigger(phase, addr, func) store:trigger(phase, addr, func) end
+function M.defer(addr) def_store:defer(addr) end
+
+
+return M
diff --git a/aconf/model/model.lua b/aconf/model/model.lua
new file mode 100644
index 0000000..0ff41d1
--- /dev/null
+++ b/aconf/model/model.lua
@@ -0,0 +1,228 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local raise = require('aconf.error').raise
+
+local fld = require('aconf.model.field')
+local Field = fld.Field
+local Member = fld.Member
+local Model = fld.Model
+local normalize_name = fld.normalize_name
+
+local node = require('aconf.model.node')
+local BoundMember = node.BoundMember
+
+local object = require('aconf.object')
+local class = object.class
+local super = object.super
+local isinstance = object.isinstance
+
+local pth = require('aconf.path')
+
+local util = require('aconf.util')
+local copy = util.copy
+local map = util.map
+
+
+local function to_member(obj, params)
+ if not params then params = {} end
+ if object.issubclass(obj, M.Model) then
+ params.model = obj
+ return Model(params)
+ end
+ local res = getmetatable(obj).class and obj or obj(params)
+ assert(isinstance(res, Member))
+ return res
+end
+
+function M.to_field(obj, params)
+ local res = to_member(obj, params)
+ assert(isinstance(res, Field))
+ return res
+end
+
+
+M.Action = class(Member)
+
+function M.Action:init(params)
+ super(self, M.Action):init(params)
+
+ if not self.func then error('Function not defined for action') end
+
+ if self.field then
+ assert(type(self.field) == 'table')
+ self.field = M.to_field(self.field)
+ self.field.addr = node.null_addr
+ end
+
+ getmetatable(self).__newindex = function(t, k, v)
+ assert(k == 'name')
+ rawset(t, k, v)
+ if t.field then t.field.name = v end
+ end
+end
+
+function M.Action:meta(context)
+ local res = super(self, M.Action):meta(context)
+ if self.field then res.arg = self.field:meta(context) end
+ return res
+end
+
+
+function M.new(base)
+ if not base then base = M.Model end
+
+ local res = class(base)
+ res.members = base == node.TreeNode and {} or copy(base.members)
+
+ local mt = copy(getmetatable(res))
+
+ function mt.__index(t, k) return base[k] end
+
+ function mt.__newindex(t, k, v)
+ assert(v)
+ k = normalize_name(k)
+
+ local override = t[k]
+ if type(v) == 'table' then v = to_member(v) end
+
+ rawset(t, k, v)
+
+ if isinstance(v, Member) then
+ v.name = k
+ if not override then table.insert(t.members, k) end
+ end
+ end
+
+ setmetatable(res, mt)
+
+ if isinstance(base, M.Model) then util.setdefaults(res, base) end
+
+ return res
+end
+
+M.Model = M.new(node.TreeNode)
+
+function M.Model:init(context)
+ super(self, M.Model):init(context, 'model', true)
+
+ local mt = getmetatable(self)
+
+ function mt.member(name, loose, tpe)
+ local m = mt.class[name]
+
+ if not tpe then tpe = Member end
+ if not isinstance(m, tpe) then m = nil end
+
+ if m == nil then
+ if loose then return end
+ raise(mt.path, 'Does not exist: '..name)
+ end
+
+ return BoundMember(self, name, m)
+ end
+
+ local function _members(tpe)
+ local res = {}
+ for _, name in ipairs(self.members) do
+ local m = mt.member(name, true, tpe)
+ if m then table.insert(res, m) end
+ end
+ return res
+ end
+
+ function mt.topology()
+ local res = {}
+ for _, f in ipairs(_members(Field)) do util.extend(res, f:topology()) end
+ return res
+ end
+
+ function mt.load(k, options)
+ local v = mt.class[k]
+ k = normalize_name(k)
+ if not v then v = mt.class[k] end
+
+ if isinstance(v, Field) then
+ return BoundMember(self, k, v):load(options)
+ end
+
+ assert(mt.txn)
+
+ if isinstance(v, M.Action) then
+ local f = v.field and BoundMember(self, k, v.field)
+ if options.create then return f and f:load(options) end
+
+ return function(var)
+ if f then f:save(var)
+ elseif var ~= nil then
+ raise(
+ pth.join(mt.path, v.name),
+ 'Action does not accept an input argument'
+ )
+ end
+ local res = v.func(self, f and f:load())
+ if f then f:_save() end
+ return res
+ end
+ end
+
+ return v
+ end
+
+ if self.is_removable then
+ function mt.removable() return self:is_removable() end
+ end
+
+ if not mt.txn then return end
+
+
+ function mt.mmeta(name) return mt.member(name):meta() end
+
+ function mt.save(k, v)
+ k = normalize_name(k)
+ mt.check_removable(k, v)
+ return mt.member(k, false, Field):save(v)
+ end
+
+ local function tmeta(tpe)
+ return map(function(m) return m:meta() end, _members(tpe))
+ end
+
+ function mt.init_meta(meta)
+ util.update(meta, {fields=tmeta(Field), actions=tmeta(M.Action)})
+ end
+
+ function mt.members()
+ return map(function(f) return f.name end, mt.meta().fields)
+ end
+
+ function mt.match(filter)
+ for k, v in pairs(filter) do
+ if not util.contains(v, mt.load(k)) then return false end
+ end
+ return true
+ end
+
+ function mt.validate()
+ for _, f in ipairs(_members(Field)) do
+ if mt.match(f.condition or {}) then f:validate_saved()
+ elseif f:_editable() then f:_save() end
+ end
+ if self.validate then self:validate() end
+ end
+
+ if self.has_permission then
+ function mt.has_permission(user, permission)
+ return self:has_permission(user, permission)
+ end
+ end
+
+ for _, f in ipairs(_members(Model)) do mt.load(f.name) end
+end
+
+
+return M
diff --git a/aconf/model/net.lua b/aconf/model/net.lua
new file mode 100644
index 0000000..7d7a00f
--- /dev/null
+++ b/aconf/model/net.lua
@@ -0,0 +1,203 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local raise = require('aconf.error').raise
+local Union = require('aconf.model.combination').Union
+
+local fld = require('aconf.model.field')
+local String = fld.String
+
+local object = require('aconf.object')
+local class = object.class
+local super = object.super
+
+local pth = require('aconf.path')
+local update = require('aconf.util').update
+
+
+local stringy = require('stringy')
+
+
+local BaseIPAddress = class(String)
+
+function BaseIPAddress:abs_mask_addr(context)
+ if self.mask_addr then
+ return pth.join(pth.parent(context.addr), self.mask_addr)
+ end
+end
+
+function BaseIPAddress:topology(context)
+ local res = super(self, BaseIPAddress):topology(context)
+ local maddr = self:abs_mask_addr(context)
+ if maddr then
+ table.insert(res, {path=context.path, addr=maddr, type=self.mask_type})
+ end
+ return res
+end
+
+function BaseIPAddress:invalid(context)
+ raise(context.path, 'Invalid IPv'..self.version..' address')
+end
+
+function BaseIPAddress:split(context, value)
+ local comps = stringy.split(value, '/')
+ if #comps == 1 then return value, self.length end
+
+ if #comps > 2 or not self.cidr then self:invalid(context) end
+
+ local mask = tonumber(comps[2])
+ if not mask or mask < 0 or mask > self.length then self:invalid(context) end
+ return comps[1], mask
+end
+
+function BaseIPAddress:_load(context)
+ local res = super(self, BaseIPAddress):_load(context)
+ local maddr = self:abs_mask_addr(context)
+ if res and maddr then
+ return res..'/'..(self:mask2cidr(context.txn:get(maddr)) or self.length)
+ end
+ return res
+end
+
+function BaseIPAddress:_save(context, value)
+ local maddr = self:abs_mask_addr(context)
+ if maddr then
+ local cidr
+ if value then value, cidr = self:split(context, value) end
+ context.txn:set(maddr, cidr and self:cidr2mask(cidr))
+ end
+ super(self, BaseIPAddress):_save(context, value)
+end
+
+
+M.IPv4Address = class(BaseIPAddress)
+
+function M.IPv4Address:init(params)
+ super(self, M.IPv4Address):init(
+ update(params, {version=4, length=32, mask_type='string'})
+ )
+end
+
+function M.IPv4Address:validate(context, value)
+ super(self, M.IPv4Address):validate(context, value)
+ local address = self:split(context, value)
+
+ local function test(...)
+ if #{...} ~= 4 then return true end
+ for _, octet in ipairs{...} do
+ if tonumber(octet) > 255 then return true end
+ end
+ end
+ if test(address:match('^(%d+)%.(%d+)%.(%d+)%.(%d+)$')) then
+ self:invalid(context)
+ end
+end
+
+function M.IPv4Address:mask2cidr(mask)
+ local acc = 0
+ for i, comp in ipairs(stringy.split(mask, '.')) do
+ acc = acc + math.pow(256, 4 - i) * tonumber(comp)
+ end
+ local res = 32
+ while acc % 2 == 0 do
+ res = res - 1
+ assert(res > -1)
+ acc = acc / 2
+ end
+ return res
+end
+
+function M.IPv4Address:cidr2mask(cidr)
+ local acc = (math.pow(2, cidr) - 1) * math.pow(2, 32 - cidr)
+ local comps = {}
+ for i = 4,1,-1 do
+ comps[i] = acc % 256
+ acc = math.floor(acc / 256)
+ end
+ return table.concat(comps, '.')
+end
+
+
+M.IPv6Address = class(BaseIPAddress)
+
+function M.IPv6Address:init(params)
+ super(self, M.IPv6Address):init(
+ update(params, {version=6, length=128, mask_type='number'})
+ )
+end
+
+function M.IPv6Address:validate(context, value)
+ super(self, M.IPv6Address):validate(context, value)
+ local address = self:split(context, value)
+
+ local function invalid() self:invalid(context) end
+
+ if address == '' then invalid() end
+
+ local comps = stringy.split(address, ':')
+ if #comps < 3 then invalid() end
+
+ local function collapse(i, ofs)
+ if comps[i] > '' then return end
+ if comps[i + ofs] > '' then invalid() end
+ table.remove(comps, i)
+ end
+ collapse(1, 1)
+ collapse(#comps, -1)
+ if #comps > 8 then invalid() end
+
+ local short = false
+ for _, comp in ipairs(comps) do
+ if comp == '' then
+ if short then invalid() end
+ short = true
+ elseif not comp:match('^%x%x?%x?%x?$') then invalid() end
+ end
+ if (
+ short and #comps == 3 and comps[2] == ''
+ ) or (not short and #comps < 8) then
+ invalid()
+ end
+end
+
+function M.IPv6Address:mask2cidr(mask) return mask end
+
+function M.IPv6Address:cidr2mask(cidr) return cidr end
+
+
+M.IPAddress = class(Union)
+
+function M.IPAddress:init(params)
+ super(self, M.IPAddress):init(
+ update(
+ params,
+ {types={M.IPv4Address, M.IPv6Address}, error='Invalid IP address'}
+ )
+ )
+end
+
+
+M.Port = class(fld.Integer)
+
+function M.Port:validate(context, value)
+ super(self, M.Port):validate(context, value)
+ if value < 0 or value > 65535 then raise(context.path, 'Invalid port') end
+end
+
+
+M.EmailAddress = class(String)
+
+function M.EmailAddress:init(params)
+ super(self, M.EmailAddress):init(
+ update(
+ params, {pattern='[A-Za-z0-9%.%+%-]+@[A-Za-z0-9%.%+%-]+%.%w%w+'}
+ )
+ )
+end
+
+
+return M
diff --git a/aconf/model/node.lua b/aconf/model/node.lua
new file mode 100644
index 0000000..49053f5
--- /dev/null
+++ b/aconf/model/node.lua
@@ -0,0 +1,379 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local raise = require('aconf.error').raise
+
+local object = require('aconf.object')
+local class = object.class
+local isinstance = object.isinstance
+local super = object.super
+
+local pth = require('aconf.path')
+
+local util = require('aconf.util')
+local copy = util.copy
+local update = util.update
+
+
+function M.null_addr(path, name) return '/null'..pth.join(path, name) end
+
+
+M.BoundMember = class()
+
+function M.BoundMember:init(parent, name, field)
+ local pmt = getmetatable(parent)
+ local mt = {}
+
+ function mt.__index(t, k)
+ local member = field[k]
+ if type(member) ~= 'function' then return member end
+
+ local addr = field.addr or pth.escape(name)
+ if type(addr) == 'function' then addr = addr(pmt.path, name) end
+ return function(self, ...)
+ return member(
+ field,
+ {
+ txn=pmt.txn,
+ parent=parent,
+ path=pth.join(pmt.path, name),
+ addr=pth.to_absolute(addr, pmt.addr)
+ },
+ ...
+ )
+ end
+ end
+
+ setmetatable(self, mt)
+end
+
+
+M.TreeNode = class()
+
+local function equal_tns(tn1, tn2)
+ return getmetatable(tn1).path == getmetatable(tn2).path
+end
+
+function M.TreeNode:init(context, dtype, editable)
+ local mt = getmetatable(self)
+ update(mt, context)
+
+ mt.name = pth.name(mt.path)
+ mt.__eq = equal_tns
+
+ function mt.meta()
+ if not mt._meta then
+ mt._meta = {type=dtype}
+ if mt.txn then
+ if mt.parent then
+ mt._meta['ui-name'] =
+ getmetatable(mt.parent).mmeta(mt.name)['ui-name']
+ end
+ mt.init_meta(mt._meta)
+ end
+ end
+ return mt._meta
+ end
+
+ function mt.get(k, options) return mt.load(k, options) end
+ function mt.removable() return true end
+
+ function mt.member_removable(k)
+ local v = mt.load(k, {dereference=false})
+ return editable and (
+ not isinstance(v, M.TreeNode) or getmetatable(v).removable()
+ )
+ end
+
+ function mt.check_removable(k, v)
+ if v == nil and not mt.member_removable(k) then
+ raise(pth.join(mt.path, k), 'Cannot be deleted')
+ end
+ end
+
+ if not mt.txn then return end
+
+ function mt.save(k, v) rawset(self, k, v) end
+ function mt.__index(t, k) return mt.get(k) end
+ function mt.__newindex(t, k, v) mt.save(k, v) end
+
+ function mt.has_permission(user, permission)
+ local p = permission..mt.path
+ if mt.txn:fetch('/auth/permissions')[p] then
+ return user:check_permission(p)
+ end
+
+ if ({create=true, delete=true})[permission] then
+ permission = 'modify'
+ end
+ return M.has_permission(mt.parent, user, permission)
+ end
+
+ mt.txn.validable[mt.path] = mt.addr
+end
+
+function M.TreeNode:fetch(path, create)
+ local mt = getmetatable(self)
+
+ if type(path) == 'string' then
+ if pth.is_absolute(path) and mt.path > '/' then
+ assert(not create)
+ return mt.txn:fetch(path)
+ end
+ path = pth.split(path)
+ end
+
+ if #path == 0 then return self end
+
+ local name = path[1]
+ table.remove(path, 1)
+
+ if name == pth.up then
+ if not mt.parent then
+ raise(mt.path, 'Root object does not have parent')
+ end
+ return M.TreeNode.fetch(mt.parent, path, create)
+ end
+
+ if not mt.member(name) then
+ raise(mt.path, 'Member does not exist: '..name)
+ end
+
+ local options = {}
+ if create then
+ options.create = true
+ if #path == 0 then options.dereference = false end
+ end
+ local next = mt.get(name, options)
+ if next == nil and (not create or #path > 0) then
+ raise(mt.path, 'Subordinate does not exist: '..name)
+ end
+
+ if #path > 0 and type(next) ~= 'table' then
+ raise(pth.join(mt.path, name), 'Is a primitive value')
+ end
+
+ return M.TreeNode.fetch(next, path, create)
+end
+
+function M.TreeNode:search_refs(path)
+ if type(path) == 'string' then path = pth.split(path) end
+
+ if #path == 0 then return {} end
+
+ local mt = getmetatable(self)
+ local name = path[1]
+ table.remove(path, 1)
+
+ local function collect(name)
+ local next = mt.load(name, {dereference=false})
+ if not next then return {} end
+
+ local member = mt.member(name)
+ if member.deleted then return {member} end
+
+ return isinstance(next, M.TreeNode) and M.TreeNode.search_refs(
+ next, path
+ ) or {}
+ end
+
+ if name == pth.wildcard then
+ local res = {}
+ for _, member in ipairs(mt.members()) do
+ util.extend(res, collect(member))
+ end
+ return res
+ end
+
+ return collect(name)
+end
+
+
+M.Collection = class(M.TreeNode)
+
+function M.Collection:init(context, params, dtype)
+ super(self, M.Collection):init(
+ context, dtype or 'collection', params.editable
+ )
+
+ self.init = nil
+ self.fetch = nil
+ self.search_refs = nil
+
+ local mt = getmetatable(self)
+ local field = M.BoundMember(self, pth.wildcard, params.field)
+
+ function mt.topology() return field:topology() end
+
+ function mt.member(name)
+ return M.BoundMember(self, name, params.field)
+ end
+
+ function mt.load(k, options) return mt.member(k):load(options) end
+
+ if not mt.txn then return end
+
+ function mt.init_meta(meta)
+ update(
+ meta,
+ {
+ editable=params.editable,
+ members=field:meta(),
+ required=params.required,
+ ['ui-member']=params.ui_member or meta['ui-name']:gsub('s$', ''),
+ widget=params.layout
+ }
+ )
+ end
+
+ local meta = mt.meta
+ function mt.meta()
+ local res = copy(meta())
+ res.removable = {}
+ for _, member in ipairs(mt.members()) do
+ if mt.member_removable(member) then
+ table.insert(res.removable, member)
+ end
+ end
+ return res
+ end
+
+ function mt.mmeta(name)
+ local meta = mt.meta()
+ local res = copy(meta.members)
+ if name ~= pth.wildcard then
+ res['ui-name'] = meta['ui-member']..' '..name
+ end
+ return res
+ end
+
+ function mt.members() return mt.txn:get(mt.addr) or {} end
+
+ function mt.__len() return #mt.members() end
+
+ function mt.validate()
+ if #mt.members() > 0 then return end
+ if params.required then raise(mt.path, 'Collection cannot be empty') end
+ if params.destroy then
+ mt.txn:set(mt.addr)
+ validate(mt.parent)
+ end
+ end
+
+ function mt.save(k, v)
+ if not params.editable then
+ raise(mt.path, 'Collection is not editable')
+ end
+
+ mt.check_removable(k, v)
+
+ if params.key then
+ local kf = M.BoundMember(self, k, params.key)
+ if kf:normalize(k) ~= k then
+ raise(mt.path, 'Invalid member name: '..k)
+ end
+ kf:validate(k)
+ end
+
+ mt.member(k):save(v)
+ end
+end
+
+
+M.List = class(M.Collection)
+
+function M.List:init(context, params, dtype)
+ super(self, M.List):init(context, params, dtype or 'list')
+
+ local mt = getmetatable(self)
+
+ local save = mt.save
+ function mt.save(k, v)
+ assert(type(k) == 'number')
+ if v == nil then
+ local len = #mt.members()
+ while k < len do
+ mt.save(k, mt.load(k + 1, {dereference=false}))
+ k = k + 1
+ end
+ end
+ save(k, v)
+ end
+
+ function mt.insert(v, i)
+ local len = #mt.members()
+ if not i then i = len + 1 end
+ for j = len,i,-1 do mt.save(j + 1, mt.load(j, {dereference=false})) end
+ mt.save(i, v)
+ end
+end
+
+
+-- experimental
+M.Mixed = class(M.Collection)
+
+function M.Mixed:init(context, params)
+ super(self, M.Mixed):init(context, params)
+
+ -- TODO dynamic meta: list non-leaf children
+ local mt = getmetatable(self)
+ mt.meta = {type='mixed', ['ui-name']=mt.path}
+ function mt.mmeta(name)
+ return {type='mixed', ['ui-name']=pth.join(mt.path, name)}
+ end
+end
+
+
+local function meta_func(attr)
+ return function(node, ...)
+ local res = getmetatable(node)[attr]
+ if type(res) == 'function' then return res(...) end
+ return res
+ end
+end
+
+for _, mf in ipairs{
+ 'addr',
+ 'contains',
+ 'has_permission',
+ 'insert',
+ 'match',
+ 'meta',
+ 'mmeta',
+ 'name',
+ 'parent',
+ 'path',
+ 'save',
+ 'topology'
+} do M[mf] = meta_func(mf) end
+
+
+function M.pairs(tbl, dereference)
+ if not isinstance(tbl, M.TreeNode) then return pairs(tbl) end
+
+ local mt = getmetatable(tbl)
+
+ local res = {}
+ for _, member in ipairs(mt.members()) do
+ res[member] = mt.load(member, {dereference=dereference})
+ end
+ return pairs(res)
+end
+
+local function _ipairs(mt, i)
+ i = i + 1
+ local v = mt.load(i, {create=false})
+ if v == nil then return end
+ return i, v
+end
+function M.ipairs(tbl)
+ if not isinstance(tbl, M.TreeNode) then return ipairs(tbl) end
+ return _ipairs, getmetatable(tbl), 0
+end
+
+
+return M
diff --git a/aconf/model/permission.lua b/aconf/model/permission.lua
new file mode 100644
index 0000000..e90099b
--- /dev/null
+++ b/aconf/model/permission.lua
@@ -0,0 +1,25 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local node = require('aconf.model.node')
+local start_txn = require('aconf.transaction')
+
+function M.define(path, ...)
+ local txn = start_txn()
+ local db = txn:fetch('/auth/permissions')
+ for _, permission in ipairs{...} do node.insert(db, permission..path) end
+ txn:commit()
+end
+
+function M.defaults(path)
+ M.define(path, 'read', 'create', 'modify', 'delete')
+ for _, action in ipairs(node.meta(start_txn():fetch(path)).actions or {}) do
+ M.define(path, action.name)
+ end
+end
+
+return M
diff --git a/aconf/model/root.lua b/aconf/model/root.lua
new file mode 100644
index 0000000..027b2e2
--- /dev/null
+++ b/aconf/model/root.lua
@@ -0,0 +1,89 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local model = require('aconf.model.model')
+local node = require('aconf.model.node')
+local object = require('aconf.object')
+local pth = require('aconf.path')
+
+local util = require('aconf.util')
+local setdefault = util.setdefault
+
+
+M.RootModel = model.new()
+
+function M.RootModel:init(txn)
+ object.super(self, M.RootModel):init{txn=txn, path='/', addr='/null'}
+end
+
+function M.RootModel:has_permission(user, permission)
+ return permission == 'read'
+end
+
+function M.RootModel:meta(path)
+ local obj = self:fetch(path, true)
+ if object.isinstance(obj, node.TreeNode) then return node.meta(obj) end
+ return node.mmeta(self:fetch(pth.parent(path), true), pth.name(path))
+end
+
+
+local _topology = {}
+local order = 0
+
+function M.topology(addr, create)
+ local top = _topology
+ if type(addr) == 'table' then addr = util.copy(addr)
+ else addr = pth.split(addr) end
+
+ local function defaults(top)
+ return util.setdefaults(top, {members={}, paths={}, referrers={}})
+ end
+
+ while #addr > 0 do
+ if create then
+ top = setdefault(defaults(top).members, addr[1], {order=order})
+ order = order + 1
+ else
+ top = top.members[addr[1]] or top.members[pth.wildcard]
+ if not top then return end
+ end
+ table.remove(addr, 1)
+ end
+
+ return defaults(top)
+end
+
+function M.register(name, field, params)
+ if not params then params = {} end
+ params.create = true
+ M.RootModel[name] = model.to_field(field, params)
+
+ local root = M.RootModel()
+
+ for _, record in ipairs(node.topology(root:fetch(name))) do
+ local top = M.topology(record.addr, true)
+
+ local function set(k, v)
+ setdefault(top, k, v)
+ assert(top[k] == v)
+ end
+
+ set('type', record.type)
+ table.insert(top.paths, record.path)
+
+ if record.scope then
+ local scope = node.addr(
+ root:fetch(pth.to_absolute(record.scope, pth.parent(record.path)))
+ )
+ set('scope', scope)
+ table.insert(M.topology(scope, true).referrers, record.path)
+ end
+ end
+end
+
+
+return M
diff --git a/aconf/model/service.lua b/aconf/model/service.lua
new file mode 100644
index 0000000..6951ffc
--- /dev/null
+++ b/aconf/model/service.lua
@@ -0,0 +1,36 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local fld = require('aconf.model.field')
+local new = require('aconf.model.model').new
+local super = require('aconf.object').super
+local pth = require('aconf.path')
+local store = require('aconf.persistence')
+
+return function(name)
+ local res = new()
+
+ local addr = pth.join('/service', name)
+ local eaddr = pth.join(addr, 'enabled')
+ res.enabled = fld.Boolean{addr=eaddr, required=true}
+ res.status = fld.String{addr=pth.join(addr, 'status'), editable=false}
+
+ local function is_enabled() return store:get(eaddr) end
+ local enabled
+ local function pre() enabled = is_enabled() end
+ local function post()
+ if enabled and is_enabled() then
+ os.execute('rc-service '..name..' reload')
+ end
+ end
+
+ function res:init(context)
+ store:trigger('pre', context.addr, pre)
+ store:trigger('post', context.addr, post)
+ super(self, res):init(context)
+ end
+
+ return res
+ end
diff --git a/aconf/model/set.lua b/aconf/model/set.lua
new file mode 100644
index 0000000..efed851
--- /dev/null
+++ b/aconf/model/set.lua
@@ -0,0 +1,56 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local TreeNode = require('aconf.model.field').TreeNode
+local node = require('aconf.model.node')
+local object = require('aconf.object')
+local pth = require('aconf.path')
+local setdefaults = require('aconf.util').setdefaults
+
+
+M.Set = object.class(require('aconf.model.node').List)
+
+function M.Set:init(context, params)
+ assert(not object.isinstance(params.field, TreeNode))
+ params.field.dereference = false
+
+ object.super(self, M.Set):init(context, params, 'set')
+
+ local function find(value)
+ value = node.BoundMember(
+ self, pth.wildcard, params.field
+ ):normalize(value)
+
+ for i, member in node.pairs(self) do
+ if member == value then return i, value end
+ end
+ end
+
+ local mt = getmetatable(self)
+
+ function mt.get(k, options)
+ options = setdefaults(options or {}, {dereference=true})
+ local i, v = find(k)
+ if i then return mt.load(i, options) end
+ if options.create then return v end
+ end
+
+ function mt.__newindex(t, k, v)
+ assert(v == nil)
+ local i = find(k)
+ if not i then return end
+ mt.save(i, nil)
+ end
+
+ function mt.contains(v) return find(v) and true or false end
+
+ local insert = mt.insert
+ function mt.insert(v) if not mt.contains(v) then insert(v) end end
+end
+
+
+return M
diff --git a/aconf/model/time.lua b/aconf/model/time.lua
new file mode 100644
index 0000000..ca6008d
--- /dev/null
+++ b/aconf/model/time.lua
@@ -0,0 +1,63 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local raise = require('aconf.error').raise
+
+local object = require('aconf.object')
+local class = object.class
+local super = object.super
+
+
+local posix = require('posix')
+
+
+M.Timestamp = class(require('aconf.model.field').String)
+M.Date = class(M.Timestamp)
+M.Time = class(M.Timestamp)
+
+M.Date.format = '%Y-%m-%d'
+M.Time.format = '%H:%M:%S'
+M.Timestamp.format = M.Date.format..' '..M.Time.format
+
+function M.Timestamp:_offset(value)
+ if not value then return end
+ local time = posix.strptime(value, self.format)
+ return time and os.time(time)
+end
+
+function M.Timestamp:_load(context)
+ local value = super(self, M.Timestamp):_load(context)
+ return value and self.epoch_offset and os.date(self.format, value) or value
+end
+
+function M.Timestamp:_save(context, value)
+ if self.epoch_offset then value = self:_offset(value) end
+ super(self, M.Timestamp):_save(context, value)
+end
+
+function M.Timestamp:normalize(context, value)
+ local time = self:_offset(value)
+ return time and os.date(self.format, time) or value
+end
+
+function M.Timestamp:validate(context, value)
+ super(self, M.Timestamp):validate(context, value)
+ if not self:_offset(value) then raise(context.path, 'Invalid value') end
+end
+
+function M.Date:init(params)
+ super(self, M.Date):init(params)
+ self.widget = 'date'
+end
+
+function M.Time:init(params)
+ super(self, M.Time):init(params)
+ if params.seconds == false then self.format = '%H:%M' end
+end
+
+
+return M
diff --git a/aconf/modules/demo-awall.lua b/aconf/modules/demo-awall.lua
new file mode 100644
index 0000000..7d28989
--- /dev/null
+++ b/aconf/modules/demo-awall.lua
@@ -0,0 +1,149 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = require('aconf.model')
+local object = require('aconf.object')
+
+
+local Direction = object.class(M.String)
+function Direction:init(params)
+ if not params then params = {} end
+ params.choice = {'in', 'out'}
+ object.super(self, Direction):init(params)
+end
+
+
+-- TODO reference types?
+
+local IPSet = M.new()
+-- TODO choices
+IPSet.type = M.String{required=true}
+IPSet.family = M.String{required=true, choice={'inet', 'inet6'}}
+-- TODO only for bitmaps
+IPSet.range = M.Range{type=M.net.IPv4Address}
+
+local Service = M.new()
+Service.proto = M.String{required=true, ui_name='Protocol'}
+Service.port = M.Set{type=M.Range{type=M.net.Port}}
+Service.icmp_type = M.String{ui_name='ICMP type'}
+Service.ct_helper = M.String{ui_name='Connection tracking helper'}
+
+-- TODO fw zone
+
+local Zone = M.new()
+Zone.iface = M.Set{type=M.String, ui_name='Interfaces'}
+Zone.addr = M.Set{type=M.String, ui_name='Addresses'}
+Zone.route_back = M.Boolean{default=false}
+
+local LogClass = M.new()
+LogClass.mode = M.String{
+ required=true, default='log', choice={'log', 'nflog', 'ulog'}
+}
+LogClass.every = M.Integer{ui_name='Sampling frequency'}
+LogClass.limit = M.Integer
+LogClass.prefix = M.String
+LogClass.probability = M.Number
+LogClass.group = M.Integer
+LogClass.range = M.Integer
+LogClass.threshold = M.Integer
+
+local IPSetReference = M.new()
+IPSetReference.name = M.Reference{scope='/awall/ipset', required=true}
+IPSetReference.args = M.List{
+ type=Direction, required=true, ui_name='Arguments'
+}
+
+local Rule = M.new()
+Rule['in'] = M.Set{
+ type=M.Reference{scope='/awall/zone'}, ui_name='Ingress zones'
+}
+Rule.out = M.Set{
+ type=M.Reference{scope='/awall/zone'}, ui_name='Egress zones'
+}
+Rule.src = M.Set{type=M.String, ui_name='Sources'}
+Rule.dest = M.Set{type=M.String, ui_name='Destinations'}
+Rule.ipset = M.Model{model=IPSetReference, ui_name='IP set'}
+Rule.ipsec = Direction{ui_name='Require IPsec'}
+Rule.service = M.Set{type=M.Reference{scope='/awall/service'}}
+Rule.action = M.String{choice={'accept'}}
+
+
+local PacketLogRule = M.new(Rule)
+PacketLogRule.log = M.Reference{scope='../../log', ui_name='Log class'}
+
+-- TODO no service field
+local PolicyRule = M.new(PacketLogRule)
+PolicyRule.action = M.String{
+ required=true, choice={'accept', 'drop', 'reject', 'tarpit'}
+}
+
+local Limit = M.new()
+Limit.count = M.Integer
+Limit.interval = M.Integer
+Limit.log = M.Reference{scope='../../../log'}
+
+local FilterRule = M.new(PolicyRule)
+FilterRule.conn_limit = M.Model{model=Limit, ui_name='Connection limit'}
+FilterRule.flow_limit = M.Model{model=Limit, ui_name='Flow limit'}
+FilterRule.dnat = M.net.IPv4Address{ui_name='DNAT target'}
+FilterRule.no_track = M.Boolean{default=false, ui_name='CT bypass'}
+FilterRule.related = M.List{type=Rule, ui_name='Related packet rules'}
+
+local DivertRule = M.new(Rule)
+DivertRule.to_port = M.Range{type=M.net.Port, ui_name='Target port'}
+
+local NATRule = M.new(DivertRule)
+NATRule.to_addr = M.Range{type=M.net.IPv4Address, ui_name='Target address'}
+
+local MarkRule = M.new(Rule)
+MarkRule.mark = M.Integer{required=true}
+
+local ClampMSSRule = M.new(Rule)
+ClampMSSRule.mss = M.Integer{ui_name='MSS'}
+
+
+local AWall = M.new()
+-- TODO differentiate lists?
+AWall.service = M.Collection{type=M.List{type=Service}}
+AWall.zone = M.Collection{type=Zone}
+AWall.log = M.Collection{
+ type=LogClass, ui_name='Log classes', ui_member='Log class'
+}
+AWall.policy = M.List{type=PolicyRule, ui_name='Policies', ui_member='Policy'}
+AWall.packet_log = M.List{
+ type=PacketLogRule, ui_name='Logging', ui_member='Logging rule'
+}
+AWall.filter = M.List{type=FilterRule}
+AWall.dnat = M.List{type=NATRule, ui_name='DNAT', ui_member='DNAT rule'}
+AWall.snat = M.List{type=NATRule, ui_name='SNAT', ui_member='SNAT rule'}
+AWall.mark = M.List{
+ type=MarkRule, ui_name='Packet marking', ui_member='Packet marking rule'
+}
+AWall.route_track = M.List{
+ type=MarkRule, ui_name='Route tracking', ui_member='Route tracking rule'
+}
+AWall.tproxy = M.List{
+ type=DivertRule,
+ ui_name='Transparent proxy',
+ ui_member='Transparent proxy rule'
+}
+AWall.clamp_mss = M.List{
+ type=ClampMSSRule, ui_name='MSS clamping', ui_member='MSS clamping rule'
+}
+AWall.no_track = M.List{
+ type=Rule, ui_name='CT bypass', ui_member='Connection tracking bypass rule'
+}
+AWall.ipset = M.Collection{type=IPSet, ui_name='IP sets', ui_member='IP set'}
+
+M.register(
+ 'awall',
+ AWall,
+ {
+ addr='/json'..require('posix').getcwd()..'/config/awall.json',
+ ui_name='Alpine Wall'
+ }
+)
+
+M.permission.defaults('/awall')
diff --git a/aconf/modules/demo-generic.lua b/aconf/modules/demo-generic.lua
new file mode 100644
index 0000000..4e602fa
--- /dev/null
+++ b/aconf/modules/demo-generic.lua
@@ -0,0 +1,14 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+-- provided as an example, to be removed from production version
+
+local M = require('aconf.model')
+
+M.register('proc', M.Mixed, {addr='/files/proc', ui_name='/proc'})
+M.permission.defaults('/proc')
+
+M.register('augeas', M.Mixed, {addr='/augeas'})
+M.permission.defaults('/augeas')
diff --git a/aconf/modules/network.lua b/aconf/modules/network.lua
new file mode 100644
index 0000000..ad772ed
--- /dev/null
+++ b/aconf/modules/network.lua
@@ -0,0 +1,281 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = require('aconf.model')
+
+local posix = require('posix')
+
+
+local Host = M.new()
+Host.address = M.net.IPAddress{addr='ipaddr'}
+Host.canonical = M.String{ui_name='Canonical name'}
+Host.alias = M.Set{
+ type=M.String,
+ addr='alias/#',
+ ui_name='Aliases',
+ ui_member='Alias',
+ detail=false
+}
+
+local Resolv = M.new()
+Resolv.servers = M.List{
+ type=M.net.IPAddress, addr='nameserver/#', widget='inline'
+}
+Resolv.search_domains = M.List{
+ type=M.String, addr='search/domain/#', widget='inline'
+}
+
+
+local iface_aug_addr = '/augeas/etc/network/interfaces'
+local iface_sys_dir = '/sys/class/net'
+local vlan_pattern = '^(.+)%.(%d+)$'
+
+for _, trigger in ipairs{
+ {phase='pre', action='stop'}, {phase='post', action='start'}
+} do
+ M.trigger(
+ trigger.phase,
+ iface_aug_addr,
+ function() os.execute('rc-service networking '..trigger.action) end
+ )
+end
+
+M.defer(iface_aug_addr)
+
+
+-- TODO allow multiple addresses of same family
+
+local IPv4 = M.new()
+IPv4.method = M.String{
+ required=true,
+ choice={
+ {'dhcp', 'DHCP'},
+ {'loopback', enabled=false},
+ {'unconfigured', be_value='manual'},
+ 'static'
+ },
+ default='unconfigured'
+}
+IPv4.address = M.net.IPv4Address{
+ condition={method='static'}, required=true, cidr=true, mask_addr='netmask'
+}
+IPv4.gateway = M.net.IPv4Address{condition={method='static'}}
+
+
+local IPv6 = M.new()
+IPv6.method = M.String{
+ required=true,
+ choice={
+ {'loopback', enabled=false}, {'unconfigured', be_value='manual'}, 'static'
+ },
+ default='unconfigured'
+}
+IPv6.address = M.net.IPv6Address{
+ condition={method='static'}, required=true, cidr=true, mask_addr='netmask'
+}
+IPv6.gateway = M.net.IPv6Address{condition={method='static'}}
+
+
+
+local Interface = M.new()
+
+function Interface:validate()
+ if self.status == 'attached' then
+ for _, version in ipairs{4, 6} do
+ self['ipv'..version].method =
+ self.class == 'loopback' and 'loopback' or 'unconfigured'
+ end
+ end
+end
+
+function Interface:is_removable() return self.class == 'logical' end
+
+function Interface:auto_set()
+ for _, set in M.node.ipairs(self:fetch('../../enabled-ifaces')) do
+ if M.node.contains(set, self) then return set end
+ end
+end
+
+function Interface:auto_vlan_tag()
+ local name = M.node.name(self)
+ local _, tag = name:match(vlan_pattern)
+ if tag then return tag end
+ return name:match('^vlan(%d+)$')
+end
+
+Interface.enabled = M.Boolean{
+ compute=function(self, iface) return iface:auto_set() and true or false end,
+ store=function(self, iface, value)
+ local set = iface:auto_set()
+ if value and not set then
+ M.node.insert(iface:fetch('../../enabled-ifaces/1'), iface)
+ elseif not value and set then set[iface] = nil end
+ end
+}
+
+Interface.class = M.String{
+ compute=function(self, iface, txn)
+ local name = M.node.name(iface)
+ if name == 'lo' then return 'loopback' end
+
+ local saddr = M.path.rawjoin('/files', iface_sys_dir, name, '.')
+ if not txn:get(saddr) then return 'logical' end
+
+ for _, addr in ipairs{
+ {saddr, 'bonding'},
+ {saddr, 'bridge'},
+ {'/files/proc/net/vlan', name}
+ } do
+ if txn:get(M.path.join(unpack(addr))) then
+ return 'logical'
+ end
+ end
+ return 'physical'
+ end,
+ choice={'loopback', 'logical', 'physical'}
+}
+
+Interface.type = M.String{
+ condition={class='logical'},
+ compute=function(self, iface)
+ if #iface.slaves > 0 then return 'bond' end
+ if #iface.ports > 0 then return 'bridge' end
+ if iface.vlan_tag then return 'vlan' end
+ end,
+ editable=function(self, iface) return not iface:auto_vlan_tag() end,
+ required=true,
+ choice={'bond', 'bridge', {'vlan', 'VLAN'}}
+}
+
+Interface.status = M.String{
+ visible=false,
+ compute=function(self, obj)
+ if obj.class == 'loopback' then return 'attached' end
+
+ for _, iface in M.node.pairs(M.node.parent(obj)) do
+ if (
+ iface.type == 'bond' and M.node.contains(iface.slaves, obj)
+ ) or (
+ iface.type == 'bridge' and M.node.contains(iface.ports, obj)
+ ) then
+ return 'attached'
+ end
+
+ if iface.type == 'vlan' and iface.trunk == obj then
+ return 'configured'
+ end
+ end
+
+ for _, version in ipairs{4, 6} do
+ if obj['ipv'..version].method ~= 'unconfigured' then
+ return 'configured'
+ end
+ end
+
+ return 'detached'
+ end
+}
+
+Interface.slaves = M.Set{
+ condition={type='bond'},
+ type=M.Reference{scope='../..', filter={status='detached'}},
+ required=true,
+ addr='@family/link/@method/none/bond-slaves'
+}
+Interface.ports = M.Set{
+ condition={type='bridge'},
+ type=M.Reference{scope='../..', filter={status='detached'}},
+ required=true,
+ addr='@family/link/@method/none/bridge-ports'
+}
+-- TODO do not allow VLAN creation for non-existent interfaces
+Interface.trunk = M.Reference{
+ condition={type='vlan'},
+ compute=function(self, iface)
+ local trunk = M.node.name(iface):match(vlan_pattern)
+ if trunk and iface:fetch('..')[trunk] then return trunk end
+ end,
+ required=true,
+ scope='..',
+ filter={status={'detached', 'configured'}},
+ addr='@family/link/@method/none/vlan-raw-device'
+}
+-- TODO ensure that (trunk, tag) is unique
+Interface.vlan_tag = M.Integer{
+ condition={type='vlan'},
+ compute='auto_vlan_tag',
+ required=true,
+ min=0,
+ max=4095,
+ addr='@family/link/@method/none/vlan-id',
+ ui_name='VLAN tag'
+}
+
+Interface.ipv4 = M.Model{
+ model=IPv4,
+ condition={status={'detached', 'configured'}},
+ create=true,
+ addr='@family/inet',
+ ui_name='IPv4 configuration',
+ widget='inline'
+}
+Interface.ipv6 = M.Model{
+ model=IPv6,
+ condition={status={'detached', 'configured'}},
+ create=true,
+ addr='@family/inet6',
+ ui_name='IPv6 configuration',
+ widget='inline'
+}
+
+Interface.stats = M.Collection{
+ type=M.Number{editable=false},
+ editable=false,
+ addr=function(path)
+ return M.path.join(
+ '/files/sys/class/net', M.path.name(path), 'statistics'
+ )
+ end,
+ ui_name='Statistics',
+ ui_member='',
+ widget='inline'
+}
+
+
+local Net = M.new()
+Net.host_name = M.String{addr='/augeas/etc/hostname/hostname'}
+Net.hosts = M.List{type=Host, addr='/augeas/etc/hosts'}
+Net.resolver = M.Model{
+ model=Resolv, addr='/augeas/etc/resolv.conf', ui_name='DNS resolver'
+}
+
+Net.enabled_ifaces = M.List{
+ type=M.Set{type=M.Reference{scope='../../interfaces', on_delete='set-null'}},
+ visible=false,
+ addr=iface_aug_addr..'/auto/#'
+}
+Net.interfaces = M.Collection{
+ type=Interface, addr=iface_aug_addr..'/iface/@', widget='inline'
+}
+
+M.register('net', Net, {ui_name='Network'})
+M.permission.defaults('/net')
+
+return function(txn)
+ local ifaces = txn:fetch('/net/interfaces')
+ for _, name in ipairs(posix.dir(iface_sys_dir)) do
+ if not ifaces[name] and posix.stat(
+ M.path.join(iface_sys_dir, name), 'type'
+ ) == 'link' then
+ ifaces[name] = {}
+ if ifaces[name].class == 'logical' then ifaces[name] = nil
+ else
+ for _, version in ipairs{4, 6} do
+ ifaces[name]['ipv'..version].method = 'unconfigured'
+ end
+ end
+ end
+ end
+ end
diff --git a/aconf/modules/openssh.lua b/aconf/modules/openssh.lua
new file mode 100644
index 0000000..5ca2544
--- /dev/null
+++ b/aconf/modules/openssh.lua
@@ -0,0 +1,28 @@
+--[[
+Copyright (c) 2013 Natanael Copa <ncopa@alpinelinux.org>
+Copyright (c) 2013-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = require('aconf.model')
+
+local Sshd = M.service('sshd')
+Sshd.root_login = M.Boolean{
+ addr='PermitRootLogin', ui_name='Permit root login', default=true
+}
+Sshd.password_auth = M.Boolean{
+ addr='PasswordAuthentication',
+ ui_name='Password authentication',
+ default=true
+}
+Sshd.use_dns = M.Boolean{addr='UseDNS', ui_name='Use DNS', default=true}
+Sshd.agent_forward = M.Boolean{
+ addr='AllowAgentForwarding', ui_name='Allow agent forwarding', default=true
+}
+
+M.register(
+ 'sshd',
+ Sshd,
+ {addr='/augeas/etc/ssh/sshd_config', ui_name='SSH daemon'}
+)
+M.permission.defaults('/sshd')
diff --git a/aconf/object.lua b/aconf/object.lua
new file mode 100644
index 0000000..64fbb9c
--- /dev/null
+++ b/aconf/object.lua
@@ -0,0 +1,68 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+function M.class(base)
+ local cls = {}
+
+ local mt = {
+ __call=function(self, ...)
+ local obj = {}
+ setmetatable(obj, {__index=cls, class=cls})
+ obj:init(...)
+ return obj
+ end
+ }
+
+ if not base and M.Object then base = M.Object end
+ if base then
+ cls._base = base
+ mt.__index = base
+ end
+
+ return setmetatable(cls, mt)
+end
+
+
+M.Object = M.class()
+
+function M.Object:init(...) end
+
+
+function M.super(obj, cls)
+ assert(M.isinstance(obj, cls))
+
+ local mt = {}
+
+ function mt.__index(t, k)
+ local v = cls._base[k]
+ if type(v) ~= 'function' then return v end
+ return function(...)
+ local arg = {...}
+ arg[1] = obj
+ return v(unpack(arg))
+ end
+ end
+
+ return setmetatable({}, mt)
+end
+
+
+function M.issubclass(cls1, cls2)
+ if cls1 == cls2 then return true end
+ if cls1._base then return M.issubclass(cls1._base, cls2) end
+ return false
+end
+
+function M.isinstance(obj, cls)
+ if not obj or type(obj) ~= 'table' then return false end
+ local mt = getmetatable(obj)
+ if not mt then return false end
+ return type(obj) == 'table' and mt.class and M.issubclass(mt.class, cls)
+end
+
+
+return M
diff --git a/aconf/path.lua b/aconf/path.lua
new file mode 100644
index 0000000..8f0bd14
--- /dev/null
+++ b/aconf/path.lua
@@ -0,0 +1,119 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local map = require('aconf.util').map
+
+
+M.up = {}
+M.wildcard = {}
+local special = {['..']=M.up, ['*']=M.wildcard}
+
+
+function M.is_absolute(path) return path:sub(1, 1) == '/' end
+
+
+function M.escape(comp)
+ for symbol, item in pairs(special) do
+ if comp == item then return symbol end
+ end
+ if type(comp) == 'number' then return tostring(comp) end
+ local res = comp:gsub('([\\/])', '\\%1')
+ return (special[res] or tonumber(res)) and '\\'..res or res
+end
+
+
+function M.rawjoin(p1, p2, ...)
+ if not p2 then return p1 end
+ if not M.is_absolute(p2) then p2 = '/'..p2 end
+ return M.rawjoin((p1 == '/' and '' or p1)..p2, ...)
+end
+
+function M.join(parent, ...)
+ return M.rawjoin(parent, unpack(map(M.escape, {...})))
+end
+
+
+function M.split(path)
+ local res = {}
+ local comp = ''
+ local escaped
+
+ local function merge(s)
+ if s > '' then
+ table.insert(res, not escaped and (special[s] or tonumber(s)) or s)
+ end
+ end
+
+ while true do
+ local prefix, sep, suffix = path:match('([^\\/]*)([\\/])(.*)')
+ if not prefix then
+ merge(comp..path)
+ return res
+ end
+
+ comp = comp..prefix
+ if sep == '\\' then
+ comp = comp..suffix:sub(1, 1)
+ escaped = true
+ path = suffix:sub(2, -1)
+ else
+ merge(comp)
+ comp = ''
+ escaped = false
+ path = suffix
+ end
+ end
+end
+
+
+function M.is_unique(path)
+ for _, comp in ipairs(M.split(path)) do
+ if comp == M.wildcard then return false end
+ end
+ return true
+end
+
+function M.is_subordinate(p1, p2)
+ p1 = M.split(p1)
+ for i, comp in ipairs(M.split(p2)) do
+ if p1[i] ~= comp then return false end
+ end
+ return true
+end
+
+
+function M.to_absolute(path, base)
+ if not M.is_absolute(path) then
+ path = base..(base ~= '/' and '/' or '')..path
+ end
+ local comps = M.split(path)
+ local i = 1
+ while i <= #comps do
+ if comps[i] == M.up then
+ if i == 1 then error('Invalid path: '..path) end
+ table.remove(comps, i - 1)
+ table.remove(comps, i - 1)
+ i = i - 1
+ else i = i + 1 end
+ end
+ return M.join('/', unpack(comps))
+end
+
+
+function M.parent(path)
+ local comps = M.split(path)
+ table.remove(comps)
+ return M.join('/', unpack(comps))
+end
+
+function M.name(path)
+ local comps = M.split(path)
+ return comps[#comps]
+end
+
+
+return M
diff --git a/aconf/persistence/backends/augeas.lua b/aconf/persistence/backends/augeas.lua
new file mode 100644
index 0000000..a448e65
--- /dev/null
+++ b/aconf/persistence/backends/augeas.lua
@@ -0,0 +1,273 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local topology = require('aconf.model.root').topology
+local class = require('aconf.object').class
+local pth = require('aconf.path')
+local tostr = require('aconf.persistence.util').tostring
+
+local util = require('aconf.util')
+local copy = util.copy
+
+
+local stringy = require('stringy')
+
+
+local function array_join(tbl, value)
+ local res = copy(tbl)
+ table.insert(res, value)
+ return res
+end
+
+local function array_without_last(tbl)
+ local res = copy(tbl)
+ res[#res] = nil
+ return res
+end
+
+
+local function basename(path)
+ local res, pred = path:match('^.*/([#%w._-]+)([^/]*)$')
+ assert(res)
+ assert(res ~= '#')
+ assert(pred == '' or pred:match('^%[.+%]$'))
+ return res
+end
+
+local function append_pred(path, pred) return path..'['..pred..']' end
+
+
+local function key_mode(mode) return mode and stringy.startswith(mode, '@') end
+
+local function key(mode)
+ assert(key_mode(mode))
+ return mode == '@' and '.' or mode:sub(2, -1)
+end
+
+local function append_key_pred(path, mode, value)
+ return append_pred(path, key(mode).." = '"..value.."'")
+end
+
+
+local function conv_path(path)
+ local res = '/files'
+ if #path == 0 then return res, nil, {} end
+
+ path = copy(path)
+ local mode
+ local keys = {}
+
+ repeat
+ local comp = path[1]
+
+ if comp == '#' or key_mode(comp) then
+ assert(not mode)
+ mode = comp
+ elseif not mode then
+ res = res..'/'..comp
+ keys = {}
+ else
+ if mode == '#' then
+ assert(type(comp) == 'number')
+ res = append_pred(res, comp)
+ else
+ assert(type(comp) == 'string' and comp:match('^[%w %_-%.]+$'))
+ res = append_key_pred(res, mode, comp)
+ table.insert(keys, key(mode))
+ end
+ mode = nil
+ end
+
+ table.remove(path, 1)
+ until #path == 0
+
+ return res, mode, keys
+end
+
+
+local function aug_top(path)
+ path = copy(path)
+ table.insert(path, 1, 'augeas')
+ return topology(path)
+end
+
+
+local backend = class()
+
+function backend:init() self.aug = require('augeas').init() end
+
+function backend:get(path, top)
+ local apath, mode, keys = conv_path(path)
+ local existence = top == true
+ if existence or not top then top = aug_top(path) end
+
+ local tpe = top and top.type
+ local leaf = tpe ~= 'table' and not mode
+
+ local matches = self.aug:match(apath..(not leaf and not mode and '/*' or ''))
+
+ if #matches == 0 and not mode then return end
+
+ if mode and #path > 1 and not self:get(
+ array_without_last(array_without_last(path)), true
+ ) then
+ return
+ end
+
+ if not tpe and not mode then
+ if #matches > 1 then
+ leaf = false
+ mode = '#'
+ else
+ local children = self.aug:match(apath..'/*')
+ if #children > 0 then
+ leaf = false
+ matches = children
+ end
+ end
+ end
+
+ if leaf then
+ assert(#matches == 1)
+ return self.aug:get(apath)
+ end
+
+ if existence then return true end
+
+ local names = {}
+
+ for i, child in ipairs(matches) do
+ local name
+ if not mode then
+ name = basename(child)
+ name = tonumber(name) or name
+ if util.contains(keys, name) then name = nil end
+ elseif mode == '#' then name = i
+ else
+ name = self.aug:get(child..(mode == '@' and '' or '/'..key(mode)))
+ end
+
+ if name and self:get(array_join(path, name), true) then
+ names[name] = true
+ end
+ end
+
+ return util.keys(names)
+end
+
+function backend:set(mods)
+ local gc = {}
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+
+ local function insert(path, new)
+ local apath, mode, keys = conv_path(path)
+ if mode then path[#path] = nil end
+ if #path == 0 then return apath, keys end
+ local name = path[#path]
+
+ local parent = array_without_last(path)
+ local ppath, pmode = conv_path(parent)
+
+ if pmode then
+ gc[pth.join(unpack(array_without_last(parent)))] = false
+ end
+
+ if pmode == '#' then
+ local count = #self.aug:match(ppath)
+ while count < name do
+ insert(parent, true)
+ count = count + 1
+ end
+ return apath, keys
+ end
+
+ local matches = self.aug:match(apath)
+ local count = #matches
+
+ if count > 0 and not new then return apath, keys end
+
+ if key_mode(pmode) then
+ apath = pmode == '@' and append_key_pred(
+ ppath, '@', ''
+ ) or append_pred(ppath, 'count('..key(pmode)..') = 0')
+
+ matches = self.aug:match(apath)
+ assert(#matches < 2)
+ apath = matches[1] or insert(parent, true)
+
+ local key = key(pmode)
+ self.aug:set(apath..'/'..key, name)
+
+ return apath, keys
+ end
+
+ if #matches == 0 then
+ matches = self.aug:match(ppath..'/*')
+
+ local function order(path)
+ local top = aug_top(path)
+ return top and top.order
+ end
+ local ord = order(path)
+
+ for _, sibling in ipairs(matches) do
+ local sord = order(array_join(parent, basename(sibling)))
+ if sord and sord > ord then
+ self.aug:insert(sibling, name, true)
+ return apath, keys
+ end
+ end
+ end
+
+ if #matches == 0 then
+ if new then self.aug:set(apath, nil) end
+ return apath, keys
+ end
+
+ self.aug:insert(matches[#matches], name)
+ return append_pred(apath, count + 1), keys
+ end
+
+ local apath, keys = insert(path)
+ local is_table = type(value) == 'table'
+
+ if not (is_table or util.contains(keys, '.')) then
+ self.aug:set(apath, value ~= nil and tostr(value) or nil)
+ end
+
+ util.setdefault(gc, pth.join(unpack(path)), true)
+ end
+
+ for path, _ in pairs(gc) do
+ local p = pth.split(path)
+ while #p > 0 do
+ local value = self:get(p)
+
+ if (
+ type(value) == 'string' and value ~= ''
+ ) or (
+ type(value) == 'table' and #value > 0
+ ) then
+ break
+ end
+
+ if gc[pth.join(unpack(p))] ~= false then self.aug:rm(conv_path(p)) end
+ p[#p] = nil
+ end
+ end
+
+ if self.aug:save() ~= 0 then
+ print('Augeas save failed')
+ for _, ep in ipairs(self.aug:match('/augeas//error')) do
+ print(ep, self.aug:get(ep))
+ end
+ assert(false)
+ end
+end
+
+
+return backend
diff --git a/aconf/persistence/backends/files.lua b/aconf/persistence/backends/files.lua
new file mode 100644
index 0000000..e19763d
--- /dev/null
+++ b/aconf/persistence/backends/files.lua
@@ -0,0 +1,107 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local topology = require('aconf.model.root').topology
+local pth = require('aconf.path')
+local util = require('aconf.persistence.util')
+local copy = require('aconf.util').copy
+
+local posix = require('posix')
+local stringy = require('stringy')
+
+
+local function get_scope(top)
+ if not top or top.type ~= 'reference' or not pth.is_unique(top.scope) then
+ return
+ end
+
+ return stringy.startswith(
+ top.scope, '/files/'
+ ) and top.scope:sub(7, -1) or nil
+end
+
+
+local backend = require('aconf.object').class()
+
+-- TODO cache expiration
+function backend:init() self.cache = {} end
+
+function backend:get(path, top)
+ local name = pth.join('/', unpack(path))
+
+ if not self.cache[name] then
+ local t = posix.stat(name, 'type')
+ if not t then return end
+
+ if t == 'regular' then
+ self.cache[name] = util.read_file(name)
+
+ elseif t == 'link' then
+ -- TODO handle relative symlinks
+ local target = posix.readlink(name)
+ assert(target)
+
+ local scope = get_scope(top)
+ assert(scope)
+ scope = scope..'/'
+
+ local slen = scope:len()
+ assert(target:sub(1, slen) == scope)
+ return target:sub(slen + 1, -1)
+
+ elseif t == 'directory' then
+ local res = {}
+ for _, fname in ipairs(posix.dir(name)) do
+ if not ({['.']=true, ['..']=true})[fname] then
+ table.insert(res, pth.name(fname))
+ end
+ end
+ return res
+
+ else error('Unsupported file type: '..name) end
+ end
+
+ return self.cache[name]
+end
+
+function backend:set(mods)
+ for _, mod in pairs(mods) do
+ local path, value = unpack(mod)
+ local name = pth.join('/', unpack(path))
+
+ if value == nil then
+ print('DEL', name)
+
+ local t = posix.stat(name, 'type')
+ if t == 'directory' then
+ assert(posix.rmdir(name))
+ elseif t then assert(os.remove(name)) end
+
+ self.cache[name] = nil
+
+ elseif type(value) == 'table' then
+ assert(posix.mkdir(name))
+
+ else
+ local scope = get_scope(topology('/files'..name))
+
+ if scope then
+ -- TODO use relative symlink
+ os.remove(name)
+ assert(posix.link(pth.to_absolute(value, scope), name, true))
+
+ else
+ local file = util.open_file(name, 'w')
+ file:write(util.tostring(value))
+ file:close()
+
+ self.cache[name] = value
+ end
+ end
+ end
+end
+
+
+return backend
diff --git a/aconf/persistence/backends/json.lua b/aconf/persistence/backends/json.lua
new file mode 100644
index 0000000..607315a
--- /dev/null
+++ b/aconf/persistence/backends/json.lua
@@ -0,0 +1,79 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local pth = require('aconf.path')
+local Cache = require('aconf.persistence.backends.volatile')
+local util = require('aconf.persistence.util')
+local copy = require('aconf.util').copy
+
+local json = require('cjson')
+local posix = require('posix')
+
+
+local backend = require('aconf.object').class()
+
+function backend:init()
+ -- TODO cache expiration
+ self.cache = {}
+ self.dirty = {}
+end
+
+function backend:split_path(path)
+ local fpath = copy(path)
+ local jpath = {}
+ local res
+
+ while #fpath > 0 do
+ local fp = pth.join('/', unpack(fpath))
+ if self.cache[fp] then return fp, jpath end
+ table.insert(jpath, 1, fpath[#fpath])
+ table.remove(fpath)
+ end
+
+ fpath = '/'
+
+ while true do
+ fpath = pth.join(fpath, jpath[1])
+ table.remove(jpath, 1)
+
+ local t = posix.stat(fpath, 'type')
+ if t == 'link' then t = posix.stat(posix.readlink(fpath), 'type') end
+ if not t or not ({directory=true, regular=true})[t] then
+ error('File or directory does not exist: '..fpath)
+ end
+
+ if t == 'regular' then return fpath, jpath end
+
+ assert(#jpath > 0)
+ end
+end
+
+function backend:get(path, top)
+ local fpath, jpath = self:split_path(path)
+ if not self.cache[fpath] then
+ self.cache[fpath] = Cache(json.decode(util.read_file(fpath)))
+ end
+ return self.cache[fpath]:get(jpath, top)
+end
+
+function backend:set(mods)
+ local dirty = {}
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+ local fpath, jpath = self:split_path(path)
+ self.cache[fpath]:_set(jpath, value)
+ dirty[fpath] = true
+ end
+
+ for path, _ in pairs(dirty) do
+ local file = util.open_file(path, 'w')
+ file:write(json.encode(self.cache[path]:_get{}))
+ file:close()
+ end
+end
+
+
+return backend
diff --git a/aconf/persistence/backends/null.lua b/aconf/persistence/backends/null.lua
new file mode 100644
index 0000000..770e5e8
--- /dev/null
+++ b/aconf/persistence/backends/null.lua
@@ -0,0 +1,10 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local backend = require('aconf.object').class()
+function backend:get(path, top) if #path == 0 then return {} end end
+function backend:set(mods) end
+
+return backend
diff --git a/aconf/persistence/backends/service.lua b/aconf/persistence/backends/service.lua
new file mode 100644
index 0000000..4569ce8
--- /dev/null
+++ b/aconf/persistence/backends/service.lua
@@ -0,0 +1,32 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local rc = require('rc')
+local stringy = require('stringy')
+
+
+local backend = require('aconf.object').class()
+
+function backend:get(path, top)
+ if #path == 1 then return {'enabled', 'status'} end
+ assert(#path == 2)
+ local status = rc.service_status(path[1])
+ if path[2] == 'status' then return status end
+ if path[2] == 'enabled' then return stringy.startswith(status, 'start') end
+end
+
+function backend:set(mods)
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+ assert(#path == 2 and path[2] == 'enabled')
+
+ local name = path[1]
+ if value then rc.service_add(name)
+ else rc.service_delete(name) end
+ os.execute('rc-service '..name..' '..(value and 'start' or 'stop'))
+ end
+end
+
+return backend
diff --git a/aconf/persistence/backends/volatile.lua b/aconf/persistence/backends/volatile.lua
new file mode 100644
index 0000000..7016a9b
--- /dev/null
+++ b/aconf/persistence/backends/volatile.lua
@@ -0,0 +1,46 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local util = require('aconf.util')
+
+
+local backend = require('aconf.object').class()
+
+function backend:init(data) self.data = data or {} end
+
+function backend:_get(path)
+ local res = self.data
+ for _, comp in ipairs(path) do
+ if res == nil then return end
+ assert(type(res) == 'table')
+ res = res[comp]
+ end
+ return res
+end
+
+function backend:get(path, top)
+ local res = self:_get(path)
+ return type(res) == 'table' and util.keys(res) or res
+end
+
+function backend:_set(path, value)
+ if type(value) == 'table' then value = {} end
+
+ if #path == 0 then self.data = value
+
+ else
+ local comps = util.copy(path)
+ local name = comps[#comps]
+ table.remove(comps)
+ self:_get(comps)[name] = value
+ end
+end
+
+function backend:set(mods)
+ for _, mod in ipairs(mods) do self:_set(unpack(mod)) end
+end
+
+
+return backend
diff --git a/aconf/persistence/defer.lua b/aconf/persistence/defer.lua
new file mode 100644
index 0000000..5db5a21
--- /dev/null
+++ b/aconf/persistence/defer.lua
@@ -0,0 +1,47 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local object = require('aconf.object')
+local super = object.super
+
+local pth = require('aconf.path')
+
+
+local DeferringCommitter = object.class(
+ require('aconf.transaction.base').Transaction
+)
+
+function DeferringCommitter:init(backend)
+ super(self, DeferringCommitter):init(backend)
+ self.defer_paths = {}
+ self.committed = true
+end
+
+function DeferringCommitter:defer(path) self.defer_paths[path] = true end
+
+function DeferringCommitter:_set_multiple(mods)
+ super(self, DeferringCommitter):_set_multiple(mods)
+
+ if not self.committed then return end
+ self.committed = false
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+ while path > '/' do
+ if self.defer_paths[path] then return end
+ path = pth.parent(path)
+ end
+ end
+
+ self:commit()
+end
+
+function DeferringCommitter:commit()
+ super(self, DeferringCommitter):commit()
+ self.committed = true
+end
+
+
+return DeferringCommitter(require('aconf.persistence'))
diff --git a/aconf/persistence/init.lua b/aconf/persistence/init.lua
new file mode 100644
index 0000000..991776a
--- /dev/null
+++ b/aconf/persistence/init.lua
@@ -0,0 +1,113 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local loadmods = require('aconf.loader')
+local topology = require('aconf.model.root').topology
+local object = require('aconf.object')
+local pth = require('aconf.path')
+
+local util = require('aconf.util')
+local contains = util.contains
+local setdefault = util.setdefault
+
+local stringy = require('stringy')
+
+
+local DataStore = object.class(
+ require('aconf.transaction.base').TransactionBackend
+)
+
+function DataStore:init()
+ object.super(self, DataStore):init()
+ self.backends = util.map(
+ function(m) return m() end,
+ loadmods('persistence/backends')
+ )
+ self.triggers = {pre={}, post={}}
+end
+
+function DataStore:trigger(phase, path, func)
+ local funcs = setdefault(self.triggers[phase], path, {})
+ if not contains(funcs, func) then table.insert(funcs, func) end
+end
+
+function DataStore:split_path(path)
+ local comps = pth.split(path)
+ local backend = self.backends[comps[1]]
+ assert(backend)
+ table.remove(comps, 1)
+ return backend, comps
+end
+
+function DataStore:get(path)
+ local backend, comps = self:split_path(path)
+ local top = topology(path)
+
+ local res = backend:get(comps, top)
+
+ if top then
+ local t = top.type
+ if t and res ~= nil then
+ local atype = type(res)
+
+ if t == 'table' then assert(atype == 'table')
+
+ else
+ assert(atype ~= 'table')
+
+ if t == 'string' then res = tostring(res)
+ elseif t == 'number' then res = tonumber(res)
+
+ elseif t == 'boolean' then
+ if atype == 'string' then res = res:lower() end
+ if res == 1 or contains({'1', 't', 'true', 'y', 'yes'}, res) then
+ res = true
+ elseif res == 0 or contains(
+ {'0', 'f', 'false', 'n', 'no'}, res
+ ) then
+ res = false
+ else res = res and true or false end
+
+ elseif contains({'binary', 'reference'}, t) then
+ assert(atype == 'string')
+
+ else assert(false) end
+ end
+ end
+ end
+
+ return util.copy(res), self.mod_time[path] or 0
+end
+
+function DataStore:_set_multiple(mods)
+ local bms = {}
+ local trigger = {}
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+
+ local tp = path
+ while not trigger[tp] do
+ trigger[tp] = true
+ tp = pth.parent(tp)
+ end
+
+ local backend, comps = self:split_path(path)
+ table.insert(setdefault(bms, backend, {}), {comps, value})
+ end
+
+ local function exec_triggers(phase)
+ for path, _ in pairs(trigger) do
+ for _, func in ipairs(self.triggers[phase][path] or {}) do func() end
+ end
+ end
+
+ exec_triggers('pre')
+ for backend, bm in pairs(bms) do backend:set(bm) end
+ exec_triggers('post')
+end
+
+
+return DataStore()
diff --git a/aconf/persistence/util.lua b/aconf/persistence/util.lua
new file mode 100644
index 0000000..d233b1d
--- /dev/null
+++ b/aconf/persistence/util.lua
@@ -0,0 +1,27 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+function M.open_file(path, mode)
+ local file = io.open(path, mode)
+ if not file then error('Cannot open file: '..path) end
+ return file
+end
+
+function M.read_file(path)
+ local file = M.open_file(path)
+ local data = file:read('*all')
+ file:close()
+ return data
+end
+
+function M.tostring(value)
+ -- TODO make values configurable per address
+ if type(value) == 'boolean' then return value and 'yes' or 'no' end
+ return tostring(value)
+end
+
+return M
diff --git a/aconf/transaction/base.lua b/aconf/transaction/base.lua
new file mode 100644
index 0000000..9df0bc2
--- /dev/null
+++ b/aconf/transaction/base.lua
@@ -0,0 +1,225 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local err = require('aconf.error')
+
+local object = require('aconf.object')
+local class = object.class
+
+local pth = require('aconf.path')
+local copy = require('aconf.util').copy
+
+-- TODO each transaction backend (i.e. persistence manager or
+-- transaction proper) should be implemented as a thread or have its
+-- internal state stored in shared storage (with appropriate locking)
+
+
+local generation = 0
+local function gen_number()
+ generation = generation + 1
+ return generation
+end
+
+
+M.TransactionBackend = class()
+
+function M.TransactionBackend:init() self.mod_time = {} end
+
+function M.TransactionBackend:get_if_older(path, timestamp)
+ local value, ts = self:get(path)
+ if ts > timestamp then err.raise('conflict', path) end
+ return value, ts
+end
+
+function M.TransactionBackend:set(path, value)
+ self:set_multiple{{path, value}}
+end
+
+function M.TransactionBackend:set_multiple(mods)
+ -- TODO delegate to PM backends?
+ local timestamp = gen_number()
+ local effective = {}
+
+ local function tostr(s) return s ~= nil and tostring(s) or nil end
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+
+ if type(value) == 'table' or type(
+ self:get(path)
+ ) == 'table' or self:get(path) ~= value then
+
+ table.insert(effective, mod)
+ self.mod_time[path] = timestamp
+ end
+ end
+
+ self:_set_multiple(effective)
+end
+
+-- TODO should be atomic, mutex with set_multiple
+function M.TransactionBackend:comp_and_setm(accessed, mods)
+ local errors = err.ErrorDict()
+ for path, timestamp in pairs(accessed) do
+ errors:collect(self.get_if_older, self, path, timestamp)
+ end
+ errors:raise()
+
+ self:set_multiple(mods)
+end
+
+
+
+local function remove_list_value(list, value)
+ value = tostring(value)
+
+ for i, v in ipairs(list) do
+ if tostring(v) == value then
+ table.remove(list, i)
+ return
+ end
+ end
+end
+
+
+M.Transaction = class(M.TransactionBackend)
+
+function M.Transaction:init(backend)
+ object.super(self, M.Transaction):init()
+ self.backend = backend
+ self:reset()
+end
+
+function M.Transaction:reset()
+ self.started = gen_number()
+ self.access_time = {}
+
+ self.added = {}
+ self.modified = {}
+ self.deleted = {}
+end
+
+function M.Transaction:get(path)
+ if self.deleted[path] then return nil, self.mod_time[path] end
+ for _, tbl in ipairs{self.added, self.modified} do
+ if tbl[path] ~= nil then
+ return copy(tbl[path]), self.mod_time[path]
+ end
+ end
+
+ local value, timestamp = self.backend:get_if_older(path, self.started)
+ self.access_time[path] = timestamp
+ return value, timestamp
+end
+
+function M.Transaction:_set_multiple(mods)
+
+ local function set(path, value, new)
+ local delete = value == nil
+
+ if self.added[path] == nil and (not new or self.deleted[path]) then
+ self.modified[path] = value
+ self.deleted[path] = delete
+ else self.added[path] = value end
+ end
+
+ for _, mod in ipairs(mods) do
+ local path, value = unpack(mod)
+
+ local ppath = pth.parent(path)
+ local parent = self:get(ppath)
+ if parent == nil then
+ parent = {}
+ self:set(ppath, parent)
+ end
+
+ local name = pth.name(path)
+ local old = self:get(path)
+
+ local is_table = type(value) == 'table'
+ local delete = value == nil
+
+ if delete then self:check_deleted(path) end
+
+ if type(old) == 'table' then
+ if delete then
+ for _, child in ipairs(old) do self:set(pth.join(path, child)) end
+ elseif is_table then return
+ elseif #old > 0 then
+ error('Cannot assign a primitive value to non-leaf node '..path)
+ end
+ end
+
+ if is_table then value = {} end
+ set(path, value, old == nil)
+
+ local function set_parent()
+ set(ppath, parent)
+ self.mod_time[ppath] = self.mod_time[path]
+ end
+
+ if old == nil and not delete then
+ table.insert(parent, name)
+ set_parent()
+ elseif old ~= nil and delete then
+ remove_list_value(parent, name)
+ set_parent()
+ end
+ end
+end
+
+function M.Transaction:check_deleted(path) end
+
+function M.Transaction:commit()
+ local mods = {}
+ local handled = {}
+
+ local function insert(path, value)
+ assert(not handled[path])
+ table.insert(mods, {path, value})
+ handled[path] = true
+ end
+
+ local function insert_add(path)
+ if not handled[path] then
+ local pp = pth.parent(path)
+ if self.added[pp] then insert_add(pp) end
+ insert(path, self.added[path])
+ end
+ end
+
+ local function insert_del(path)
+ if not handled[path] then
+ local value = self.backend:get(path)
+ if type(value) == 'table' then
+ for _, child in ipairs(value) do
+ local cp = pth.join(path, child)
+ assert(self.deleted[cp])
+ insert_del(cp)
+ end
+ end
+ insert(path)
+ end
+ end
+
+ for path, deleted in pairs(self.deleted) do
+ if deleted then insert_del(path) end
+ end
+
+ for path, value in pairs(self.modified) do
+ if type(value) ~= 'table' then insert(path, value) end
+ end
+
+ for path, _ in pairs(self.added) do insert_add(path) end
+
+ self.backend:comp_and_setm(self.access_time, mods)
+
+ self:reset()
+end
+
+
+return M
diff --git a/aconf/transaction/init.lua b/aconf/transaction/init.lua
new file mode 100644
index 0000000..9e508b9
--- /dev/null
+++ b/aconf/transaction/init.lua
@@ -0,0 +1,126 @@
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local ErrorDict = require('aconf.error').ErrorDict
+local root = require('aconf.model.root')
+
+local object = require('aconf.object')
+local super = object.super
+
+local pth = require('aconf.path')
+
+local util = require('aconf.util')
+local copy = util.copy
+
+
+local ModelTransaction = object.class(
+ require('aconf.transaction.base').Transaction
+)
+
+function ModelTransaction:init(backend, validate)
+ super(self, ModelTransaction):init(backend)
+
+ self.validate = validate
+ self.validable = {}
+
+ self.root = root.RootModel(self)
+end
+
+function ModelTransaction:committing()
+ return self.commit_val and true or false
+end
+
+function ModelTransaction:check()
+ if not self.backend then error('Transaction already committed') end
+end
+
+function ModelTransaction:get(path)
+ self:check()
+ return super(self, ModelTransaction):get(path)
+end
+
+function ModelTransaction:set_multiple(mods)
+ super(self, ModelTransaction):set_multiple(mods)
+ for _, mod in ipairs(mods) do
+ local addr, value = unpack(mod)
+ if value == nil then
+ for _, val in ipairs{self.validable, self.commit_val} do
+ local done
+ repeat
+ done = true
+ for path, a in pairs(copy(val)) do
+ if a == addr then
+ for p, _ in pairs(copy(val)) do
+ if pth.is_subordinate(p, path) then val[p] = nil end
+ end
+ done = false
+ break
+ end
+ end
+ until done
+ end
+ end
+ end
+end
+
+function ModelTransaction:check_deleted(path)
+ -- assume one-level refs for now
+ local top = root.topology(pth.parent(path))
+ if top then
+ local errors = ErrorDict()
+ for _, refs in ipairs(top.referrers) do
+ for _, ref in ipairs(self.root:search_refs(refs)) do
+ errors:collect(ref.deleted, ref, path)
+ end
+ end
+ errors:raise()
+ end
+end
+
+function ModelTransaction:fetch(path) return self.root:fetch(path) end
+
+function ModelTransaction:meta(path) return self.root:meta(path) end
+
+function ModelTransaction:commit()
+ self:check()
+
+ if self.validate then
+ self.commit_val = copy(self.validable)
+ local errors = ErrorDict()
+
+ local function validate(path)
+ if path > '/' then validate(pth.parent(path)) end
+ if not self.commit_val[path] then return end
+ errors:collect(getmetatable(self:fetch(path)).validate)
+ self.commit_val[path] = nil
+ end
+
+ while next(self.commit_val) do validate(next(self.commit_val)) end
+ self.commit_val = nil
+ errors:raise()
+ end
+
+ super(self, ModelTransaction):commit()
+
+ if not self.validate then
+ util.update(self.backend.validable, self.validable)
+ end
+
+ self.backend = nil
+end
+
+
+local store = require('aconf.persistence')
+local def_store = require('aconf.persistence.defer')
+
+return function(options)
+ options = options or {}
+ return ModelTransaction(
+ options.parent or (
+ options.allow_commit_defer and def_store or store
+ ),
+ not (options.parent and options.defer_validation)
+ )
+ end
diff --git a/aconf/util.lua b/aconf/util.lua
new file mode 100644
index 0000000..d5d5d42
--- /dev/null
+++ b/aconf/util.lua
@@ -0,0 +1,103 @@
+--- Alpine Configurator utility functions.
+--
+-- @module aconf.util
+
+--[[
+Copyright (c) 2012-2014 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+
+local M = {}
+
+--- set default value for a key in a table unless it is already set.
+-- @param t the table
+-- @param k the key
+-- @param v the default value
+-- @return the value `t[k]`
+function M.setdefault(t, k, v)
+ if t[k] == nil then t[k] = v end
+ return t[k]
+end
+
+--- merge a table into another.
+-- Copy values for all keys from `src` to `dst` and optionally keep existing
+-- values.
+-- @param dst the destination table
+-- @param src the source table
+-- @param preserve a boolean. If true then will existing entries in `dst` be
+-- kept.
+-- @return the destination table, `dst`
+function M.update(dst, src, preserve)
+ for k, v in pairs(src) do
+ if not preserve or dst[k] == nil then dst[k] = v end
+ end
+ return dst
+end
+
+--- copy default vaules from one table to another.
+-- Copy all entries in `src` to `dst` but keep any already existing values
+-- in `dst`.
+-- @param dst the destination table
+-- @param src the source table containing the default values
+-- @return the destination table, `dst`
+function M.setdefaults(dst, src) return M.update(dst, src, true) end
+
+--- copy a varable.
+-- If `var` is a table, then the table is cloned, otherwise return the value
+-- of `var`.
+-- @param var the variable to copy
+-- @return a clone of `var`
+function M.copy(var)
+ return type(var) == 'table' and M.setdefaults({}, var) or var
+end
+
+--- extend an array.
+-- inserts all elements in `src` into `dst`
+-- @param dst the destination array
+-- @param src the source array
+-- @return the destination array, `dst`
+function M.extend(dst, src)
+ for _, v in ipairs(src) do table.insert(dst, v) end
+ return dst
+end
+
+--- extract the keys of a table as an array.
+-- @param tbl a table
+-- @return an array of keys
+function M.keys(tbl)
+ local res = {}
+ for k, v in pairs(tbl) do table.insert(res, k) end
+ return res
+end
+
+--- determine whether a value is present in an array.
+-- @param list an array
+-- @param value a value
+-- @return a boolean
+function M.contains(list, value)
+ for k, v in ipairs(list) do if v == value then return true end end
+ return false
+end
+
+--- map a function over a table.
+-- @param func a function with one argument
+-- @param tbl the table
+-- @return the transformed table
+function M.map(func, tbl)
+ local res = {}
+ for k, v in pairs(tbl) do res[k] = func(v) end
+ return res
+end
+
+--- select array values satisfying a filter.
+-- @param func a function with one argument
+-- @param list the array
+-- @return the filtered array
+function M.filter(func, list)
+ local res = {}
+ for _, v in ipairs(list) do if func(v) then table.insert(res, v) end end
+ return res
+end
+
+return M