From ca381bdbd28a68c44a3d25bebc4959606c76256d Mon Sep 17 00:00:00 2001 From: Chalith Desaman Date: Wed, 30 Nov 2022 17:10:14 +0530 Subject: [PATCH] Sashimono reconfiguration support in message board and sagent (#205) --- installer/setup.sh | 162 +++++++++++++++++++++++++++++++++-- mb-xrpl/app.js | 6 +- mb-xrpl/lib/appenv.js | 1 + mb-xrpl/lib/config-helper.js | 7 ++ mb-xrpl/lib/message-board.js | 13 ++- mb-xrpl/lib/setup.js | 162 ++++++++++++++++++++++++++++++++++- src/main.cpp | 96 ++++++++++++++++++++- 7 files changed, 433 insertions(+), 14 deletions(-) diff --git a/installer/setup.sh b/installer/setup.sh index 8a0db03..e76e1b6 100755 --- a/installer/setup.sh +++ b/installer/setup.sh @@ -75,7 +75,7 @@ if [ -f /etc/systemd/system/$SASHIMONO_SERVICE.service ] && [ -d $SASHIMONO_BIN && echo "$evernode is already installed on your host. Use the 'evernode' command to manage your host." \ && exit 1 - [ "$1" != "uninstall" ] && [ "$1" != "status" ] && [ "$1" != "list" ] && [ "$1" != "update" ] && [ "$1" != "log" ] && [ "$1" != "applyssl" ] && [ "$1" != "transfer" ]\ + [ "$1" != "uninstall" ] && [ "$1" != "status" ] && [ "$1" != "list" ] && [ "$1" != "update" ] && [ "$1" != "log" ] && [ "$1" != "applyssl" ] && [ "$1" != "transfer" ] && [ "$1" != "reconfig" ] \ && echomult "$evernode host management tool \nYour host is registered on $evernode. \nSupported commands: @@ -83,6 +83,7 @@ if [ -f /etc/systemd/system/$SASHIMONO_SERVICE.service ] && [ -d $SASHIMONO_BIN \nlist - View contract instances running on this system \nlog - Generate evernode log file. \napplyssl - Apply new SSL certificates for contracts. + \reconfig - Change the host configuration. \nupdate - Check and install $evernode software updates \ntransfer - Initiate an $evernode transfer for your machine \nuninstall - Uninstall and deregister from $evernode" \ @@ -221,6 +222,16 @@ function validate_inet_addr() { return 0 } +function validate_positive_decimal() { + ! [[ $1 =~ ^(0*[1-9][0-9]*(\.[0-9]+)?|0+\.[0-9]*[1-9][0-9]*)$ ]] && return 1 + return 0 +} + +function validate_ws_url() { + ! [[ $1 =~ ^(wss?:\/\/)([^\/|^:|^ ]{3,})(:([0-9]{1,5}))?$ ]] && return 1 + return 0 +} + function set_inet_addr() { if $interactive ; then @@ -390,17 +401,17 @@ function set_instance_alloc() { while true ; do read -p "Specify the total RAM in megabytes to distribute among all contract instances: " ramMB " && exit 1 + + [ ! -z $alloc_cpu ] && [ $alloc_cpu != 0 ] && ( ! ( validate_positive_decimal $alloc_cpu && [[ $alloc_cpu -le 1000000 ]] ) ) && echo "Invalid cpu allocation." && exit 1 + [ ! -z $alloc_ramKB ] && [ $alloc_ramKB != 0 ] && ! validate_positive_decimal $alloc_ramKB && echo "Invalid ram size." && exit 1 + [ ! -z $alloc_swapKB ] && [ $alloc_swapKB != 0 ] && ! validate_positive_decimal $alloc_swapKB && echo "Invalid swap size." && exit 1 + [ ! -z $alloc_diskKB ] && [ $alloc_diskKB != 0 ] && ! validate_positive_decimal $alloc_diskKB && echo "Invalid disk size." && exit 1 + [ ! -z $alloc_instcount ] && [ $alloc_instcount != 0 ] && ! validate_positive_decimal $alloc_instcount && echo "Invalid instance count." && exit 1 + [ ! -z $lease_amount ] && [ $lease_amount != 0 ] && ! validate_positive_decimal $lease_amount && echo "Invalid lease amount." && exit 1 + [ ! -z $rippled_server ] && ! validate_ws_url $rippled_server && echo "Rippled URL must be a valid URL that starts with 'wss://' ." && exit 1 + + reconfig + + echo "Successfully changed the configuration!" fi [ "$mode" != "uninstall" ] && check_installer_pending_finish diff --git a/mb-xrpl/app.js b/mb-xrpl/app.js index 4220116..829daaa 100644 --- a/mb-xrpl/app.js +++ b/mb-xrpl/app.js @@ -51,6 +51,9 @@ async function main() { else if (process.argv.length === 3 && process.argv[2] === 'upgrade') { await new Setup().upgrade(); } + else if ((process.argv.length === 5 || process.argv.length === 6) && process.argv[2] === 'reconfig') { + await new Setup().changeConfig(process.argv[3], process.argv[5], process.argv[4]); + } else if (process.argv[2] === 'help') { console.log(`Usage: node index.js - Run message board. @@ -62,6 +65,7 @@ async function main() { node index.js deregister - Deregister the host from Evernode. node index.js reginfo - Display Evernode registration info. node index.js upgrade - Upgrade message board data. + node index.js reconfig [leaseAmount] [totalInstanceCount] [rippledServer] - Update message board configuration. node index.js help - Print help.`); } else { @@ -76,7 +80,7 @@ async function main() { console.log('Data dir: ' + appenv.DATA_DIR); console.log('Using Sashimono cli: ' + appenv.SASHI_CLI_PATH); - const mb = new MessageBoard(appenv.CONFIG_PATH, appenv.SECRET_CONFIG_PATH, appenv.DB_PATH, appenv.SASHI_CLI_PATH, appenv.SASHI_DB_PATH); + const mb = new MessageBoard(appenv.CONFIG_PATH, appenv.SECRET_CONFIG_PATH, appenv.DB_PATH, appenv.SASHI_CLI_PATH, appenv.SASHI_DB_PATH, appenv.SASHI_CONFIG_PATH); await mb.init(); } diff --git a/mb-xrpl/lib/appenv.js b/mb-xrpl/lib/appenv.js index 1d227d4..9dfd54f 100644 --- a/mb-xrpl/lib/appenv.js +++ b/mb-xrpl/lib/appenv.js @@ -19,6 +19,7 @@ appenv = { DB_TABLE_NAME: 'leases', DB_UTIL_TABLE_NAME: 'util_data', SASHI_DB_PATH: (appenv.IS_DEV_MODE ? "../build/" : path.join(appenv.DATA_DIR, '../')) + "sa.sqlite", + SASHI_CONFIG_PATH: (appenv.IS_DEV_MODE ? "../build/" : path.join(appenv.DATA_DIR, '../')) + "sa.cfg", SASHI_TABLE_NAME: 'instances', LAST_WATCHED_LEDGER: 'last_watched_ledger', ACQUIRE_LEASE_TIMEOUT_THRESHOLD: 0.8, diff --git a/mb-xrpl/lib/config-helper.js b/mb-xrpl/lib/config-helper.js index 2465981..bc72faa 100644 --- a/mb-xrpl/lib/config-helper.js +++ b/mb-xrpl/lib/config-helper.js @@ -39,6 +39,13 @@ class ConfigHelper { fs.writeFileSync(secretConfigPath, JSON.stringify(secretCfg, null, 2), { mode: 0o600 }); // Set file permission so only current user can read/write. fs.writeFileSync(configPath, JSON.stringify(publicCfg, null, 2), { mode: 0o644 }); // Set file permission so only current user can read/write and others can read. } + + static readSashiConfig(sashiConfigPath) { + if (!fs.existsSync(sashiConfigPath)) + throw `Sashimono configuration file does not exist at ${sashiConfigPath}`; + + return JSON.parse(fs.readFileSync(sashiConfigPath).toString()); + } } module.exports = { diff --git a/mb-xrpl/lib/message-board.js b/mb-xrpl/lib/message-board.js index 905a253..d8efcad 100644 --- a/mb-xrpl/lib/message-board.js +++ b/mb-xrpl/lib/message-board.js @@ -23,7 +23,7 @@ class MessageBoard { #graceTimeoutRef = null; #lastHaltedTime = null; - constructor(configPath, secretConfigPath, dbPath, sashiCliPath, sashiDbPath) { + constructor(configPath, secretConfigPath, dbPath, sashiCliPath, sashiDbPath, sashiConfigPath) { this.configPath = configPath; this.secretConfigPath = secretConfigPath; this.leaseTable = appenv.DB_TABLE_NAME; @@ -37,7 +37,8 @@ class MessageBoard { this.sashiCli = new SashiCLI(sashiCliPath); this.db = new SqliteDatabase(dbPath); this.sashiDb = new SqliteDatabase(sashiDbPath); - this.sashiTable = appenv.SASHI_TABLE_NAME + this.sashiTable = appenv.SASHI_TABLE_NAME; + this.sashiConfigPath = sashiConfigPath; } async init() { @@ -84,10 +85,14 @@ class MessageBoard { // Catch up missed transactions based on the previously updated "last_watched_ledger" record (checkpoint). await this.#catchupMissedLeases().catch(console.error); + // Load the sashimono config. + const sashiConfig = ConfigHelper.readSashiConfig(this.sashiConfigPath); this.activeInstanceCount = this.expiryList.length; console.log(`Active instance count: ${this.activeInstanceCount}`); // Update the registry with the active instance count. - await this.hostClient.updateRegInfo(this.activeInstanceCount, this.cfg.version); + await this.hostClient.updateRegInfo(this.activeInstanceCount, this.cfg.version, sashiConfig.system.max_instance_count, null, null, + sashiConfig.system.max_cpu_us, Math.floor((sashiConfig.system.max_mem_kbytes + sashiConfig.system.max_swap_kbytes) / 1000), + Math.floor(sashiConfig.system.max_storage_kbytes / 1000)); this.db.close(); this.xrplApi.on(evernode.XrplApiEvents.LEDGER, async (e) => { @@ -601,7 +606,7 @@ class MessageBoard { // Burn the NFTs and recreate the offer and send back the lease amount back to the tenant. await this.hostClient.expireLease(nfTokenId, tenantAddress).catch(console.error); // We refresh the config here, So if the purchaserTargetPrice is updated by the purchaser service, the new value will be taken. - this.hostClient.refreshConfig(); + await this.hostClient.refreshConfig(); const leaseAmount = this.cfg.xrpl.leaseAmount ? this.cfg.xrpl.leaseAmount : parseFloat(this.hostClient.config.purchaserTargetPrice); await this.hostClient.offerLease(leaseIndex, leaseAmount, appenv.TOS_HASH).catch(console.error); } diff --git a/mb-xrpl/lib/setup.js b/mb-xrpl/lib/setup.js index e8b7f2c..449e88e 100644 --- a/mb-xrpl/lib/setup.js +++ b/mb-xrpl/lib/setup.js @@ -185,6 +185,9 @@ class Setup { const hostClient = new evernode.HostClient(acc.address, acc.secret); await hostClient.connect(); + if (hostClient.config.hostRegFee > (await hostClient.getEVRBalance())) + throw `EVR balance in the account is less than the registration fee (${hostClient.config.hostRegFee}EVRs).`; + // Sometimes we may get 'tecPATH_DRY' error from rippled when some servers in the testnet cluster // haven't still updated the ledger. In such cases, we retry several times before giving up. let attempts = 0; @@ -311,8 +314,8 @@ class Setup { } } - // Initiate Host Machine Transfer. + // Initiate Host Machine Transfer. async transfer(transfereeAddress) { console.log("Transferring host..."); const acc = this.#getConfig().xrpl; @@ -322,7 +325,162 @@ class Setup { await hostClient.connect(); await hostClient.transfer(transfereeAddress); await this.burnMintedNfts(hostClient.xrplAcc); - await hostClient.disconnect(); + } + + // Change the message board configurations. + async changeConfig(leaseAmount, rippledServer, totalInstanceCount) { + + // Update the configuration. + const cfg = this.#getConfig(); + + if (leaseAmount && isNaN(leaseAmount)) + throw 'Lease amount should be a number'; + else if (rippledServer && !rippledServer.match(/^(wss?:\/\/)([^\/|^:|^ ]{3,})(:([0-9]{1,5}))?$/g)) + throw 'Provided Rippled Server is invalid'; + else if (totalInstanceCount && isNaN(totalInstanceCount)) + throw 'Maximum instance count should be a number'; + + const leaseAmountParsed = leaseAmount ? parseInt(leaseAmount) : 0; + const totalInstanceCountParsed = totalInstanceCount ? parseInt(totalInstanceCount) : 0; + + // Return if not changed. + if (!totalInstanceCount && + (!leaseAmount || cfg.xrpl.leaseAmount == leaseAmount) && + (!rippledServer || cfg.xrpl.rippledServer == leaseAmount)) + return; + + await this.recreateLeases(leaseAmountParsed, totalInstanceCountParsed, rippledServer, cfg); + + if (leaseAmountParsed) + cfg.xrpl.leaseAmount = leaseAmountParsed; + if (rippledServer) + cfg.xrpl.rippledServer = rippledServer; + this.#saveConfig(cfg); + } + + // Recreate unsold NFTs + async recreateLeases(leaseAmount, totalInstanceCount, rippledServer, existingCfg) { + // Get sold NFTs. + const db = new SqliteDatabase(appenv.DB_PATH); + const leaseTable = appenv.DB_TABLE_NAME; + + db.open(); + const leaseRecords = (await db.getValues(leaseTable).finally(() => { db.close() })).filter(i => (i.status === "Acquired" || i.status === "Extended")); + const soldCount = leaseRecords.length; + + if (totalInstanceCount && soldCount > totalInstanceCount) + throw `There are ${soldCount} active instances, So max instance count cannot be less than that.`; + + const acc = existingCfg.xrpl; + let xrplApi; + let hostClient; + + async function initClients(rippledServer) { + setEvernodeDefaults(acc.registryAddress, rippledServer); + xrplApi = new evernode.XrplApi(); + hostClient = new evernode.HostClient(acc.address, acc.secret, { xrplApi: xrplApi }); + await xrplApi.connect(); + await hostClient.connect(); + } + + async function deinitClients() { + await hostClient.disconnect(); + await xrplApi.disconnect(); + } + + await initClients(acc.rippledServer); + + // Get unsold NFTs. + const unsoldNfts = (await hostClient.xrplAcc.getNfts()).filter(n => n.URI.startsWith(evernode.EvernodeConstants.LEASE_NFT_PREFIX_HEX)) + .map(n => { return { nfTokenId: n.NFTokenID, leaseIndex: evernode.UtilHelpers.decodeLeaseNftUri(n.URI).leaseIndex }; }); + const unsoldCount = unsoldNfts.length; + + // Return if not changed. + if (!leaseAmount && !rippledServer && (!totalInstanceCount || (soldCount + unsoldCount) == totalInstanceCount)) { + await deinitClients(); + return; + } + + async function getVacantLeaseIndexes(includeUnsold = true) { + let acquired = includeUnsold ? [] : unsoldNfts.map(n => n.leaseIndex); + let vacant = []; + for (const l of leaseRecords) { + try { + const tenantAddress = l.tenant_xrp_address; + const nfTokenId = l.container_name; + const nft = (await (new evernode.XrplAccount(tenantAddress, null, { xrplApi: xrplApi })).getNfts())?.find(n => n.NFTokenID == nfTokenId); + if (nft) { + const index = evernode.UtilHelpers.decodeLeaseNftUri(nft.URI).leaseIndex; + acquired.push(index); + } + } catch { + } + } + let i = 0; + while (vacant.length + acquired.length < totalInstanceCount) { + if (!acquired.includes(i)) + vacant.push(i); + i++; + } + return vacant; + } + + let nftsToBurn = []; + let nftIndexesToCreate = []; + // If lease amount is changed we need to burn all the unsold nfts + if ((leaseAmount && acc.leaseAmount !== leaseAmount) || (rippledServer && acc.rippledServer !== rippledServer)) { + nftsToBurn = unsoldNfts; + + // If total instance count also changed decide the nfts that we need to create. + if (totalInstanceCount && (soldCount + unsoldCount) !== totalInstanceCount) { + // If less than current count, Create only first chuck of the burned nfts. + // If greater than current count, create burned nfts plus extra nfts that are needed. + if (totalInstanceCount < soldCount + unsoldCount) { + nftIndexesToCreate = nftsToBurn.map(n => n.leaseIndex).sort((a, b) => a - b).slice(0, totalInstanceCount - soldCount); + } + else { + nftIndexesToCreate = await getVacantLeaseIndexes(); + } + } + } + // If only instance count is changed decide whether we need to add or burn comparing the current count and updated count. + else if (totalInstanceCount && (soldCount + unsoldCount) !== totalInstanceCount) { + if (totalInstanceCount < soldCount + unsoldCount) { + nftsToBurn = unsoldNfts.sort((a, b) => a.leaseIndex - b.leaseIndex).slice(totalInstanceCount - soldCount); + nftIndexesToCreate = []; + } + else { + nftsToBurn = []; + nftIndexesToCreate = await getVacantLeaseIndexes(false); + } + } + + for (const nft of nftsToBurn) { + try { + await hostClient.expireLease(nft.nfTokenId); + } + catch (e) { + console.error(e); + } + } + + // If rippled server is changed, create new nfts from new server. + if (rippledServer && rippledServer !== acc.rippledServer) { + await initClients(rippledServer); + } + + for (const idx of nftIndexesToCreate) { + try { + await hostClient.offerLease(idx, + leaseAmount ? leaseAmount : (acc.leaseAmount ? acc.leaseAmount : parseFloat(this.hostClient.config.purchaserTargetPrice)), + appenv.TOS_HASH); + } + catch (e) { + console.error(e); + } + } + + await deinitClients(); } } diff --git a/src/main.cpp b/src/main.cpp index 86a03f8..5983054 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -21,6 +21,7 @@ std::cerr << "sagent new [data_dir] [host_addr] [registry_addr] [inst_count] [cpu_us] [ram_kbytes] [swap_kbytes] [disk_kbytes]\n"; \ std::cerr << "sagent run [data_dir]\n"; \ std::cerr << "sagent upgrade [data_dir]\n"; \ + std::cerr << "sagent reconfig [data_dir] [inst_count] [cpu_us] [ram_kbytes] [swap_kbytes] [disk_kbytes]\n"; \ std::cerr << "Example: sagent run /etc/sashimono\n"; \ return -1; \ } @@ -40,7 +41,8 @@ int parse_cmd(int argc, char **argv) if ((conf::ctx.command == "new" && argc >= 2 && argc <= 12) || (conf::ctx.command == "run" && argc >= 2 && argc <= 3) || (conf::ctx.command == "upgrade" && argc >= 2 && argc <= 3) || - (conf::ctx.command == "version" && argc == 2)) + (conf::ctx.command == "version" && argc == 2) || + (conf::ctx.command == "reconfig" && argc >= 2 && argc <= 8)) return 0; } @@ -208,6 +210,98 @@ int main(int argc, char **argv) if (conf::write_config(conf::cfg) != 0) return -1; } + else if (conf::ctx.command == "reconfig") + { + conf::set_dir_paths(argv[0], (argc >= 3) ? argv[2] : ""); + + size_t inst_count = 0, cpu_us = 0, ram_kbytes = 0, swap_kbytes = 0, disk_kbytes = 0; + + if (((argc >= 4) && (util::stoull(argv[3], inst_count) != 0)) || + ((argc >= 5) && (util::stoull(argv[4], cpu_us) != 0)) || + ((argc >= 6) && (util::stoull(argv[5], ram_kbytes) != 0)) || + ((argc >= 7) && (util::stoull(argv[6], swap_kbytes) != 0)) || + ((argc >= 8) && (util::stoull(argv[7], disk_kbytes) != 0))) + { + std::cerr << "Invalid Sashimono Agent config update args.\n"; + std::cerr << inst_count << ", " << cpu_us << ", " << ram_kbytes << ", " + << swap_kbytes << ", " << disk_kbytes << "\n"; + return 1; + } + + if (conf::init() != 0) + return 1; + + // Return if not changed. + if ((inst_count == 0 || conf::cfg.system.max_instance_count == inst_count) && + (cpu_us == 0 || conf::cfg.system.max_cpu_us == cpu_us) && + (ram_kbytes == 0 || conf::cfg.system.max_mem_kbytes == ram_kbytes) && + (swap_kbytes == 0 || conf::cfg.system.max_swap_kbytes == swap_kbytes) && + (disk_kbytes == 0 || conf::cfg.system.max_storage_kbytes == disk_kbytes)) + return 0; + + salog::init(); + + if (hp::init() == -1) + return 1; + + std::vector instances; + hp::get_instance_list(instances); + hp::deinit(); + + // If there are active instances, do not allow reducing the resources per instance. Otherwise we allow adjusting resources. + if (inst_count != 0 && instances.size() > inst_count) + { + std::cerr << "There are " << instances.size() << " active instances, So max instance count cannot be less than that.\n"; + return 1; + } + else if (instances.size() > 0) + { + size_t new_count = inst_count != 0 ? inst_count : conf::cfg.system.max_instance_count; + size_t new_cpu = cpu_us != 0 ? cpu_us : conf::cfg.system.max_cpu_us; + size_t new_ram = ram_kbytes != 0 ? ram_kbytes : conf::cfg.system.max_mem_kbytes; + size_t new_swap = swap_kbytes != 0 ? swap_kbytes : conf::cfg.system.max_swap_kbytes; + size_t new_disk = disk_kbytes != 0 ? disk_kbytes : conf::cfg.system.max_storage_kbytes; + + if (new_cpu / new_count < conf::cfg.system.max_cpu_us / conf::cfg.system.max_instance_count) + { + std::cerr << "CPU per instance should be greater than " << conf::cfg.system.max_cpu_us / conf::cfg.system.max_instance_count << " Micro Sec.\n"; + return 1; + } + else if (new_ram / new_count < conf::cfg.system.max_mem_kbytes / conf::cfg.system.max_instance_count) + { + std::cerr << "RAM per instance should be greater than " << conf::cfg.system.max_mem_kbytes / conf::cfg.system.max_instance_count << " KB.\n"; + return 1; + } + else if (new_swap / new_count < conf::cfg.system.max_swap_kbytes / conf::cfg.system.max_instance_count) + { + std::cerr << "Swap per instance should be greater than " << conf::cfg.system.max_swap_kbytes / conf::cfg.system.max_instance_count << " KB.\n"; + return 1; + } + else if (new_disk / new_count < conf::cfg.system.max_storage_kbytes / conf::cfg.system.max_instance_count) + { + std::cerr << "Storage per instance should be greater than " << conf::cfg.system.max_storage_kbytes / conf::cfg.system.max_instance_count << " KB.\n"; + return 1; + } + } + + if (inst_count > 0) + conf::cfg.system.max_instance_count = inst_count; + + if (cpu_us > 0) + conf::cfg.system.max_cpu_us = cpu_us; + + if (ram_kbytes > 0) + conf::cfg.system.max_mem_kbytes = ram_kbytes; + + if (swap_kbytes > 0) + conf::cfg.system.max_swap_kbytes = swap_kbytes; + + if (disk_kbytes > 0) + conf::cfg.system.max_storage_kbytes = disk_kbytes; + + if (conf::write_config(conf::cfg) != 0) + return -1; + } return 0; } \ No newline at end of file