luci-app-dockerman: initial checkin
[project/luci.git] / applications / luci-app-dockerman / luasrc / model / docker.lua
1 --[[
2 LuCI - Lua Configuration Interface
3 Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
4 ]]--
5
6 require "luci.util"
7 local docker = require "luci.docker"
8 local uci = (require "luci.model.uci").cursor()
9
10 local _docker = {}
11
12 --pull image and return iamge id
13 local update_image = function(self, image_name)
14 local json_stringify = luci.jsonc and luci.jsonc.stringify
15 _docker:append_status("Images: " .. "pulling" .. " " .. image_name .. "...\n")
16 local res = self.images:create({query = {fromImage=image_name}}, _docker.pull_image_show_status_cb)
17 if res and res.code == 200 and (#res.body > 0 and not res.body[#res.body].error and res.body[#res.body].status and (res.body[#res.body].status == "Status: Downloaded newer image for ".. image_name)) then
18 _docker:append_status("done\n")
19 else
20 res.body.message = res.body[#res.body] and res.body[#res.body].error or (res.body.message or res.message)
21 end
22 new_image_id = self.images:inspect({name = image_name}).body.Id
23 return new_image_id, res
24 end
25
26 local table_equal = function(t1, t2)
27 if not t1 then return true end
28 if not t2 then return false end
29 if #t1 ~= #t2 then return false end
30 for i, v in ipairs(t1) do
31 if t1[i] ~= t2[i] then return false end
32 end
33 return true
34 end
35
36 local table_subtract = function(t1, t2)
37 if not t1 or next(t1) == nil then return nil end
38 if not t2 or next(t2) == nil then return t1 end
39 local res = {}
40 for _, v1 in ipairs(t1) do
41 local found = false
42 for _, v2 in ipairs(t2) do
43 if v1 == v2 then
44 found= true
45 break
46 end
47 end
48 if not found then
49 table.insert(res, v1)
50 end
51 end
52 return next(res) == nil and nil or res
53 end
54
55 local map_subtract = function(t1, t2)
56 if not t1 or next(t1) == nil then return nil end
57 if not t2 or next(t2) == nil then return t1 end
58 local res = {}
59 for k1, v1 in pairs(t1) do
60 local found = false
61 for k2, v2 in ipairs(t2) do
62 if k1 == k2 and luci.util.serialize_data(v1) == luci.util.serialize_data(v2) then
63 found= true
64 break
65 end
66 end
67 if not found then
68 res[k1] = v1
69 -- if v1 and type(v1) == "table" then
70 -- if next(v1) == nil then
71 -- res[k1] = { k = 'v' }
72 -- else
73 -- res[k1] = v1
74 -- end
75 -- end
76 end
77 end
78
79 return next(res) ~= nil and res or nil
80 end
81
82 _docker.clear_empty_tables = function ( t )
83 local k, v
84 if next(t) == nil then
85 t = nil
86 else
87 for k, v in pairs(t) do
88 if type(v) == 'table' then
89 t[k] = _docker.clear_empty_tables(v)
90 end
91 end
92 end
93 return t
94 end
95
96 -- return create_body, extra_network
97 local get_config = function(container_config, image_config)
98 local config = container_config.Config
99 local old_host_config = container_config.HostConfig
100 local old_network_setting = container_config.NetworkSettings.Networks or {}
101 if config.WorkingDir == image_config.WorkingDir then config.WorkingDir = "" end
102 if config.User == image_config.User then config.User = "" end
103 if table_equal(config.Cmd, image_config.Cmd) then config.Cmd = nil end
104 if table_equal(config.Entrypoint, image_config.Entrypoint) then config.Entrypoint = nil end
105 if table_equal(config.ExposedPorts, image_config.ExposedPorts) then config.ExposedPorts = nil end
106 config.Env = table_subtract(config.Env, image_config.Env)
107 config.Labels = table_subtract(config.Labels, image_config.Labels)
108 config.Volumes = map_subtract(config.Volumes, image_config.Volumes)
109 -- subtract ports exposed in image from container
110 if old_host_config.PortBindings and next(old_host_config.PortBindings) ~= nil then
111 config.ExposedPorts = {}
112 for p, v in pairs(old_host_config.PortBindings) do
113 config.ExposedPorts[p] = { HostPort=v[1] and v[1].HostPort }
114 end
115 end
116
117 -- handle network config, we need only one network, extras need to network connect action
118 local network_setting = {}
119 local multi_network = false
120 local extra_network = {}
121 for k, v in pairs(old_network_setting) do
122 if multi_network then
123 extra_network[k] = v
124 else
125 network_setting[k] = v
126 end
127 multi_network = true
128 end
129
130 -- handle hostconfig
131 local host_config = old_host_config
132 -- if host_config.PortBindings and next(host_config.PortBindings) == nil then host_config.PortBindings = nil end
133 -- host_config.LogConfig = nil
134 host_config.Mounts = {}
135 -- for volumes
136 for i, v in ipairs(container_config.Mounts) do
137 if v.Type == "volume" then
138 table.insert(host_config.Mounts, {
139 Type = v.Type,
140 Target = v.Destination,
141 Source = v.Source:match("([^/]+)\/_data"),
142 BindOptions = (v.Type == "bind") and {Propagation = v.Propagation} or nil,
143 ReadOnly = not v.RW
144 })
145 end
146 end
147
148
149 -- merge configs
150 local create_body = config
151 create_body["HostConfig"] = host_config
152 create_body["NetworkingConfig"] = {EndpointsConfig = network_setting}
153 create_body = _docker.clear_empty_tables(create_body) or {}
154 extra_network = _docker.clear_empty_tables(extra_network) or {}
155 return create_body, extra_network
156 end
157
158 local upgrade = function(self, request)
159 _docker:clear_status()
160 -- get image name, image id, container name, configuration information
161 local container_info = self.containers:inspect({id = request.id})
162 if container_info.code > 300 and type(container_info.body) == "table" then
163 return container_info
164 end
165 local image_name = container_info.body.Config.Image
166 if not image_name:match(".-:.+") then image_name = image_name .. ":latest" end
167 local old_image_id = container_info.body.Image
168 local container_name = container_info.body.Name:sub(2)
169
170 local image_id, res = update_image(self, image_name)
171 if res and res.code ~= 200 then return res end
172 if image_id == old_image_id then
173 return {code = 305, body = {message = "Already up to date"}}
174 end
175
176 _docker:append_status("Container: " .. "Stop" .. " " .. container_name .. "...")
177 res = self.containers:stop({name = container_name})
178 if res and res.code < 305 then
179 _docker:append_status("done\n")
180 else
181 return res
182 end
183
184 _docker:append_status("Container: rename" .. " " .. container_name .. " to ".. container_name .. "_old ...")
185 res = self.containers:rename({name = container_name, query = { name = container_name .. "_old" }})
186 if res and res.code < 300 then
187 _docker:append_status("done\n")
188 else
189 return res
190 end
191
192 -- handle config
193 local image_config = self.images:inspect({id = old_image_id}).body.Config
194 local create_body, extra_network = get_config(container_info.body, image_config)
195
196 -- create new container
197 _docker:append_status("Container: Create" .. " " .. container_name .. "...")
198 create_body = _docker.clear_empty_tables(create_body)
199 res = self.containers:create({name = container_name, body = create_body})
200 if res and res.code > 300 then return res end
201 _docker:append_status("done\n")
202
203 -- extra networks need to network connect action
204 for k, v in pairs(extra_network) do
205 _docker:append_status("Networks: Connect" .. " " .. container_name .. "...")
206 res = self.networks:connect({id = k, body = {Container = container_name, EndpointConfig = v}})
207 if res.code > 300 then return res end
208
209 _docker:append_status("done\n")
210 end
211 _docker:clear_status()
212 return res
213 end
214
215 local duplicate_config = function (self, request)
216 local container_info = self.containers:inspect({id = request.id})
217 if container_info.code > 300 and type(container_info.body) == "table" then return nil end
218 local old_image_id = container_info.body.Image
219 local image_config = self.images:inspect({id = old_image_id}).body.Config
220 return get_config(container_info.body, image_config)
221 end
222
223 _docker.new = function(option)
224 local option = option or {}
225 local remote = uci:get("dockerman", "local", "remote_endpoint")
226 options = {
227 host = (remote == "true") and (option.host or uci:get("dockerman", "local", "remote_host")) or nil,
228 port = (remote == "true") and (option.port or uci:get("dockerman", "local", "remote_port")) or nil,
229 debug = option.debug or uci:get("dockerman", "local", "debug") == 'true' and true or false,
230 debug_path = option.debug_path or uci:get("dockerman", "local", "debug_path")
231 }
232 options.socket_path = (remote ~= "true" or not options.host or not options.port) and (option.socket_path or uci:get("dockerman", "local", "socket_path") or "/var/run/docker.sock") or nil
233 local _new = docker.new(options)
234 _new.options.status_path = uci:get("dockerman", "local", "status_path")
235 _new.containers_upgrade = upgrade
236 _new.containers_duplicate_config = duplicate_config
237 return _new
238 end
239 _docker.options={}
240 _docker.options.status_path = uci:get("dockerman", "local", "status_path")
241
242 _docker.append_status=function(self,val)
243 if not val then return end
244 local file_docker_action_status=io.open(self.options.status_path, "a+")
245 file_docker_action_status:write(val)
246 file_docker_action_status:close()
247 end
248
249 _docker.write_status=function(self,val)
250 if not val then return end
251 local file_docker_action_status=io.open(self.options.status_path, "w+")
252 file_docker_action_status:write(val)
253 file_docker_action_status:close()
254 end
255
256 _docker.read_status=function(self)
257 return nixio.fs.readfile(self.options.status_path)
258 end
259
260 _docker.clear_status=function(self)
261 nixio.fs.remove(self.options.status_path)
262 end
263
264 local status_cb = function(res, source, handler)
265 res.body = res.body or {}
266 while true do
267 local chunk = source()
268 if chunk then
269 --standard output to res.body
270 table.insert(res.body, chunk)
271 handler(chunk)
272 else
273 return
274 end
275 end
276 end
277
278 --{"status":"Pulling from library\/debian","id":"latest"}
279 --{"status":"Pulling fs layer","progressDetail":[],"id":"50e431f79093"}
280 --{"status":"Downloading","progressDetail":{"total":50381971,"current":2029978},"id":"50e431f79093","progress":"[==> ] 2.03MB\/50.38MB"}
281 --{"status":"Download complete","progressDetail":[],"id":"50e431f79093"}
282 --{"status":"Extracting","progressDetail":{"total":50381971,"current":17301504},"id":"50e431f79093","progress":"[=================> ] 17.3MB\/50.38MB"}
283 --{"status":"Pull complete","progressDetail":[],"id":"50e431f79093"}
284 --{"status":"Digest: sha256:a63d0b2ecbd723da612abf0a8bdb594ee78f18f691d7dc652ac305a490c9b71a"}
285 --{"status":"Status: Downloaded newer image for debian:latest"}
286 _docker.pull_image_show_status_cb = function(res, source)
287 return status_cb(res, source, function(chunk)
288 local json_parse = luci.jsonc.parse
289 local step = json_parse(chunk)
290 if type(step) == "table" then
291 local buf = _docker:read_status()
292 local num = 0
293 local str = '\t' .. (step.id and (step.id .. ": ") or "") .. (step.status and step.status or "") .. (step.progress and (" " .. step.progress) or "").."\n"
294 if step.id then buf, num = buf:gsub("\t"..step.id .. ": .-\n", str) end
295 if num == 0 then
296 buf = buf .. str
297 end
298 _docker:write_status(buf)
299 end
300 end)
301 end
302
303 --{"status":"Downloading from https://downloads.openwrt.org/releases/19.07.0/targets/x86/64/openwrt-19.07.0-x86-64-generic-rootfs.tar.gz"}
304 --{"status":"Importing","progressDetail":{"current":1572391,"total":3821714},"progress":"[====================\u003e ] 1.572MB/3.822MB"}
305 --{"status":"sha256:d5304b58e2d8cc0a2fd640c05cec1bd4d1229a604ac0dd2909f13b2b47a29285"}
306 _docker.import_image_show_status_cb = function(res, source)
307 return status_cb(res, source, function(chunk)
308 local json_parse = luci.jsonc.parse
309 local step = json_parse(chunk)
310 if type(step) == "table" then
311 local buf = _docker:read_status()
312 local num = 0
313 local str = '\t' .. (step.status and step.status or "") .. (step.progress and (" " .. step.progress) or "").."\n"
314 if step.status then buf, num = buf:gsub("\t"..step.status .. " .-\n", str) end
315 if num == 0 then
316 buf = buf .. str
317 end
318 _docker:write_status(buf)
319 end
320 end
321 )
322 end
323
324 -- _docker.print_status_cb = function(res, source)
325 -- return status_cb(res, source, function(step)
326 -- luci.util.perror(step)
327 -- end
328 -- )
329 -- end
330
331 _docker.create_macvlan_interface = function(name, device, gateway, subnet)
332 if not nixio.fs.access("/etc/config/network") or not nixio.fs.access("/etc/config/firewall") then return end
333 if uci:get("dockerman", "local", "remote_endpoint") == "true" then return end
334 local ip = require "luci.ip"
335 local if_name = "docker_"..name
336 local dev_name = "macvlan_"..name
337 local net_mask = tostring(ip.new(subnet):mask())
338 local lan_interfaces
339 -- add macvlan device
340 uci:delete("network", dev_name)
341 uci:set("network", dev_name, "device")
342 uci:set("network", dev_name, "name", dev_name)
343 uci:set("network", dev_name, "ifname", device)
344 uci:set("network", dev_name, "type", "macvlan")
345 uci:set("network", dev_name, "mode", "bridge")
346 -- add macvlan interface
347 uci:delete("network", if_name)
348 uci:set("network", if_name, "interface")
349 uci:set("network", if_name, "proto", "static")
350 uci:set("network", if_name, "ifname", dev_name)
351 uci:set("network", if_name, "ipaddr", gateway)
352 uci:set("network", if_name, "netmask", net_mask)
353 uci:foreach("firewall", "zone", function(s)
354 if s.name == "lan" then
355 local interfaces
356 if type(s.network) == "table" then
357 interfaces = table.concat(s.network, " ")
358 uci:delete("firewall", s[".name"], "network")
359 else
360 interfaces = s.network and s.network or ""
361 end
362 interfaces = interfaces .. " " .. if_name
363 interfaces = interfaces:gsub("%s+", " ")
364 uci:set("firewall", s[".name"], "network", interfaces)
365 end
366 end)
367 uci:commit("firewall")
368 uci:commit("network")
369 os.execute("ifup " .. if_name)
370 end
371
372 _docker.remove_macvlan_interface = function(name)
373 if not nixio.fs.access("/etc/config/network") or not nixio.fs.access("/etc/config/firewall") then return end
374 if uci:get("dockerman", "local", "remote_endpoint") == "true" then return end
375 local if_name = "docker_"..name
376 local dev_name = "macvlan_"..name
377 uci:foreach("firewall", "zone", function(s)
378 if s.name == "lan" then
379 local interfaces
380 if type(s.network) == "table" then
381 interfaces = table.concat(s.network, " ")
382 else
383 interfaces = s.network and s.network or ""
384 end
385 interfaces = interfaces and interfaces:gsub(if_name, "")
386 interfaces = interfaces and interfaces:gsub("%s+", " ")
387 uci:set("firewall", s[".name"], "network", interfaces)
388 end
389 end)
390 uci:commit("firewall")
391 uci:delete("network", dev_name)
392 uci:delete("network", if_name)
393 uci:commit("network")
394 os.execute("ip link del " .. if_name)
395 end
396
397 return _docker