diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2de73230a..8932914e5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -208,3 +208,34 @@ zombienet-tests-integration: retry: 2 tags: - zombienet-polkadot-integration-test + +zombienet-dummy-chain-upgrade: + stage: deploy + <<: *kubernetes-env + image: "paritypr/zombienet:${CI_COMMIT_SHORT_SHA}" + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + - if: $CI_COMMIT_REF_NAME == "master" + - if: $CI_COMMIT_REF_NAME =~ /^[0-9]+$/ # PRs + - if: $CI_COMMIT_REF_NAME =~ /^v[0-9]+\.[0-9]+.*$/ # i.e. v1.0, v2.1rc1 + # needs: + # - job: publish-docker-pr + + variables: + GH_DIR: 'https://github.com/paritytech/zombienet/tree/feat-chain-upgrade-command/tests' + + before_script: + - echo "Zombie-net Tests Config" + - echo "paritypr/zombienet:${CI_COMMIT_SHORT_SHA}" + - echo "${GH_DIR}" + - export DEBUG=zombie* + - export ZOMBIENET_INTEGRATION_TEST_IMAGE="docker.io/paritypr/synth-wave:master" + - export COL_IMAGE="docker.io/paritypr/colander:master" + + script: + - /home/nonroot/zombie-net/scripts/run-test-local-env-manager.sh + --test="0003-parachains-upgrade-smoke-test.feature" + allow_failure: true + retry: 2 + tags: + - zombienet-polkadot-integration-test diff --git a/package.json b/package.json index fdee8a379..d4deb79cb 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "debug": "^4.3.2", "execa": "^5.1.1", "mocha": "^9.1.2", + "napi-maybe-compressed-blob": "0.0.2", "tmp-promise": "^3.0.2", "toml": "^3.0.0", "yaml": "^2.0.0-9" diff --git a/scripts/docker/zombienet_injected.Dockerfile b/scripts/docker/zombienet_injected.Dockerfile index a54552ac6..87e65bc8f 100644 --- a/scripts/docker/zombienet_injected.Dockerfile +++ b/scripts/docker/zombienet_injected.Dockerfile @@ -41,6 +41,7 @@ WORKDIR /home/nonroot/zombie-net COPY ./artifacts/dist ./dist COPY static-configs ./static-configs COPY scripts ./scripts +COPY tests ./tests COPY artifacts/package* ./ RUN npm install --production RUN chown -R nonroot. /home/nonroot diff --git a/scripts/run-test-env-manager.sh b/scripts/run-test-env-manager.sh index 42450a12c..d32b5a1e1 100755 --- a/scripts/run-test-env-manager.sh +++ b/scripts/run-test-env-manager.sh @@ -214,10 +214,15 @@ function run_test { set -x set +e if [[ ! -z $TEST_TO_RUN ]]; then + TEST_FOUND=0 for i in $(find ${OUTPUT_DIR} -name "${TEST_TO_RUN}"| head -1); do + TEST_FOUND=1 zombie test $i EXIT_STATUS=$? done; + if [[ $TEST_FOUND -lt 1 ]]; then + EXIT_STATUS=1 + fi; else for i in $(find ${OUTPUT_DIR} -name *.feature | sort); do echo "running test: ${i}" diff --git a/scripts/run-test-local-env-manager.sh b/scripts/run-test-local-env-manager.sh new file mode 100755 index 000000000..0904b4e1c --- /dev/null +++ b/scripts/run-test-local-env-manager.sh @@ -0,0 +1,161 @@ +#!/bin/bash + +# Based on https://gitlab.parity.io/parity/simnet/-/blob/master/scripts/run-test-environment-manager-v2.sh + +set -eou pipefail + + +function usage { + cat << EOF +DEPENDENCY 1: gcloud +https://cloud.google.com/sdk/docs/install + +DEPENDENCY 2: kubectl +gcloud components install kubectl + + +Usage: ${SCRIPT_NAME} OPTION + +OPTION + -t, --test OPTIONAL Test file to run + If omitted "all" test in the tests directory will be used. + -h, --help OPTIONAL Print this help message + -o, --output-dir OPTIONAL + Path to dir where to save contens of --github-remote-dir + Defaults to ${SCRIPT_PATH} + specified, it will be ifered from there. + +EXAMPLES +Run tests +${SCRIPT_NAME} -g https://github.com/paritytech/polkadot/tree/master/zombienet_tests + +EOF +} + +function main { + # Main entry point for the script + set_defaults_for_globals + parse_args "$@" + create_isolated_dir + copy_to_isolated + run_test + log INFO "Exit status is ${EXIT_STATUS}" + exit "${EXIT_STATUS}" +} + +function create_isolated_dir { + TS=$(date +%s) + ISOLATED=${OUTPUT_DIR}/${TS} + mkdir -p ${ISOLATED} + OUTPUT_DIR="${ISOLATED}" +} + +function set_defaults_for_globals { + # DEFAULT VALUES for variables used for testing different projects + SCRIPT_NAME="$0" + SCRIPT_PATH=$(dirname "$0") # relative + SCRIPT_PATH=$(cd "${SCRIPT_PATH}" && pwd) # absolutized and normalized + + export GOOGLE_CREDENTIALS="/etc/zombie-net/sa-zombie.json" + + cd "${SCRIPT_PATH}" + + EXIT_STATUS=0 + GH_REMOTE_DIR="" + TEST_TO_RUN="" + + + LAUNCH_ARGUMENTS="" + USE_LOCAL_TESTS=false + OUTPUT_DIR="${SCRIPT_PATH}" +} + +function parse_args { + function needs_arg { + if [ -z "${OPTARG}" ]; then + log DIE "No arg for --${OPT} option" + fi + } + + function check_args { + if [[ -n "${GH_REMOTE_DIR}" && + ! "${GH_REMOTE_DIR}" =~ https:\/\/github.com\/ ]] ; then + log DIE "Not a github URL" + fi + } + + # shellcheck disable=SC2214 + while getopts i:t:g:h:uo:-: OPT; do + # support long options: https://stackoverflow.com/a/28466267/519360 + if [ "$OPT" = "-" ]; then # long option: reformulate OPT and OPTARG + OPT="${OPTARG%%=*}" # extract long option name + OPTARG="${OPTARG#$OPT}" # extract long option argument (may be empty) + OPTARG="${OPTARG#=}" # if long option argument, remove assigning `=` + fi + case "$OPT" in + t | test) needs_arg ; TEST_TO_RUN="${OPTARG}" ;; + g | github-remote-dir) needs_arg ; GH_REMOTE_DIR="${OPTARG}" ;; + h | help ) usage ; exit 0 ;; + o | output-dir) needs_arg ; OUTPUT_DIR="${OPTARG}" ;; + ??* ) log DIE "Illegal option --${OPT}" ;; + ? ) exit 2 ;; + esac + done + shift $((OPTIND-1)) # remove parsed options and args from $@ list + check_args +} + +function copy_to_isolated { + cd "${SCRIPT_PATH}" + echo $(pwd) + echo $(ls) + echo $(ls ..) + cp -r ../tests/* "${OUTPUT_DIR}" +} +function run_test { + # RUN_IN_CONTAINER is env var that is set in the dockerfile + if [[ -v RUN_IN_CONTAINER ]]; then + gcloud auth activate-service-account --key-file "${GOOGLE_CREDENTIALS}" + gcloud container clusters get-credentials parity-zombienet --zone europe-west3-b --project parity-zombienet + fi + cd "${OUTPUT_DIR}" + set -x + set +e + if [[ ! -z $TEST_TO_RUN ]]; then + TEST_FOUND=0 + for i in $(find ${OUTPUT_DIR} -name "${TEST_TO_RUN}"| head -1); do + TEST_FOUND=1 + zombie test $i + EXIT_STATUS=$? + done; + if [[ $TEST_FOUND -lt 1 ]]; then + EXIT_STATUS=1 + fi; + else + for i in $(find ${OUTPUT_DIR} -name *.feature | sort); do + echo "running test: ${i}" + zombie test $i + TEST_EXIT_STATUS=$? + EXIT_STATUS=$((EXIT_STATUS+TEST_EXIT_STATUS)) + done; + fi + + set +x + set -e +} + +function log { + local lvl msg fmt + lvl=$1 msg=$2 + fmt='+%Y-%m-%d %H:%M:%S' + lg_date=$(date "${fmt}") + if [[ "${lvl}" = "DIE" ]] ; then + lvl="ERROR" + echo -e "\n${lg_date} - ${lvl} - ${msg}" + exit 1 + else + echo -e "\n${lg_date} - ${lvl} - ${msg}" + fi +} + +main "$@" diff --git a/src/jsapi-helpers/chain-upgrade.ts b/src/jsapi-helpers/chain-upgrade.ts new file mode 100644 index 000000000..269a99f11 --- /dev/null +++ b/src/jsapi-helpers/chain-upgrade.ts @@ -0,0 +1,71 @@ +import { ApiPromise, Keyring } from "@polkadot/api"; +import { withTypeString } from "@polkadot/types"; +import { cryptoWaitReady } from "@polkadot/util-crypto"; +import { readFileSync, promises as fsPromises } from "fs"; + +import { compress, decompress } from "napi-maybe-compressed-blob"; + +export async function chainUpgrade(api: ApiPromise, wasmFilePath: string): Promise { + // The filename of the runtime/PVF we want to upgrade to. Usually a file + // with `.compact.compressed.wasm` extension. + console.log(`upgrading chain with file: ${wasmFilePath}`); + + let code = readFileSync(wasmFilePath).toString("hex"); + await performChainUpgrade(api, code); +} + +export async function chainDummyUpgrade(api: ApiPromise): Promise { + const code: any = await api.rpc.state.getStorage(":code"); + const codeHex = code.toString().slice(2) + const codeBuf = Buffer.from(hexToBytes(codeHex)); + const decompressed = decompress(codeBuf); + + // add dummy + // echo -n -e "\x00\x07\x05\x64\x75\x6D\x6D\x79\x0A" + const dummyBuf = [0x00, 0x07, 0x05, 0x64, 0x75, 0x6D, 0x6D, 0x79, 0x0A]; + const withDummyCode = Buffer.concat([decompressed, Buffer.from(dummyBuf)]); + + // compress again + const compressed = compress(withDummyCode); + + // perform upgrade + await performChainUpgrade(api, compressed.toString("hex")); +} + + +async function performChainUpgrade(api: ApiPromise, code: string) { + await cryptoWaitReady() + + const keyring = new Keyring({ type: "sr25519" }); + const alice = keyring.addFromUri("//Alice"); + + await new Promise(async (resolve, reject) => { + const unsub = await api.tx.sudo + .sudoUncheckedWeight(api.tx.system.setCodeWithoutChecks(`0x${code}`), 1) + .signAndSend(alice, (result) => { + console.log(`Current status is ${result.status}`); + if (result.status.isInBlock) { + console.log( + `Transaction included at blockHash ${result.status.asInBlock}` + ); + } else if (result.status.isFinalized) { + console.log( + `Transaction finalized at blockHash ${result.status.asFinalized}` + ); + unsub(); + return resolve(); + } else if (result.isError) { + console.log(`Transaction Error`); + unsub(); + return reject(); + } + }); + }); +} + +/// Internal +function hexToBytes(hex: any) { + for (var bytes = [], c = 0; c < hex.length; c += 2) + bytes.push(parseInt(hex.substr(c, 2), 16)); + return bytes; +} \ No newline at end of file diff --git a/src/jsapi-helpers/index.ts b/src/jsapi-helpers/index.ts new file mode 100644 index 000000000..e8ddee896 --- /dev/null +++ b/src/jsapi-helpers/index.ts @@ -0,0 +1,15 @@ +import { ApiPromise, WsProvider } from "@polkadot/api"; +import { chainUpgrade, chainDummyUpgrade } from "./chain-upgrade"; + +async function connect(apiUrl: string, types: any): Promise { + const provider = new WsProvider(apiUrl) + const api = new ApiPromise({ provider, types }) + await api.isReady + return api +} + +export { + connect, + chainUpgrade, + chainDummyUpgrade +} \ No newline at end of file diff --git a/src/test-runner.ts b/src/test-runner.ts index fd81d8a39..ba096b9c1 100644 --- a/src/test-runner.ts +++ b/src/test-runner.ts @@ -5,7 +5,10 @@ import { LaunchConfig } from "./types"; import { readNetworkConfig, sleep } from "./utils"; import { Network } from "./network"; import path from "path"; +import { ApiPromise } from "@polkadot/api"; const zombie = require("../"); +const {connect, chainUpgrade, chainDummyUpgrade} = require("./jsapi-helpers"); + const debug = require("debug")("zombie::test-runner"); const { assert, expect } = chai; @@ -118,7 +121,7 @@ export async function run(testFile: string, provider: string, isCI: boolean = f if (!testFn) continue; const test = new Test( assertion, - async () => await testFn(network, backchannelMap) + async () => await testFn(network, backchannelMap, testFile) ); suite.addTest(test); test.timeout(0); @@ -190,6 +193,11 @@ function parseAssertionLine(assertion: string) { const pauseRegex = new RegExp(/^([\w]+): pause$/i); const resumeRegex = new RegExp(/^([\w]+): resume$/i); + // Chain Commands + const chainUpgradeRegex = new RegExp(/^([\w]+): chain upgrade with (.*?)$/i); + const chainDummyUpgradeRegex = new RegExp(/^([\w]+): chain generate dummy upgrade$/i); + + // Matchs let m: string[] | null; @@ -338,6 +346,49 @@ function parseAssertionLine(assertion: string) { }; } + m = chainUpgradeRegex.exec(assertion); + if (m && m[1]) { + const nodeName = m[1]; + const upgradeFilePath = m[2]; + + return async (network: Network, backchannelMap: BackchannelMap, testFile: string) => { + const node = network.node(nodeName); + const api: ApiPromise = await connect(node.wsUri); + + let resolvedUpgradeFilePath; + try { + if (fs.existsSync(upgradeFilePath)) { + const dir = path.dirname(upgradeFilePath); + resolvedUpgradeFilePath = path.resolve(dir, upgradeFilePath); + } else { + // the path is relative to the test file + const fileTestPath = path.dirname(testFile); + resolvedUpgradeFilePath = path.resolve(fileTestPath, upgradeFilePath); + } + await chainUpgrade(api,resolvedUpgradeFilePath); + } catch(e) { + console.log(e); + throw new Error(`Error upgrading chain with file: ${resolvedUpgradeFilePath}`); + } + expect(true).to.be.ok; + }; + } + + + m = chainDummyUpgradeRegex.exec(assertion); + if (m && m[1]) { + const nodeName = m[1]; + + return async (network: Network, backchannelMap: BackchannelMap, testFile: string) => { + const node = network.node(nodeName); + const api: ApiPromise = await connect(node.wsUri); + await chainDummyUpgrade(api); + + expect(true).to.be.ok; + }; + } + + // if we can't match let produce a fail test return async (network: Network) => { assert.equal(0, 1); diff --git a/tests/0003-parachains-upgrade-smoke-test.feature b/tests/0003-parachains-upgrade-smoke-test.feature new file mode 100644 index 000000000..4218121c0 --- /dev/null +++ b/tests/0003-parachains-upgrade-smoke-test.feature @@ -0,0 +1,12 @@ +Description: Smoke Test +Network: ./0003-parachains-upgrade-smoke-test.toml +Creds: config + + +alice: is up +bob: is up +alice: parachain 100 is registered within 225 seconds +alice: parachain 100 block height is at least 10 within 200 seconds +#alice: chain upgrade with /Users/pepo/parity/subwasm/runtime_000_uc.wasm +alice: chain generate dummy upgrade +alice: parachain 100 block height is at least 14 within 200 seconds diff --git a/tests/0003-parachains-upgrade-smoke-test.toml b/tests/0003-parachains-upgrade-smoke-test.toml new file mode 100644 index 000000000..ed2cf4034 --- /dev/null +++ b/tests/0003-parachains-upgrade-smoke-test.toml @@ -0,0 +1,30 @@ +[settings] +timeout = 1000 +# provider = "Podman" + +[relaychain] +default_image = "{{ZOMBIENET_INTEGRATION_TEST_IMAGE}}" +chain = "rococo-local" +command = "polkadot" + + [[relaychain.nodes]] + name = "alice" + extra_args = [ "--alice" ] + + [[relaychain.nodes]] + name = "bob" + extra_args = [ "--bob" ] + +[[parachains]] +id = 100 +addToGenesis = false + + [parachains.collator] + name = "collator01" + image = "{{COL_IMAGE}}" + command = "/usr/local/bin/adder-collator" + +[types.Header] +number = "u64" +parent_hash = "Hash" +post_state = "Hash"