cc-mek-scada/ccmsi.lua

679 lines
24 KiB
Lua

--[[
CC-MEK-SCADA Installer Utility
Copyright (c) 2023 - 2024 Mikayla Fischler
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
]]--
local CCMSI_VERSION = "v1.17"
local install_dir = "/.install-cache"
local manifest_path = "https://mikaylafischler.github.io/cc-mek-scada/manifests/"
local repo_path = "http://raw.githubusercontent.com/MikaylaFischler/cc-mek-scada/"
---@diagnostic disable-next-line: undefined-global
local _is_pkt_env = pocket -- luacheck: ignore pocket
local function println(msg) print(tostring(msg)) end
-- stripped down & modified copy of log.dmesg
local function print(msg)
msg = tostring(msg)
local cur_x, cur_y = term.getCursorPos()
local out_w, out_h = term.getSize()
-- jump to next line if needed
if cur_x == out_w then
cur_x = 1
if cur_y == out_h then
term.scroll(1)
term.setCursorPos(1, cur_y)
else
term.setCursorPos(1, cur_y + 1)
end
end
-- wrap
local lines, remaining, s_start, s_end, ln = {}, true, 1, out_w + 1 - cur_x, 1
while remaining do
local line = string.sub(msg, s_start, s_end)
if line == "" then
remaining = false
else
lines[ln] = line
s_start = s_end + 1
s_end = s_end + out_w
ln = ln + 1
end
end
-- print
for i = 1, #lines do
cur_x, cur_y = term.getCursorPos()
if i > 1 and cur_x > 1 then
if cur_y == out_h then
term.scroll(1)
term.setCursorPos(1, cur_y)
else term.setCursorPos(1, cur_y + 1) end
end
term.write(lines[i])
end
end
local opts = { ... }
local mode, app, target
local install_manifest = manifest_path.."main/install_manifest.json"
local function red() term.setTextColor(colors.red) end
local function orange() term.setTextColor(colors.orange) end
local function yellow() term.setTextColor(colors.yellow) end
local function green() term.setTextColor(colors.green) end
local function cyan() term.setTextColor(colors.cyan) end
local function blue() term.setTextColor(colors.blue) end
local function white() term.setTextColor(colors.white) end
local function lgray() term.setTextColor(colors.lightGray) end
-- get command line option in list
local function get_opt(opt, options)
for _, v in pairs(options) do if opt == v then return v end end
return nil
end
-- wait for any key to be pressed
---@diagnostic disable-next-line: undefined-field
local function any_key() os.pullEvent("key_up") end
-- ask the user yes or no
local function ask_y_n(question, default)
print(question)
if default == true then print(" (Y/n)? ") else print(" (y/N)? ") end
local response = read();any_key()
if response == "" then return default
elseif response == "Y" or response == "y" then return true
elseif response == "N" or response == "n" then return false
else return nil end
end
-- print out a white + blue text message
local function pkg_message(message, package) white();print(message.." ");blue();println(package);white() end
-- indicate actions to be taken based on package differences for installs/updates
local function show_pkg_change(name, v)
if v.v_local ~= nil then
if v.v_local ~= v.v_remote then
print("["..name.."] updating ");blue();print(v.v_local);white();print(" \xbb ");blue();println(v.v_remote);white()
elseif mode == "install" then
pkg_message("["..name.."] reinstalling", v.v_local)
end
else pkg_message("["..name.."] new install of", v.v_remote) end
return v.v_local ~= v.v_remote
end
-- read the local manifest file
local function read_local_manifest()
local local_ok = false
local local_manifest = {}
local imfile = fs.open("install_manifest.json", "r")
if imfile ~= nil then
local_ok, local_manifest = pcall(function () return textutils.unserializeJSON(imfile.readAll()) end)
imfile.close()
end
return local_ok, local_manifest
end
-- get the manifest from GitHub
local function get_remote_manifest()
local response, error = http.get(install_manifest)
if response == nil then
orange();println("Failed to get installation manifest from GitHub, cannot update or install.")
red();println("HTTP error: "..error);white()
return false, {}
end
local ok, manifest = pcall(function () return textutils.unserializeJSON(response.readAll()) end)
if not ok then red();println("error parsing remote installation manifest");white() end
return ok, manifest
end
-- record the local installation manifest
local function write_install_manifest(manifest, dependencies)
local versions = {}
for key, value in pairs(manifest.versions) do
local is_dependency = false
for _, dependency in pairs(dependencies) do
if (key == "bootloader" and dependency == "system") or key == dependency then
is_dependency = true;break
end
end
if key == app or key == "comms" or is_dependency then versions[key] = value end
end
manifest.versions = versions
local imfile = fs.open("install_manifest.json", "w")
imfile.write(textutils.serializeJSON(manifest))
imfile.close()
end
-- try at most 3 times to download a file from the repository and write into w_path base directory
local function http_get_file(file, w_path)
local dl, err
for i = 1, 3 do
dl, err = http.get(repo_path..file)
if dl then
if i > 1 then green();println("success!");lgray() end
local f = fs.open(w_path..file, "w")
f.write(dl.readAll())
f.close()
break
else
red();println("HTTP Error: "..err)
if i < 3 then lgray();print("> retrying...") end
---@diagnostic disable-next-line: undefined-field
os.sleep(i/3.0)
end
end
return dl ~= nil
end
-- recursively build a tree out of the file manifest
local function gen_tree(manifest, log)
local function _tree_add(tree, split)
if #split > 1 then
local name = table.remove(split, 1)
if tree[name] == nil then tree[name] = {} end
table.insert(tree[name], _tree_add(tree[name], split))
else return split[1] end
return nil
end
local list, tree = { log }, {}
-- make a list of each and every file
for _, files in pairs(manifest.files) do for i = 1, #files do table.insert(list, files[i]) end end
for i = 1, #list do
local split = {}
---@diagnostic disable-next-line: discard-returns
string.gsub(list[i], "([^/]+)", function(c) split[#split + 1] = c end)
if #split == 1 then table.insert(tree, list[i])
else table.insert(tree, _tree_add(tree, split)) end
end
return tree
end
local function _in_array(val, array)
for _, v in pairs(array) do if v == val then return true end end
return false
end
local function _clean_dir(dir, tree)
if tree == nil then tree = {} end
local ls = fs.list(dir)
for _, val in pairs(ls) do
local path = dir.."/"..val
if fs.isDir(path) then
_clean_dir(path, tree[val])
if #fs.list(path) == 0 then fs.delete(path);println("deleted "..path) end
elseif (not _in_array(val, tree)) and (val ~= "config.lua" ) then ---@todo remove config.lua on full release
fs.delete(path)
println("deleted "..path)
end
end
end
-- go through app/common directories to delete unused files
local function clean(manifest)
local log = nil
if fs.exists(app..".settings") and settings.load(app..".settings") then
log = settings.get("LogPath")
if log:sub(1, 1) == "/" then log = log:sub(2) end
end
local tree = gen_tree(manifest, log)
table.insert(tree, "install_manifest.json")
table.insert(tree, "ccmsi.lua")
local ls = fs.list("/")
for _, val in pairs(ls) do
if fs.isDriveRoot(val) then
yellow();println("skipped mount '"..val.."'")
elseif fs.isDir(val) then
if tree[val] ~= nil then lgray();_clean_dir("/"..val, tree[val])
else white(); if ask_y_n("delete the unused directory '"..val.."'") then lgray();_clean_dir("/"..val) end end
if #fs.list(val) == 0 then fs.delete(val);lgray();println("deleted empty directory '"..val.."'") end
elseif not _in_array(val, tree) and (string.find(val, ".settings") == nil) then
white();if ask_y_n("delete the unused file '"..val.."'") then fs.delete(val);lgray();println("deleted "..val) end
end
end
white()
end
-- get and validate command line options
if _is_pkt_env then println("- SCADA Installer "..CCMSI_VERSION.." -")
else println("-- CC Mekanism SCADA Installer "..CCMSI_VERSION.." --") end
if #opts == 0 or opts[1] == "help" then
println("usage: ccmsi <mode> <app> <branch>")
if _is_pkt_env then
yellow();println("<mode>");lgray()
println(" check - check latest")
println(" install - fresh install")
println(" update - update app")
println(" uninstall - remove app")
yellow();println("<app>");lgray()
println(" reactor-plc")
println(" rtu")
println(" supervisor")
println(" coordinator")
println(" pocket")
println(" installer (update only)")
yellow();println("<branch>");lgray();
println(" main (default) | devel");white()
else
println("<mode>")
lgray()
println(" check - check latest versions available")
yellow()
println(" ccmsi check <branch> for target")
lgray()
println(" install - fresh install")
println(" update - update files")
println(" uninstall - delete files INCLUDING config/logs")
white();println("<app>");lgray()
println(" reactor-plc - reactor PLC firmware")
println(" rtu - RTU firmware")
println(" supervisor - supervisor server application")
println(" coordinator - coordinator application")
println(" pocket - pocket application")
println(" installer - ccmsi installer (update only)")
white();println("<branch>")
lgray();println(" main (default) | devel");white()
end
return
else
mode = get_opt(opts[1], { "check", "install", "update", "uninstall" })
if mode == nil then
red();println("Unrecognized mode.");white()
return
end
app = get_opt(opts[2], { "reactor-plc", "rtu", "supervisor", "coordinator", "pocket", "installer" })
if app == nil and mode ~= "check" then
red();println("Unrecognized application.");white()
return
elseif app == "installer" and mode ~= "update" then
red();println("Installer app only supports 'update' option.");white()
return
end
-- determine target
if mode == "check" then target = opts[2] else target = opts[3] end
if (target ~= "main") and (target ~= "devel") then
if (target and target ~= "") then yellow();println("Unknown target, defaulting to 'main'");white() end
target = "main"
end
-- set paths
install_manifest = manifest_path..target.."/install_manifest.json"
repo_path = repo_path..target.."/"
end
-- run selected mode
if mode == "check" then
local ok, manifest = get_remote_manifest()
if not ok then return end
local local_ok, local_manifest = read_local_manifest()
if not local_ok then
yellow();println("failed to load local installation information");white()
local_manifest = { versions = { installer = CCMSI_VERSION } }
else
local_manifest.versions.installer = CCMSI_VERSION
end
-- list all versions
for key, value in pairs(manifest.versions) do
term.setTextColor(colors.purple)
local tag = string.format("%-14s", "["..key.."]")
if not _is_pkt_env then print(tag) end
if key == "installer" or (local_ok and (local_manifest.versions[key] ~= nil)) then
if _is_pkt_env then println(tag) end
blue();print(local_manifest.versions[key])
if value ~= local_manifest.versions[key] then
white();print(" (")
cyan();print(value);white();println(" available)")
else green();println(" (up to date)") end
elseif not _is_pkt_env then
lgray();print("not installed");white();print(" (latest ")
cyan();print(value);white();println(")")
end
end
if manifest.versions.installer ~= local_manifest.versions.installer and not _is_pkt_env then
yellow();println("\nA different version of the installer is available, it is recommended to update (use 'ccmsi update installer').");white()
end
elseif mode == "install" or mode == "update" then
local update_installer = app == "installer"
local ok, manifest = get_remote_manifest()
if not ok then return end
local ver = {
app = { v_local = nil, v_remote = nil, changed = false },
boot = { v_local = nil, v_remote = nil, changed = false },
comms = { v_local = nil, v_remote = nil, changed = false },
common = { v_local = nil, v_remote = nil, changed = false },
graphics = { v_local = nil, v_remote = nil, changed = false },
lockbox = { v_local = nil, v_remote = nil, changed = false }
}
-- try to find local versions
local local_ok, lmnf = read_local_manifest()
if not local_ok then
if mode == "update" then
red();println("Failed to load local installation information, cannot update.");white()
return
end
elseif not update_installer then
ver.boot.v_local = lmnf.versions.bootloader
ver.app.v_local = lmnf.versions[app]
ver.comms.v_local = lmnf.versions.comms
ver.common.v_local = lmnf.versions.common
ver.graphics.v_local = lmnf.versions.graphics
ver.lockbox.v_local = lmnf.versions.lockbox
if lmnf.versions[app] == nil then
red();println("Another application is already installed, please uninstall it before installing a new application.");white()
return
end
end
if manifest.versions.installer ~= CCMSI_VERSION then
if not update_installer then yellow();println("A different version of the installer is available, it is recommended to update to it.");white() end
if update_installer or ask_y_n("Would you like to update now") then
lgray();println("GET ccmsi.lua")
local dl, err = http.get(repo_path.."ccmsi.lua")
if dl == nil then
red();println("HTTP Error: "..err)
println("Installer download failed.");white()
else
local handle = fs.open(debug.getinfo(1, "S").source:sub(2), "w") -- this file, regardless of name or location
handle.write(dl.readAll())
handle.close()
green();println("Installer updated successfully.");white()
end
return
end
elseif update_installer then
green();println("Installer already up-to-date.");white()
return
end
ver.boot.v_remote = manifest.versions.bootloader
ver.app.v_remote = manifest.versions[app]
ver.comms.v_remote = manifest.versions.comms
ver.common.v_remote = manifest.versions.common
ver.graphics.v_remote = manifest.versions.graphics
ver.lockbox.v_remote = manifest.versions.lockbox
green()
if mode == "install" then print("Installing ") else print("Updating ") end
println(app.." files...");white()
ver.boot.changed = show_pkg_change("bootldr", ver.boot)
ver.common.changed = show_pkg_change("common", ver.common)
ver.comms.changed = show_pkg_change("comms", ver.comms)
if ver.comms.changed and ver.comms.v_local ~= nil then
print("[comms] ");yellow();println("other devices on the network will require an update");white()
end
ver.app.changed = show_pkg_change(app, ver.app)
ver.graphics.changed = show_pkg_change("graphics", ver.graphics)
ver.lockbox.changed = show_pkg_change("lockbox", ver.lockbox)
--------------------------
-- START INSTALL/UPDATE --
--------------------------
local space_required = manifest.sizes.manifest
local space_available = fs.getFreeSpace("/")
local single_file_mode = false
local file_list = manifest.files
local size_list = manifest.sizes
local dependencies = manifest.depends[app]
table.insert(dependencies, app)
-- helper function to check if a dependency is unchanged
local function unchanged(dependency)
if dependency == "system" then return not ver.boot.changed
elseif dependency == "graphics" then return not ver.graphics.changed
elseif dependency == "lockbox" then return not ver.lockbox.changed
elseif dependency == "common" then return not (ver.common.changed or ver.comms.changed)
elseif dependency == app then return not ver.app.changed
else return true end
end
local any_change = false
for _, dependency in pairs(dependencies) do
local size = size_list[dependency]
space_required = space_required + size
any_change = any_change or not unchanged(dependency)
end
if mode == "update" and not any_change then
yellow();println("Nothing to do, everything is already up-to-date!");white()
return
end
-- ask for confirmation
if not ask_y_n("Continue", false) then return end
-- check space constraints
if space_available < space_required then
single_file_mode = true
yellow();println("NOTICE: Insufficient space available for a full cached download!");white()
lgray();println("Files can instead be downloaded one by one. If you are replacing a current install this may corrupt your install ONLY if it fails (such as a sudden network issue). If that occurs, you can still try again.")
if mode == "update" then println("If installation still fails, delete this device's log file and/or any unrelated files you have on this computer then try again.") end
white();
if not ask_y_n("Do you wish to continue", false) then
println("Operation cancelled.")
return
end
end
local success = true
if not single_file_mode then
if fs.exists(install_dir) then fs.delete(install_dir);fs.makeDir(install_dir) end
-- download all dependencies
for _, dependency in pairs(dependencies) do
if mode == "update" and unchanged(dependency) then
pkg_message("skipping download of unchanged package", dependency)
else
pkg_message("downloading package", dependency)
lgray()
local files = file_list[dependency]
for _, file in pairs(files) do
println("GET "..file)
if not http_get_file(file, install_dir.."/") then
red();println("failed to download "..file)
success = false
break
end
end
end
if not success then break end
end
-- copy in downloaded files (installation)
if success then
for _, dependency in pairs(dependencies) do
if mode == "update" and unchanged(dependency) then
pkg_message("skipping install of unchanged package", dependency)
else
pkg_message("installing package", dependency)
lgray()
local files = file_list[dependency]
for _, file in pairs(files) do
local temp_file = install_dir.."/"..file
if fs.exists(file) then fs.delete(file) end
fs.move(temp_file, file)
end
end
end
end
fs.delete(install_dir)
if success then
write_install_manifest(manifest, dependencies)
green()
if mode == "install" then
println("Installation completed successfully.")
else println("Update completed successfully.") end
white();println("Ready to clean up unused files, press any key to continue...")
any_key();clean(manifest)
white();println("Done.")
else
if mode == "install" then
red();println("Installation failed.")
else orange();println("Update failed, existing files unmodified.") end
end
else
-- go through all files and replace one by one
for _, dependency in pairs(dependencies) do
if mode == "update" and unchanged(dependency) then
pkg_message("skipping install of unchanged package", dependency)
else
pkg_message("installing package", dependency)
lgray()
local files = file_list[dependency]
for _, file in pairs(files) do
println("GET "..file)
if not http_get_file(file, "/") then
red();println("failed to download "..file)
success = false
break
end
end
end
if not success then break end
end
if success then
write_install_manifest(manifest, dependencies)
green()
if mode == "install" then
println("Installation completed successfully.")
else println("Update completed successfully.") end
white();println("Ready to clean up unused files, press any key to continue...")
any_key();clean(manifest)
white();println("Done.")
else
red()
if mode == "install" then
println("Installation failed, files may have been skipped.")
else println("Update failed, files may have been skipped.") end
end
end
elseif mode == "uninstall" then
local ok, manifest = read_local_manifest()
if not ok then
red();println("Error parsing local installation manifest.");white()
return
end
if manifest.versions[app] == nil then
red();println("Error: '"..app.."' is not installed.")
return
end
orange();println("Uninstalling all "..app.." files...")
-- ask for confirmation
if not ask_y_n("Continue", false) then return end
-- delete unused files first
clean(manifest)
local file_list = manifest.files
local dependencies = manifest.depends[app]
table.insert(dependencies, app)
-- delete all installed files
lgray()
for _, dependency in pairs(dependencies) do
local files = file_list[dependency]
for _, file in pairs(files) do
if fs.exists(file) then fs.delete(file);println("deleted "..file) end
end
local folder = files[1]
while true do
local dir = fs.getDir(folder)
if dir == "" or dir == ".." then break else folder = dir end
end
if fs.isDir(folder) then
fs.delete(folder)
println("deleted directory "..folder)
end
end
-- delete log file
local log_deleted = false
local settings_file = app..".settings"
if fs.exists(settings_file) and settings.load(settings_file) then
local log = settings.get("LogPath")
if log ~= nil then
log_deleted = true
if fs.exists(log) then
fs.delete(log)
println("deleted log file "..log)
end
end
end
if not log_deleted then
red();println("Failed to delete log file (it may not exist).");lgray()
end
if fs.exists(settings_file) then
fs.delete(settings_file);println("deleted "..settings_file)
end
fs.delete("install_manifest.json")
println("deleted install_manifest.json")
green();println("Done!")
end
white()