diff options
Diffstat (limited to 'aconf')
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 |