summaryrefslogtreecommitdiffstats
path: root/aconf/model
diff options
context:
space:
mode:
authorKaarle Ritvanen <kaarle.ritvanen@datakunkku.fi>2014-03-10 22:45:18 +0200
committerKaarle Ritvanen <kaarle.ritvanen@datakunkku.fi>2014-03-24 01:18:13 +0200
commit7d9c43916b0600ac4879dfe9793eab807a83ab2b (patch)
treeec54ed64c9a557b6ea4ad88d31138a02d3e0cd04 /aconf/model
parentcb6c243dc356ef1d46d7ddb96e6ea6ae007c6cca (diff)
downloadaconf-7d9c43916b0600ac4879dfe9793eab807a83ab2b.tar.bz2
aconf-7d9c43916b0600ac4879dfe9793eab807a83ab2b.tar.xz
rename ACF2 to Alpine Configurator (aconf)
Diffstat (limited to 'aconf/model')
-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
13 files changed, 1928 insertions, 0 deletions
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