--!A cross-platform build utility based on Lua -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. -- You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- -- Unless required by applicable law or agreed to in writing, software -- distributed under the License is distributed on an "AS IS" BASIS, -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. -- -- Copyright (C) 2015-present, Xmake Open Source Community. -- -- @author ruki -- @file client.lua -- -- imports import("core.base.tty") import("core.base.bytes") import("core.base.base64") import("core.base.socket") import("core.base.option") import("core.base.scheduler") import("core.project.config", {alias = "project_config"}) import("lib.detect.find_tool") import("private.service.client_config", {alias = "config"}) import("private.service.message") import("private.service.client") import("private.service.stream", {alias = "socket_stream"}) import("private.service.remote_build.filesync", {alias = "new_filesync"}) -- define module local remote_build_client = remote_build_client or client() local super = remote_build_client:class() -- init client function remote_build_client:init() super.init(self) -- get project directory local projectdir = os.projectdir() local projectfile = os.projectfile() if projectfile and os.isfile(projectfile) and projectdir then self._PROJECTDIR = projectdir self._WORKDIR = path.join(project_config.directory(), "service", "remote_build") else raise("we need to enter a project directory with xmake.lua first!") end -- init host self:_init_host() -- init filesync local filesync = new_filesync(self:projectdir(), path.join(self:workdir(), "manifest.txt")) filesync:ignorefiles_add(".git/**") filesync:ignorefiles_add(".xmake/**") self._FILESYNC = filesync -- init timeout self._SEND_TIMEOUT = config.get("remote_build.send_timeout") or config.get("send_timeout") or -1 self._RECV_TIMEOUT = config.get("remote_build.recv_timeout") or config.get("recv_timeout") or -1 self._CONNECT_TIMEOUT = config.get("remote_build.connect_timeout") or config.get("connect_timeout") or 10000 end -- get class function remote_build_client:class() return remote_build_client end -- connect to the remote server function remote_build_client:connect() if self:is_connected() then print("%s: has been connected!", self) return end -- Do we need user authorization? local user = self:user() local token = self:token() if not token and user then -- get user password cprint("Please input user ${bright}%s${clear} password to connect <%s:%d>:", user, self:addr(), self:port()) io.flush() local pass = (io.read() or ""):trim() assert(pass ~= "", "password is empty!") -- compute user authorization token = base64.encode(user .. ":" .. pass) token = hash.md5(bytes(token)) -- update the computed token self:token_set(token) end -- do connect local addr = self:addr() local port = self:port() local sock = assert(socket.connect(addr, port, {timeout = self:connect_timeout()}), "%s: server unreachable!", self) local session_id = self:session_id() local ok = false local errors cprint("${dim}%s: connect %s:%d ..", self, addr, port) if sock then local stream = socket_stream(sock, {send_timeout = self:send_timeout(), recv_timeout = self:recv_timeout()}) if stream:send_msg(message.new_connect(session_id, {token = token})) and stream:flush() then local msg = stream:recv_msg() if msg then vprint(msg:body()) if msg:success() then ok = true else errors = msg:errors() end end end end if ok then cprint("${dim}%s: connected!", self) else cprint("${dim}%s: connect %s:%d failed, %s", self, addr, port, errors or "unknown") end -- update status local status = self:status() status.addr = addr status.port = port status.token = token status.user = user status.connected = ok status.session_id = session_id self:status_save() -- sync files if ok then self:sync() end end -- disconnect server function remote_build_client:disconnect() if not self:is_connected() then print("%s: has been disconnected!", self) return end -- update status local status = self:status() status.token = nil status.connected = false self:status_save() cprint("${dim}%s: disconnected!", self) end -- sync server files function remote_build_client:sync() assert(self:is_connected(), "%s: has been not connected!", self) local addr = self:addr() local port = self:port() local sock = assert(socket.connect(addr, port, {timeout = self:connect_timeout()}), "%s: server unreachable!", self) local session_id = self:session_id() local errors local ok = false local diff_files local xmakesrc = option.get("xmakesrc") cprint("${dim}%s: sync files in %s:%d ..", self, addr, port) while sock do -- diff files local stream = socket_stream(sock, {send_timeout = self:send_timeout(), recv_timeout = self:recv_timeout()}) diff_files, errors = self:_diff_files(stream, {xmakesrc = xmakesrc}) if not diff_files then break end if not diff_files.changed then ok = true break end -- do sync cprint("Uploading files ..") local send_ok = false if stream:send_msg(message.new_sync(session_id, diff_files, {token = self:token(), xmakesrc = xmakesrc and true or false}), {compress = true}) and stream:flush() then if self:_send_diff_files(stream, diff_files, {rootdir = xmakesrc}) then send_ok = true end end if not send_ok then errors = "send files failed" break end -- sync ok local msg = stream:recv_msg({timeout = -1}) if msg and msg:success() then vprint(msg:body()) ok = true elseif msg then errors = msg:errors() end break end if ok then cprint("${dim}%s: sync files ok!", self) else cprint("${dim}%s: sync files failed in %s:%d, %s", self, addr, port, errors or "unknown") end end -- pull server files function remote_build_client:pull(filepattern, outputdir) assert(self:is_connected(), "%s: has been not connected!", self) local addr = self:addr() local port = self:port() local sock = assert(socket.connect(addr, port, {timeout = self:connect_timeout()}), "%s: server unreachable!", self) local session_id = self:session_id() local errors local ok = false if not filepattern:find("*", 1, true) and os.isdir(filepattern) then filepattern = path.join(filepattern, "**") end if not outputdir then outputdir = os.curdir() end cprint("${dim}%s: pull %s in %s:%d ..", self, filepattern, addr, port) local stream = socket_stream(sock, {send_timeout = self:send_timeout(), recv_timeout = self:recv_timeout()}) if stream:send_msg(message.new_pull(session_id, filepattern, {token = self:token()})) and stream:flush() then local fileitems local msg = stream:recv_msg({timeout = -1}) if msg then dprint(msg:body()) if msg:success() then fileitems = msg:body().fileitems else errors = msg:errors() end end if fileitems then for _, fileitem in ipairs(fileitems) do print("recving %s ..", fileitem) if not stream:recv_file(path.normalize(path.join(outputdir, fileitem))) then errors = string.format("recv %s failed", fileitem) break end end msg = stream:recv_msg({timeout = -1}) if msg then dprint(msg:body()) if msg:success() then ok = true else errors = msg:errors() end end end end if ok then cprint("${dim}%s: pull files to %s!", self, outputdir) else cprint("${dim}%s: pull files failed in %s:%d, %s", self, addr, port, errors or "unknown") end end -- clean server files function remote_build_client:clean() assert(self:is_connected(), "%s: has been not connected!", self) local addr = self:addr() local port = self:port() local sock = assert(socket.connect(addr, port, {timeout = self:connect_timeout()}), "%s: server unreachable!", self) local session_id = self:session_id() local errors local ok = false cprint("${dim}%s: clean files in %s:%d ..", self, addr, port) local stream = socket_stream(sock, {send_timeout = self:send_timeout(), recv_timeout = self:recv_timeout()}) if stream:send_msg(message.new_clean(session_id, {token = self:token(), all = option.get("all")})) and stream:flush() then local msg = stream:recv_msg({timeout = -1}) if msg then vprint(msg:body()) if msg:success() then ok = true else errors = msg:errors() end end end if ok then cprint("${dim}%s: clean files ok!", self) else cprint("${dim}%s: clean files failed in %s:%d, %s", self, addr, port, errors or "unknown") end end -- run command function remote_build_client:runcmd(program, argv) assert(self:is_connected(), "%s: has been not connected!", self) local addr = self:addr() local port = self:port() local sock = assert(socket.connect(addr, port, {timeout = self:connect_timeout()}), "%s: server unreachable!", self) local session_id = self:session_id() local errors local ok = false local buff = bytes(8192) local command = os.args(table.join(program, argv)) local leftstr = "" cprint("${dim}%s: run '%s' in %s:%d ..", self, command, addr, port) local stream = socket_stream(sock, {send_timeout = self:send_timeout(), recv_timeout = self:recv_timeout()}) if stream:send_msg(message.new_runcmd(session_id, program, argv, {token = self:token()})) and stream:flush() then local stdin_opt = {stop = false} local group_name = "remote_build/runcmd" scheduler.co_group_begin(group_name, function (co_group) scheduler.co_start(self._read_stdin, self, stream, stdin_opt) end) while true do local msg = stream:recv_msg({timeout = -1}) if msg then if msg:is_data() then local data = stream:recv(buff, msg:body().size) if data then leftstr = leftstr .. data:str() local pos = leftstr:lastof("\n", true) if pos then cprint(leftstr:sub(1, pos - 1)) leftstr = leftstr:sub(pos + 1) end else errors = string.format("recv output data(%d) failed!", msg:body().size) break end elseif msg:is_end() then ok = true break else if msg:success() then ok = true else errors = msg:errors() end break end else break end end stdin_opt.stop = true scheduler.co_group_wait(group_name) end if #leftstr > 0 then cprint(leftstr) end if ok then cprint("${dim}%s: run command ok!", self) else cprint("${dim}%s: run command failed in %s:%d, %s", self, addr, port, errors or "unknown") end io.flush() end -- is connected? function remote_build_client:is_connected() return self:status().connected end -- get the status function remote_build_client:status() local status = self._STATUS local statusfile = self:statusfile() if not status then if os.isfile(statusfile) then status = io.load(statusfile) end status = status or {} self._STATUS = status end return status end -- save status function remote_build_client:status_save() io.save(self:statusfile(), self:status()) end -- get the status file function remote_build_client:statusfile() return path.join(self:workdir(), "status.txt") end -- get the project directory function remote_build_client:projectdir() return self._PROJECTDIR end -- get working directory function remote_build_client:workdir() return self._WORKDIR end -- get user token function remote_build_client:token() return self._TOKEN or self:status().token end -- set user token function remote_build_client:token_set(token) self._TOKEN = token end -- get the session id, only for unique project function remote_build_client:session_id() return self:status().session_id or hash.uuid(option.get("session")):split("-", {plain = true})[1]:lower() end -- init host address and token -- -- Supported configuration formats: -- -- New format (multiple hosts): -- remote_build = { -- hosts = { -- { -- name = "windows", -- connect = "10.5.139.8:9691", -- token = "0e052f8c7153a6111d5a418e514020ee" -- }, -- { -- name = "linux", -- connect = "192.168.1.100:9691", -- token = "abc123def456" -- } -- } -- } -- -- Old format (single host, backward compatible): -- remote_build = { -- connect = "10.5.139.8:9691", -- token = "0e052f8c7153a6111d5a418e514020ee" -- } -- -- Usage: -- xmake service --connect --host=windows # connect by name -- xmake service --connect --host=10.5.139.8:9691 # connect by address -- xmake service --connect # use default host function remote_build_client:_init_host() local remote_build_config = config.get("remote_build") or {} local address local token -- if already connected, use status first if self:is_connected() then local status = self:status() if status.addr and status.port then address = status.addr .. ":" .. status.port token = status.token self:address_set(address) if token then self:token_set(token) end return end end -- support new hosts format if remote_build_config.hosts then local host_name = option.get("host") if host_name then -- find host by name or address for _, host in ipairs(remote_build_config.hosts) do local host_ip, _ = host.connect:split(":", {plain = true}) if host.name == host_name or host.connect == host_name or (host_ip == host_name and not host_name:find(":", 1, true)) then address = host.connect token = host.token break end end if not address then raise("host '%s' not found in configuration!", host_name) end else -- use first host as default if #remote_build_config.hosts > 0 then address = remote_build_config.hosts[1].connect token = remote_build_config.hosts[1].token end end -- support old format for backward compatibility elseif remote_build_config.connect then address = remote_build_config.connect token = remote_build_config.token end if not address then raise("config(remote_build.connect) or config(remote_build.hosts) not found!") end self:address_set(address) if token then self:token_set(token) end end -- set the given client address function remote_build_client:address_set(address) local addr, port, user = self:address_parse(address) self._ADDR = addr self._PORT = port self._USER = user end -- get user name function remote_build_client:user() return self._USER or self:status().user end -- get the ip address function remote_build_client:addr() return self._ADDR or self:status().addr end -- get the address port function remote_build_client:port() return self._PORT or self:status().port end -- get filesync function remote_build_client:_filesync() return self._FILESYNC end -- diff server files function remote_build_client:_diff_files(stream, opt) opt = opt or {} assert(self:is_connected(), "%s: has been not connected!", self) print("Scanning files ..") local filesync = self:_filesync() if opt.xmakesrc then assert(os.isdir(opt.xmakesrc), "%s: %s not found!", opt.xmakesrc) filesync = new_filesync(opt.xmakesrc, path.join(self:workdir(), "xmakesrc_manifest.txt")) filesync:ignorefiles_add(".git/**") end local manifest, filecount = filesync:snapshot() local session_id = self:session_id() local count = 0 local result, errors cprint("Comparing ${bright}%d${clear} files ..", filecount) if stream:send_msg(message.new_diff(session_id, manifest, {token = self:token(), xmakesrc = opt.xmakesrc and true or false}), {compress = true}) and stream:flush() then local msg = stream:recv_msg({timeout = -1}) if msg and msg:success() then result = msg:body().manifest if result then for _, fileitem in ipairs(result.inserted) do if count < 8 then cprint(" ${green}[+]: ${clear}%s", fileitem) end count = count + 1 end for _, fileitem in ipairs(result.modified) do if count < 8 then cprint(" ${yellow}[*]: ${clear}%s", fileitem) end count = count + 1 end for _, fileitem in ipairs(result.removed) do if count < 8 then cprint(" ${red}[-]: ${clear}%s", fileitem) end count = count + 1 end if count >= 8 then print(" ...") end end elseif msg then errors = msg:errors() end end cprint("${bright}%d${clear} files has been changed!", count) return result, errors end -- send diff files function remote_build_client:_send_diff_files(stream, diff_files, opt) opt = opt or {} local count = 0 local totalsize = 0 local compressed_size = 0 local totalcount = #(diff_files.inserted or {}) + #(diff_files.modified or {}) local time = os.mclock() local startime = time for _, fileitem in ipairs(diff_files.inserted) do local filepath = fileitem if opt.rootdir and not path.is_absolute(fileitem) then filepath = path.absolute(fileitem, opt.rootdir) end local filesize = os.filesize(filepath) if os.mclock() - time > 1000 then cprint("Uploading ${bright}%d%%${clear} ..", math.floor(count * 100 / totalcount)) time = os.mclock() end vprint("uploading %s, %d bytes ..", fileitem, filesize) local sent, compressed_real = stream:send_file(filepath, {compress = filesize > 4096}) if not sent then return false end count = count + 1 totalsize = totalsize + filesize compressed_size = compressed_size + compressed_real end for _, fileitem in ipairs(diff_files.modified) do local filepath = fileitem if opt.rootdir and not path.is_absolute(fileitem) then filepath = path.absolute(fileitem, opt.rootdir) end local filesize = os.filesize(filepath) if os.mclock() - time > 1000 then cprint("Uploading ${bright}%d%%${clear} ..", math.floor(count * 100 / totalcount)) time = os.mclock() end vprint("uploading %s, %d bytes ..", fileitem, filesize) local sent, compressed_real = stream:send_file(filepath, {compress = filesize > 4096}) if not sent then return false end count = count + 1 totalsize = totalsize + filesize compressed_size = compressed_size + compressed_real end cprint("Uploading ${bright}%s%%${clear} ..", totalcount > 0 and math.floor(count * 100 / totalcount) or 0) cprint("${bright}%s${clear} files, ${bright}%s (%s%%)${clear} bytes are uploaded, spent ${bright}%s${clear} ms.", totalcount, compressed_size, totalsize > 0 and math.floor(compressed_size * 100 / totalsize) or 0, os.mclock() - startime) return stream:flush() end -- read stdin data function remote_build_client:_read_stdin(stream, opt) local term = tty.term() if term == "msys2" or term == "cygwin" then wprint("we cannot capture stdin on %s, please pass `-y` option to xmake command or use cmd/powershell terminal!", term) end while not opt.stop do -- FIXME, io.readable is invalid on msys2/cygwin, it always return false -- @see https://github.com/xmake-io/xmake/issues/2504 if io.readable() then local line = io.read("L") -- with crlf if line and #line > 0 then local ok = false local data = bytes(line) if stream:send_msg(message.new_data(0, data:size(), {token = self:token()})) then if stream:send(data) and stream:flush() then ok = true end end if not ok then break end else -- we need to avoid always reading stdin -- https://github.com/xmake-io/xmake/issues/3422 os.sleep(1) end else os.sleep(500) end end -- say bye if stream:send_msg(message.new_end(self:session_id(), {token = self:token()})) then stream:flush() end end function remote_build_client:__tostring() return "" end -- is connected? we cannot depend on client:init when run action function is_connected() -- the current process is in service? we cannot enable it if os.getenv("XMAKE_IN_SERVICE") then return false end local projectdir = os.projectdir() local projectfile = os.projectfile() if projectfile and os.isfile(projectfile) and projectdir then local workdir = path.join(project_config.directory(), "service", "remote_build") local statusfile = path.join(workdir, "status.txt") if os.isfile(statusfile) then local status = io.load(statusfile) if status and status.connected then return true end end end end -- new a client instance function new() local instance = remote_build_client() instance:init() return instance end -- get the singleton function singleton() local instance = _g.singleton if not instance then config.load() instance = new() _g.singleton = instance end return instance end function main() return new() end