diff --git a/README.md b/README.md index 1503ab6..db2e0f8 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,73 @@ in order to create a local test network. - `wasm` is generated using the ` export-genesis-wasm` subcommand. - `header` is retrieved by calling `api.rpc.chain.getHeader(genesis_hash)`. +## Simulating chaos + +By default `polkadot-launch` will spawn the nodes as regular processes on your computer, additionally it also +supports launching the nodes as docker containers and it integrates with [blockade](https://github.com/worstcase/blockade) +to be able to simulate arbitrary network failures. + +### Requirements + +- docker +- blockade + +You'll need to have docker running on your computer and will need to have docker images available for all +the nodes you're going to launch. Docker files for the relaychain nodes and collators are provided in the +`docker/` folder and can be built with `./docker/build-images.sh`. + +To install blockade you'll need to have Python 2.7 installed and you should then be able to do: + +`pip install blockade` + +For nix users the provided `shell.nix` defines all the dependencies required to install blockade locally. + +### Usage + +Launch the network with `polkadot-launch config.json`. Once the launch process is complete you can use +the blockade tool to generate network failures. + +``` +> blockade status +NODE CONTAINER ID STATUS IP NETWORK PARTITION +parachain1 0bcd0e676c2e UP 172.17.0.2 NORMAL +parachain2 e425a139093c UP 172.17.0.4 NORMAL +relay1 327e79ecc829 UP 172.17.0.8 NORMAL +relay2 5ecc3364293b UP 172.17.0.7 NORMAL +relay3 bd6ebe188b4a UP 172.17.0.3 NORMAL +relay4 225719ce1933 UP 172.17.0.5 NORMAL +simpleParachain1 af10c596612d UP 172.17.0.6 NORMAL +``` + +``` +> blockade flaky parachain1 +``` + +This command will induce a packet loss of ~30% on the node `parachain1`. + +Latency can be simulated with: + +``` +> blockade slow parachain1 +``` + +And we can also add arbitrary partitions to the network: + +``` +blockade partition relay1,relay2,relay3,relay4 +``` + +This will create a partition on the network where the relay chain nodes will be +on their own partition and therefore unable to connect to the collators. + +To heal the partitions do: + +``` +> blockade join +``` + +For reference on how to use blockade check: https://github.com/worstcase/blockade#commands. + ## Development To work on this project, you will need [`yarn`](https://yarnpkg.com/). diff --git a/config.json b/config.json index cc0092f..27406af 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,9 @@ { + "blockade": true, "relaychain": { "bin": "./bin/polkadot", "chain": "rococo-local", + "dockerImage": "polkadot", "nodes": [ { "name": "alice", @@ -28,6 +30,7 @@ "parachains": [ { "bin": "./bin/rococo-collator", + "dockerImage": "cumulus", "id": "200", "wsPort": 9988, "port": 31200, @@ -36,6 +39,7 @@ }, { "bin": "./bin/rococo-collator", + "dockerImage": "cumulus", "id": "300", "wsPort": 9999, "port": 31300, @@ -46,6 +50,7 @@ "simpleParachains": [ { "bin": "./bin/adder-collator", + "dockerImage": "adder-collator", "id": "400", "port": "31400", "balance": "1000000000000000000000" diff --git a/docker/adder-collator.Dockerfile b/docker/adder-collator.Dockerfile new file mode 100644 index 0000000..9da033e --- /dev/null +++ b/docker/adder-collator.Dockerfile @@ -0,0 +1,47 @@ +FROM debian:buster-slim + +# install tools and dependencies +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get upgrade -y && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + clang cmake curl git pkg-config && \ +# apt cleanup + apt-get autoremove -y && \ + apt-get clean && \ + find /var/lib/apt/lists/ -type f -not -name lock -delete; \ +# add user and link ~/.local/share/adder-collator to /data + useradd -m -u 1000 -U -s /bin/sh -d /adder-collator adder-collator && \ + mkdir -p /data /adder-collator/.local/share && \ + chown -R adder-collator:adder-collator /data && \ + ln -s /data /adder-collator/.local/share/adder-collator + +# install rust toolchain +RUN curl https://sh.rustup.rs -sSf | sh -s -- -y && \ + export PATH="$PATH:$HOME/.cargo/bin" && \ + rustup toolchain install nightly && \ + rustup target add wasm32-unknown-unknown --toolchain nightly && \ + rustup default stable + +# clone polkadot. the trick of using the github API will make the docker cache +# invalidate when there's a new commit. +ADD https://api.github.com/repos/paritytech/polkadot/git/refs/heads/rococo-v1 version.json +RUN git clone -b rococo-v1 https://github.com/paritytech/polkadot.git /tmp/polkadot + +# build polkadot +RUN cd /tmp/polkadot && \ + export PATH="$PATH:$HOME/.cargo/bin" && \ + cargo build --release -p test-parachain-adder-collator && \ + cp /tmp/polkadot/target/release/adder-collator /usr/local/bin + +# show backtraces +ENV RUST_BACKTRACE 1 + +USER adder-collator + +# check if executable works in this container +RUN /usr/local/bin/adder-collator --version + +EXPOSE 30333 9933 9944 +VOLUME ["/adder-collator"] + +ENTRYPOINT ["/usr/local/bin/adder-collator"] diff --git a/docker/build-images.sh b/docker/build-images.sh new file mode 100755 index 0000000..65effce --- /dev/null +++ b/docker/build-images.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh + +echo 'Building polkadot docker image' +docker build -f polkadot.Dockerfile . --tag=polkadot + +echo 'Building cumulus docker image' +docker build -f cumulus.Dockerfile . --tag=cumulus + +echo 'Building adder collator docker image' +docker build -f adder-collator.Dockerfile . --tag=adder-collator diff --git a/docker/cumulus.Dockerfile b/docker/cumulus.Dockerfile new file mode 100644 index 0000000..36def2b --- /dev/null +++ b/docker/cumulus.Dockerfile @@ -0,0 +1,47 @@ +FROM debian:buster-slim + +# install tools and dependencies +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get upgrade -y && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + clang cmake curl git pkg-config && \ +# apt cleanup + apt-get autoremove -y && \ + apt-get clean && \ + find /var/lib/apt/lists/ -type f -not -name lock -delete; \ +# add user and link ~/.local/share/cumulus to /data + useradd -m -u 1000 -U -s /bin/sh -d /cumulus cumulus && \ + mkdir -p /data /cumulus/.local/share && \ + chown -R cumulus:cumulus /data && \ + ln -s /data /cumulus/.local/share/cumulus + +# install rust toolchain +RUN curl https://sh.rustup.rs -sSf | sh -s -- -y && \ + export PATH="$PATH:$HOME/.cargo/bin" && \ + rustup toolchain install nightly && \ + rustup target add wasm32-unknown-unknown --toolchain nightly && \ + rustup default stable + +# clone cumulus. the trick of using the github API will make the docker cache +# invalidate when there's a new commit. +ADD https://api.github.com/repos/paritytech/cumulus/git/refs/heads/rococo-v1 version.json +RUN git clone -b rococo-v1 https://github.com/paritytech/cumulus.git /tmp/cumulus + +# build cumulus +RUN cd /tmp/cumulus && \ + export PATH="$PATH:$HOME/.cargo/bin" && \ + cargo build --release -p rococo-collator && \ + cp /tmp/cumulus/target/release/rococo-collator /usr/local/bin + +# show backtraces +ENV RUST_BACKTRACE 1 + +USER cumulus + +# check if executable works in this container +RUN /usr/local/bin/rococo-collator --version + +EXPOSE 30333 9933 9944 +VOLUME ["/cumulus"] + +ENTRYPOINT ["/usr/local/bin/rococo-collator"] diff --git a/docker/polkadot.Dockerfile b/docker/polkadot.Dockerfile new file mode 100644 index 0000000..23ac348 --- /dev/null +++ b/docker/polkadot.Dockerfile @@ -0,0 +1,47 @@ +FROM debian:buster-slim + +# install tools and dependencies +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get upgrade -y && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + clang cmake curl git pkg-config && \ +# apt cleanup + apt-get autoremove -y && \ + apt-get clean && \ + find /var/lib/apt/lists/ -type f -not -name lock -delete; \ +# add user and link ~/.local/share/polkadot to /data + useradd -m -u 1000 -U -s /bin/sh -d /polkadot polkadot && \ + mkdir -p /data /polkadot/.local/share && \ + chown -R polkadot:polkadot /data && \ + ln -s /data /polkadot/.local/share/polkadot + +# install rust toolchain +RUN curl https://sh.rustup.rs -sSf | sh -s -- -y && \ + export PATH="$PATH:$HOME/.cargo/bin" && \ + rustup toolchain install nightly && \ + rustup target add wasm32-unknown-unknown --toolchain nightly && \ + rustup default stable + +# clone polkadot. the trick of using the github API will make the docker cache +# invalidate when there's a new commit. +ADD https://api.github.com/repos/paritytech/polkadot/git/refs/heads/rococo-v1 version.json +RUN git clone -b rococo-v1 https://github.com/paritytech/polkadot.git /tmp/polkadot + +# build polkadot +RUN cd /tmp/polkadot && \ + export PATH="$PATH:$HOME/.cargo/bin" && \ + cargo build --release && \ + cp /tmp/polkadot/target/release/polkadot /usr/local/bin + +# show backtraces +ENV RUST_BACKTRACE 1 + +USER polkadot + +# check if executable works in this container +RUN /usr/local/bin/polkadot --version + +EXPOSE 30333 9933 9944 +VOLUME ["/polkadot"] + +ENTRYPOINT ["/usr/local/bin/polkadot"] diff --git a/package.json b/package.json index 40da7f9..343e410 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@polkadot/util-crypto": "^6.1.1", "filter-console": "^0.1.1", "typescript": "^4.1.5", + "yaml": "^1.10.2", "yargs": "^15.4.1", "yarn": "^1.22.10" }, diff --git a/shell.nix b/shell.nix index 027ef04..ea61f8e 100644 --- a/shell.nix +++ b/shell.nix @@ -1,9 +1,24 @@ { pkgs ? import { } }: -let - polkadot-launch = pkgs.callPackage ./default.nix { }; -in -pkgs.mkShell { + +with pkgs; mkShell { buildInputs = [ - polkadot-launch + nodejs + python27 + python27Packages.pip + python27Packages.setuptools + stdenv + yarn + yarn2nix ]; + + shellHook = '' + # Tells pip to put packages into $PIP_PREFIX instead of the usual locations. + # See https://pip.pypa.io/en/stable/user_guide/#environment-variables. + export PIP_PREFIX=${toString ./.}/_build/pip_packages + export PYTHONPATH="$PIP_PREFIX/${python27.sitePackages}:$PYTHONPATH" + export PATH="$PIP_PREFIX/bin:$PATH" + unset SOURCE_DATE_EPOCH + # NOTE: the line below is commented as this is not compatible with lorri + # pip install blockade + ''; } diff --git a/src/blockade.ts b/src/blockade.ts new file mode 100644 index 0000000..4b886e0 --- /dev/null +++ b/src/blockade.ts @@ -0,0 +1,134 @@ +import { execFile as ex } from "child_process"; +import YAML from "yaml"; +import fs from "fs/promises"; +import util from "util"; + +import { ParachainConfig, RelayChainConfig, SimpleParachainConfig } from "./types"; + +const execFile = util.promisify(ex); + +export async function generateBlockadeConfig( + relaychain: RelayChainConfig, + parachains: ParachainConfig[], + simpleParachains: SimpleParachainConfig[], + specFilename: string, +) { + const containers: any = {}; + + if (!relaychain.dockerImage) { + console.error("Missing docker image config for relaychain nodes"); + process.exit(); + } + + var n = 1; + for (const node of relaychain.nodes) { + let args = [ + "--chain=/data/spec.json", + "--tmp", + "--ws-port=" + node.wsPort, + "--port=" + node.port, + "--" + node.name.toLowerCase(), + ]; + + if (node.flags) { + args = args.concat(node.flags); + } + + let config: any = { + image: relaychain.dockerImage, + command: args.join(" "), + expose: [node.port], + volumes: { + [`./${specFilename}`]: "/data/spec.json" + } + }; + + // expose the websockets port on the first container + if (n == 1) { + config.ports = { [node.wsPort]: node.wsPort } + config.command += " --ws-external"; + } + + containers[`relay${n}`] = config; + + n += 1; + } + + var n = 1; + for (const parachain of parachains) { + if (!parachain.dockerImage) { + console.error("Missing docker image config for parachain: " + parachain.id); + process.exit(); + } + + let args = [ + "--tmp", + "--ws-port=" + parachain.wsPort, + "--port=" + parachain.port, + "--parachain-id=" + parachain.id, + "--collator", + "--", + "--chain=/data/spec.json" + ]; + + let config: any = { + image: parachain.dockerImage, + command: args.join(" "), + expose: [parachain.port], + volumes: { + [`./${specFilename}`]: "/data/spec.json" + } + }; + + containers[`parachain${n}`] = config; + + n += 1; + } + + var n = 1; + for (const simpleParachain of simpleParachains) { + if (!simpleParachain.dockerImage) { + console.error("Missing docker image config for simple parachain: " + simpleParachain.id); + process.exit(); + } + + let args = [ + "--tmp", + "--parachain-id=" + simpleParachain.id, + "--port=" + simpleParachain.port, + "--chain=/data/spec.json", + "--execution=wasm", + ]; + + let config: any = { + image: simpleParachain.dockerImage, + command: args.join(" "), + expose: [simpleParachain.port], + volumes: { + [`./${specFilename}`]: "/data/spec.json" + } + }; + + containers[`simpleParachain${n}`] = config; + + n += 1; + } + + const config = { + containers: containers, + network: { + flaky: "30%", + slow: "300ms 1000ms distribution normal" + } + }; + + return fs.writeFile("blockade.yaml", YAML.stringify(config)); +} + +export async function startBlockade() { + execFile("blockade", ["up"]); +} + +export async function stopBlockade() { + execFile("blockade", ["destroy"]); +} diff --git a/src/index.ts b/src/index.ts index 18970df..5e67169 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,10 @@ #!/usr/bin/env node +import { + generateBlockadeConfig, + startBlockade, + stopBlockade, +} from "./blockade"; import { startNode, startCollator, @@ -73,21 +78,53 @@ async function main() { process.exit(); } const chain = config.relaychain.chain; - await generateChainSpec(relay_chain_bin, chain); + + if (config.blockade) { + await generateChainSpec("docker", ["run", config.relaychain.dockerImage!], chain); + } else { + await generateChainSpec(relay_chain_bin, [], chain); + } clearAuthorities(`${chain}.json`); for (const node of config.relaychain.nodes) { await addAuthority(`${chain}.json`, node.name); } - await generateChainSpecRaw(relay_chain_bin, chain); + + if (config.blockade) { + let chainSpecPath = resolve(process.cwd(), `${chain}.json`); + let args = [ + "run", + "-v", + `${chainSpecPath}:${chainSpecPath}`, + config.relaychain.dockerImage!, + ]; + await generateChainSpecRaw("docker", args, chain, chainSpecPath); + } else { + await generateChainSpecRaw(relay_chain_bin, [], chain); + } + const spec = resolve(`${chain}-raw.json`); - // First we launch each of the validators for the relay chain. - for (const node of config.relaychain.nodes) { - const { name, wsPort, port, flags } = node; - console.log(`Starting ${name}...`); - // We spawn a `child_process` starting a node, and then wait until we - // able to connect to it using PolkadotJS in order to know its running. - startNode(relay_chain_bin, name, wsPort, port, spec, flags); + if (config.blockade) { + // When using blockade we will generate the blockade config and spawn + // all nodes at once + await generateBlockadeConfig( + config.relaychain, + config.parachains, + config.simpleParachains, + `${chain}-raw.json`, + ); + + // Start blockade + await startBlockade(); + } else { + // First we launch each of the validators for the relay chain. + for (const node of config.relaychain.nodes) { + const { name, wsPort, port, flags } = node; + console.log(`Starting ${name}...`); + // We spawn a `child_process` starting a node, and then wait until we + // able to connect to it using PolkadotJS in order to know its running. + startNode(relay_chain_bin, name, wsPort, port, spec, flags); + } } // Connect to the first relay chain node to submit the extrinsic. @@ -99,16 +136,26 @@ async function main() { // Then launch each parachain for (const parachain of config.parachains) { const { id, wsPort, balance, port, flags, chain } = parachain; - const bin = resolve(config_dir, parachain.bin); - if (!fs.existsSync(bin)) { - console.error("Parachain binary does not exist: ", bin); - process.exit(); + + let bin; + if (config.blockade) { + bin = "docker" + } else { + bin = resolve(config_dir, parachain.bin); + if (!fs.existsSync(bin)) { + console.error("Parachain binary does not exist: ", bin); + process.exit(); + } } + let account = parachainAccount(id); console.log( `Starting a Collator for parachain ${id}: ${account}, Collator port : ${port} wsPort : ${wsPort}` ); - await startCollator(bin, id, wsPort, port, chain, spec, flags); + + if (!config.blockade) { + await startCollator(bin, id, wsPort, port, chain, spec, flags); + } // If it isn't registered yet, register the parachain on the relaychain if (!registeredParachains[id]) { @@ -118,8 +165,10 @@ async function main() { let genesisState; let genesisWasm; try { - genesisState = await exportGenesisState(bin, id, chain); - genesisWasm = await exportGenesisWasm(bin, chain); + const args = config.blockade? ["run", parachain.dockerImage!] : []; + + genesisState = await exportGenesisState(bin, args, id, chain); + genesisWasm = await exportGenesisWasm(bin, args, chain); } catch (err) { console.error(err); process.exit(1); @@ -141,24 +190,35 @@ async function main() { if (config.simpleParachains) { for (const simpleParachain of config.simpleParachains) { const { id, port, balance } = simpleParachain; - const bin = resolve(config_dir, simpleParachain.bin); - if (!fs.existsSync(bin)) { - console.error("Simple parachain binary does not exist: ", bin); - process.exit(); + + let bin; + if (config.blockade) { + bin = "docker" + } else { + bin = resolve(config_dir, simpleParachain.bin); + if (!fs.existsSync(bin)) { + console.error("Simple parachain binary does not exist: ", bin); + process.exit(); + } } let account = parachainAccount(id); - console.log(`Starting Parachain ${id}: ${account}`); - await startSimpleCollator(bin, id, spec, port); + + if (!config.blockade) { + console.log(`Starting Parachain ${id}: ${account}`); + await startSimpleCollator(bin, id, spec, port); + } // Get the information required to register the parachain on the relay chain. let genesisState; let genesisWasm; try { + const args = config.blockade? ["run", simpleParachain.dockerImage!] : []; + // adder-collator does not support `--parachain-id` for export-genesis-state (and it is // not necessary for it anyway), so we don't pass it here. - genesisState = await exportGenesisState(bin); - genesisWasm = await exportGenesisWasm(bin); + genesisState = await exportGenesisState(bin, args); + genesisWasm = await exportGenesisWasm(bin, args); } catch (err) { console.error(err); process.exit(1); @@ -212,7 +272,11 @@ async function ensureOnboarded(relayChainApi: ApiPromise, paraId: number) { // Kill all processes when exiting. process.on("exit", function () { - killAll(); + if (config.blockade) { + stopBlockade(); + } else { + killAll(); + } }); // Handle ctrl+c to trigger `exit`. diff --git a/src/spawn.ts b/src/spawn.ts index f71f9ab..90eee83 100644 --- a/src/spawn.ts +++ b/src/spawn.ts @@ -13,9 +13,9 @@ const p: { [key: string]: ChildProcessWithoutNullStreams } = {}; const execFile = util.promisify(ex); // Output the chainspec of a node. -export async function generateChainSpec(bin: string, chain: string) { +export async function generateChainSpec(bin: string, argsParam: string[], chain: string) { return new Promise(function (resolve, reject) { - let args = ["build-spec", "--chain=" + chain, "--disable-default-bootnode"]; + let args = argsParam.concat(["build-spec", "--chain=" + chain, "--disable-default-bootnode"]); p["spec"] = spawn(bin, args); let spec = fs.createWriteStream(`${chain}.json`); @@ -37,9 +37,13 @@ export async function generateChainSpec(bin: string, chain: string) { } // Output the chainspec of a node using `--raw` from a JSON file. -export async function generateChainSpecRaw(bin: string, chain: string) { +export async function generateChainSpecRaw(bin: string, argsParam: string[], chain: string, chainSpecPath?: string) { return new Promise(function (resolve, reject) { - let args = ["build-spec", "--chain=" + chain + ".json", "--raw"]; + if (chainSpecPath === undefined) { + chainSpecPath = `${chain}.json`; + } + + let args = argsParam.concat(["build-spec", "--chain=" + chainSpecPath, "--raw"]); p["spec"] = spawn(bin, args); let spec = fs.createWriteStream(`${chain}-raw.json`); @@ -96,9 +100,11 @@ export function startNode( // Used for registering the parachain on the relay chain. export async function exportGenesisWasm( bin: string, + argsParam: string[], chain?: string ): Promise { - let args = ["export-genesis-wasm"]; + let args = argsParam.concat(["export-genesis-wasm"]); + if (chain) { args.push("--chain=" + chain); } @@ -116,10 +122,12 @@ export async function exportGenesisWasm( /// Export the genesis state aka genesis head. export async function exportGenesisState( bin: string, + argsParam: string[], id?: string, chain?: string ): Promise { - let args = ["export-genesis-state"]; + let args = argsParam.concat(["export-genesis-state"]); + if (id) { args.push("--parachain-id=" + id); } diff --git a/src/types.d.ts b/src/types.d.ts index 10f2a41..1822466 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,4 +1,5 @@ export interface LaunchConfig { + blockade?: boolean; relaychain: RelayChainConfig; parachains: ParachainConfig[]; simpleParachains: SimpleParachainConfig[]; @@ -8,6 +9,7 @@ export interface LaunchConfig { } export interface ParachainConfig { bin: string; + dockerImage?: string; id: string; rpcPort: number; wsPort: number; @@ -18,6 +20,7 @@ export interface ParachainConfig { } export interface SimpleParachainConfig { bin: string; + dockerImage?: string; id: string; port: string; balance: string; @@ -30,6 +33,7 @@ export interface HrmpChannelsConfig { } export interface RelayChainConfig { bin: string; + dockerImage?: string; chain: string; nodes: { name: string; diff --git a/yarn.lock b/yarn.lock index 3175bf2..c9d6b42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -793,6 +793,11 @@ yaeti@^0.0.6: resolved "https://registry.yarnpkg.com/yaeti/-/yaeti-0.0.6.tgz#f26f484d72684cf42bedfb76970aa1608fbf9577" integrity sha1-8m9ITXJoTPQr7ft2lwqhYI+/lXc= +yaml@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" diff --git a/yarn.nix b/yarn.nix index b78ce91..d1aa087 100644 --- a/yarn.nix +++ b/yarn.nix @@ -865,6 +865,14 @@ sha1 = "f26f484d72684cf42bedfb76970aa1608fbf9577"; }; } + { + name = "yaml___yaml_1.10.2.tgz"; + path = fetchurl { + name = "yaml___yaml_1.10.2.tgz"; + url = "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz"; + sha1 = "2301c5ffbf12b467de8da2333a459e29e7920e4b"; + }; + } { name = "yargs_parser___yargs_parser_18.1.3.tgz"; path = fetchurl {