From 6ec18bc8c53a7a73918b4baeacb696d11eff791b Mon Sep 17 00:00:00 2001 From: Pedro Aceves Date: Wed, 5 Feb 2025 20:43:43 -0700 Subject: [PATCH 01/27] initial --- lib/xrp/cdk.json | 44 + lib/xrp/doc/README.md | 149 ++ .../assets/Architecture-HA Nodes.drawio.png | Bin 0 -> 134127 bytes .../Architecture-Single node.drawio.png | Bin 0 -> 101428 bytes lib/xrp/lib/assets/cw-agent.json | 76 + lib/xrp/lib/assets/rippled/configBuilder.py | 133 ++ lib/xrp/lib/assets/rippled/ripple.repo | 7 + lib/xrp/lib/assets/rippled/rippled.cfg | 1507 +++++++++++++++++ .../lib/assets/rippled/rippled.cfg.template | 31 + lib/xrp/lib/assets/rippled/rippledconfig.py | 51 + .../assets/rippled/validators.txt.template | 59 + .../assets/user-data/check_xrp_sequence.sh | 215 +++ lib/xrp/lib/assets/user-data/node.sh | 447 +++++ .../lib/assets/user-data/synch-check.service | 7 + .../lib/assets/user-data/synch-check.timer | 10 + lib/xrp/lib/common-stack.ts | 74 + lib/xrp/lib/config/XRPConfig.interface.ts | 18 + lib/xrp/lib/config/XRPConfig.ts | 55 + lib/xrp/lib/config/createIniFile.ts | 49 + lib/xrp/lib/constructs/node-cw-dashboard.ts | 218 +++ .../lib/constructs/xrp-node-security-group.ts | 51 + lib/xrp/lib/ha-nodes-stack.ts | 152 ++ lib/xrp/lib/single-node-stack.ts | 147 ++ 23 files changed, 3500 insertions(+) create mode 100644 lib/xrp/cdk.json create mode 100644 lib/xrp/doc/README.md create mode 100644 lib/xrp/doc/assets/Architecture-HA Nodes.drawio.png create mode 100644 lib/xrp/doc/assets/Architecture-Single node.drawio.png create mode 100644 lib/xrp/lib/assets/cw-agent.json create mode 100644 lib/xrp/lib/assets/rippled/configBuilder.py create mode 100644 lib/xrp/lib/assets/rippled/ripple.repo create mode 100644 lib/xrp/lib/assets/rippled/rippled.cfg create mode 100644 lib/xrp/lib/assets/rippled/rippled.cfg.template create mode 100644 lib/xrp/lib/assets/rippled/rippledconfig.py create mode 100644 lib/xrp/lib/assets/rippled/validators.txt.template create mode 100644 lib/xrp/lib/assets/user-data/check_xrp_sequence.sh create mode 100644 lib/xrp/lib/assets/user-data/node.sh create mode 100644 lib/xrp/lib/assets/user-data/synch-check.service create mode 100644 lib/xrp/lib/assets/user-data/synch-check.timer create mode 100644 lib/xrp/lib/common-stack.ts create mode 100644 lib/xrp/lib/config/XRPConfig.interface.ts create mode 100644 lib/xrp/lib/config/XRPConfig.ts create mode 100644 lib/xrp/lib/config/createIniFile.ts create mode 100644 lib/xrp/lib/constructs/node-cw-dashboard.ts create mode 100644 lib/xrp/lib/constructs/xrp-node-security-group.ts create mode 100644 lib/xrp/lib/ha-nodes-stack.ts create mode 100644 lib/xrp/lib/single-node-stack.ts diff --git a/lib/xrp/cdk.json b/lib/xrp/cdk.json new file mode 100644 index 00000000..d374005c --- /dev/null +++ b/lib/xrp/cdk.json @@ -0,0 +1,44 @@ +{ + "app": "npx ts-node --prefer-ts-exts app.ts", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false + } +} diff --git a/lib/xrp/doc/README.md b/lib/xrp/doc/README.md new file mode 100644 index 00000000..11b26853 --- /dev/null +++ b/lib/xrp/doc/README.md @@ -0,0 +1,149 @@ +# Sample AWS Blockchain Node Runner app for XRP Nodes + +| Contributed by | +|:--------------------------------:| +| Pedro Aceves
acevespa@amazon.com | + +XRP node deployment on AWS. All nodes are configure as ["Stock Servers"](https://xrpl.org/docs/infrastructure/configuration/server-modes/run-rippled-as-a-stock-server) + +## Overview of Deployment Architectures for Single and HA setups + +### Single node setup + +![Single Node Deployment](./assets/Architecture-Single%20node.drawio.png) + +1. A XRP node deployed in the [Default VPC](https://docs.aws.amazon.com/vpc/latest/userguide/default-vpc.html) continuously synchronizes with the rest of nodes on the configured xrp network through [Internet Gateway](https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Internet_Gateway.html). +2. The XRP node is used by dApps or development tools internally from within the Default VPC. RPC API is not exposed to the Internet directly to protect nodes from unauthorized access. +3. The XRP node sends various monitoring metrics for both EC2 and current XRP ledger sequence to Amazon CloudWatch. It also updates the dashboard with correct storage device names to display respective metrics properly. + +### HA setup + +![Highly Available Nodes Deployment](./assets/Architecture-HA%20Nodes.drawio.png) + +1. A set of XRP nodes are deployed within an [Auto Scaling Group](https://docs.aws.amazon.com/autoscaling/ec2/userguide/auto-scaling-groups.html) in the [Default VPC](https://docs.aws.amazon.com/vpc/latest/userguide/default-vpc.html) continuously synchronizing with the rest of nodes on the configured xrp network through [Internet Gateway](https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Internet_Gateway.html). +2. The XRP nodes are accessed by dApps or development tools internally through [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). RPC API is not exposed to the Internet to protect nodes from unauthorized access. +3. The XRP nodes send various monitoring metrics for EC2 to Amazon CloudWatch. + +## Setup Instructions + +### Open AWS CloudShell + +To begin, ensure you login to your AWS account with permissions to create and modify resources in IAM, EC2, EBS, VPC, S3, KMS, and Secrets Manager. + +From the AWS Management Console, open the [AWS CloudShell](https://docs.aws.amazon.com/cloudshell/latest/userguide/welcome.html), a web-based shell environment. If unfamiliar, review the [2-minute YouTube video](https://youtu.be/fz4rbjRaiQM) for an overview and check out [CloudShell with VPC environment](https://docs.aws.amazon.com/cloudshell/latest/userguide/creating-vpc-environment.html) that we'll use to test nodes API from internal IP address space. + +Once ready, you can run the commands to deploy and test blueprints in the CloudShell. + +### Clone this repository and install dependencies + +```bash +git clone https://github.com/aws-samples/aws-blockchain-node-runners.git +cd aws-blockchain-node-runners +npm install +``` + +### Configure your setup + +1. Make sure you are in the root directory of the cloned repository + +2. If you have deleted or don't have the default VPC, create default VPC + +```bash +aws ec2 create-default-vpc +``` + +> **NOTE:** *You may see the following error if the default VPC already exists: `An error occurred (DefaultVpcAlreadyExists) when calling the CreateDefaultVpc operation: A Default VPC already exists for this account in this region.`. That means you can just continue with the following steps.* + +3. Configure your setup + +Create your own copy of `.env` file and edit it to update with your AWS Account ID and Region: +```bash +cd lib/xrp +cp ./sample-configs/.env-xrp-testnet .env +nano .env +``` +> **NOTE:** *You can find more examples inside `sample-configs` * + + +4. Deploy common components such as IAM role: + +```bash +npx cdk deploy XRP-common +``` + + +### Deploy a Single Node + +1. Deploy the node + +```bash +npx cdk deploy XRP-single-node --json --outputs-file single-node-deploy.json +``` + +2. After starting the node you need to wait for the initial synchronization process to finish. You can use Amazon CloudWatch to track the progress. There is a script that publishes CloudWatch metrics every 5 minutes, where you can watch `XRP Sequence` metrics. When the node is fully synced the sequence should match that of the configured xrp network (testnet, mainnet, etc). To see them: + + - Navigate to [CloudWatch service](https://console.aws.amazon.com/cloudwatch/) (make sure you are in the region you have specified for `AWS_REGION`) + - Open `Dashboards` and select dashboard that starts with `XRP-single-node` from the list of dashboards. + +### Deploy HA Nodes + +1. Deploy multiple HA Nodes + +```bash +pwd +# Make sure you are in aws-blockchain-node-runners/lib/xrp +npx cdk deploy XRP-ha-nodes --json --outputs-file ha-nodes-deploy.json +``` + +2. Give the new nodes time to initialize + +> **NOTE:** *By default and for security reasons the load balancer is available only from within the default VPC in the region where it is deployed. It is not available from the Internet and is not open for external connections. Before opening it up please make sure you protect your RPC APIs.* + +### Clearing up and undeploy everything + +Destroy HA Nodes, Single Nodes and Common stacks + +```bash +# Setting the AWS account id and region in case local .env file is lost + export AWS_ACCOUNT_ID= + export AWS_REGION= + +pwd +# Make sure you are in aws-blockchain-node-runners/lib/xrp + +# Destroy HA Nodes +cdk destroy XRP-ha-nodes + +# Destroy Single Node +cdk destroy XRP-single-node + +# Delete all common components like IAM role and Security Group +cdk destroy XRP-common +``` + +### FAQ + +1. How to check the logs from the EC2 user-data script? + +```bash +pwd +# Make sure you are in aws-blockchain-node-runners/lib/xrp + +export INSTANCE_ID=$(cat single-node-deploy.json | jq -r '..|.node-instance-id? | select(. != null)') +echo "INSTANCE_ID=" $INSTANCE_ID +aws ssm start-session --target $INSTANCE_ID --region $AWS_REGION +sudo cat /var/log/cloud-init-output.log +sudo cat /var/log/user-data.log +``` +2. How can I change rippled (XRP) configuration? + There are two places of configuration for the xrp nodes: + + a. .env file. Here is where you specify the xrp network you want. This is the key into the config hash in part b +```bash +HUB_NETWORK_ID="testnet" +``` + + b. lib/xrp/lib/assets/rippled/rippledconfig.py file. Here you can setup listners an network configuration for the network specified in part "a" + + + diff --git a/lib/xrp/doc/assets/Architecture-HA Nodes.drawio.png b/lib/xrp/doc/assets/Architecture-HA Nodes.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..e52cb7b88e2b02818d052c4421eec84ccf56a0f9 GIT binary patch literal 134127 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 DqEV=f literal 0 HcmV?d00001 diff --git a/lib/xrp/doc/assets/Architecture-Single node.drawio.png b/lib/xrp/doc/assets/Architecture-Single node.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..cc3770c03a3075a14c2b86d4984d012dea8a7006 GIT binary patch literal 101428 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

g}jey|4^Y&;Ipk(~j#P zbZ_WcRbyUUfLDKfdN1Q<)orQ-!kh(L^1r+Qm0vr^)n3Xhg)bgZ^qzLPhat&f}p_T`~|-<9a7)Wn-b zb00f;n(s214zy?Ij+geX*NnW7ZQD#9yhNrJ3G8ftn^uRk)JfT>UCH^9%r5~(-p?s^ECWx z8%O)kspyQ4Pvw=xOS9gFarX<~x=m5SBsDdCskEI^RnxhfOF#56shFP;)$CT4W7%K1 zw&%;?_?9@)2EY!fi6bhnsFrM=h8-ULy5;MKBqO!q0(9>g*ZvtQTYZ7Y9tpAx92{Ht zX`D+QhI1u96VNQ06K5$6m;dlC_uc8zSdVR7VzV3bTx?Psz-f8XrjYe*;I2h#6Dd~U=N)&ndae-+P=iti>@`>9uA(h1ws14sZ zeAf$lp=&_+`cvg|st?yrHcz|4Bf8VXfW_c+JG4`R`|AFoEq z8GlZrSGz9_qOf(d|1gp9>bM>0n zRQFU!&&j6-DyUPPA3j}iX}|G{TdH-|j4=l3Mccwi>C!@#H}bwT^5*c&C`|$MlW1D({H`gnFG7ms9DyH5w>{x{(4E@TRgi4t?&x0_u>?f`%jJC{pvImt$4UEr~llXr+Y^ZO~&H~Ol=wwsWOnl@&x7`5oZq} zL)`Sa+#8tin76+qknL==S#AUOlPpuO^#@2Ro=o#T-bWFhACF=kp0F2{a1HZ#l5`D?$_36 z5a8wME@D)hcs@^6>C)%>0|$%MZP!J_YOl#1_fQMCk+oN{<+c>fOx~bsSVo3^{&v^x zr*!M1-t?smy3GZgzi=W~D*if8h*rg=C|3SxnqzHcX8D4w|0sXsmq*3 z@78)s(H>LJ6n2v=9(GRgLMgtw@@zk-H7(R;t05IeC%Y6S{f)ga@8AdhROda~&(;+_ zf!7p3#?qlchjZvheOdz~&^qE5z4%{cJ^}Qz1Rw6^$VZ@pPiREncX!_htpWAP)$OC!7*5WzJsLO2De4s!mspKUq9ytMha0hteoJch_ zG3dpP*KQlXDYBrAyQNQ~Eq2HYm>dSJnGD9~H9(X2B6}KL=D}pOQ4Nw{cBe`G8zW-y z*$p(w;Kvdn^-A%u7rkZZJbcj-?*qC{EgRr@6m+g&C;lyY1_w|UZc@!lA`b?hfm{2# z_O4wA25Z~d^2ApxKvRw4I`;itBTRi_@#jGGR_6lgSn#^)F9BB0ZrVECLT1c#9moj6 z*W{=%eSk+c$${A}qw&GJb()w_4m3n~9Oif$)V`L;#2K%jK%D}rc#ZfKn~LK^k#dfc z_rJgU1ykSj>JY6C(VtI6Jo=E-VvYUU>ce=8Z4C{5v)pFp6Kr4s-xO-Ta+Nq<9YT`3 zJJ93UVVixFIm+LdRrihyT;=Rx1<>GqU~OGvYvm0Aq|KLQa|O2?7+!#7VqzoC=^z{_ z7qpc)|3e&jLX4>a&&CAiYNzLk4d8Qojy5NJaUQeXdl1X~#8ZekY+dLQ|+1XCqj z2d^#lj}fg@9|mMHdi(mX>c7gN-f>WSAC3ob`-8SJT(H?k5A15B|DC-%R8Zt_^5I>2 z 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

  • OSiKP+oXGH|UB}u2KmK z_r5o4m}JW*cCA&*@d&&3(JL^B&;UmaG5X*{wM*VqP1c>nI654yPmzSeZ{GJlrC#jY z^(N)eR2UnlL?WzzxFE*?U$ZPWPJ_PxkMF4JfAVzS5e`^jHaBW@NLOjnv0A#nzSBLL z|N1jP9u(pjWUASGOBbd1S@F(0>1zSTU{O7w%M>X4%X>(aqGUd>r$1*Y)X#CwX>VN$ z|1}hx>Jd;*tIn_yT;GOAe+GM_LLYI}V z22Yjaub{H72Sjck9-hq5zjc&v_H{Jx z9V9}-`MH_-pK3RkB3dLT3BxP6JKdWk%Vzfht7;L?&WyL;Y^qk+uD;xlWJvG+>B^^f z!5Vw(2Z=x)#`++>+|S&Xt@wfL_T0zZ1}ykHdSZ#y49u6`QvtTlxU+shyUwrxuTctB zYd_-dwDSXueah7dkk9R{%ylyc&#I+N1&EVg Hh*Ub%wh9A9zn{l1qh|mk&6lkl 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 B3TFTS literal 0 HcmV?d00001 diff --git a/lib/xrp/lib/assets/cw-agent.json b/lib/xrp/lib/assets/cw-agent.json new file mode 100644 index 00000000..28833017 --- /dev/null +++ b/lib/xrp/lib/assets/cw-agent.json @@ -0,0 +1,76 @@ +{ + "agent": { + "metrics_collection_interval": 60, + "run_as_user": "root" + }, + "metrics": { + "aggregation_dimensions": [ + [ + "InstanceId" + ] + ], + "append_dimensions": { + "InstanceId": "${aws:InstanceId}" + }, + "metrics_collected": { + "cpu": { + "measurement": [ + "cpu_usage_idle", + "cpu_usage_iowait", + "cpu_usage_user", + "cpu_usage_system" + ], + "metrics_collection_interval": 60, + "resources": [ + "*" + ], + "totalcpu": false + }, + "disk": { + "measurement": [ + "used_percent" + ], + "metrics_collection_interval": 60, + "resources": [ + "*" + ] + }, + "diskio": { + "measurement": [ + "io_time", + "write_bytes", + "read_bytes", + "writes", + "reads", + "write_time", + "read_time", + "iops_in_progress" + ], + "metrics_collection_interval": 60, + "resources": [ + "*" + ] + }, + "mem": { + "measurement": [ + "mem_used_percent", + "mem_cached" + ], + "metrics_collection_interval": 60 + }, + "netstat": { + "measurement": [ + "tcp_established", + "tcp_time_wait" + ], + "metrics_collection_interval": 60 + }, + "swap": { + "measurement": [ + "swap_used_percent" + ], + "metrics_collection_interval": 60 + } + } + } +} diff --git a/lib/xrp/lib/assets/rippled/configBuilder.py b/lib/xrp/lib/assets/rippled/configBuilder.py new file mode 100644 index 00000000..da04e703 --- /dev/null +++ b/lib/xrp/lib/assets/rippled/configBuilder.py @@ -0,0 +1,133 @@ +import configparser +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Any, Tuple + +import rippledconfig + + +@dataclass +class RippledConfig: + """Class to handle Rippled configuration settings""" + assets_path: Path + xrp_network: str + + def __init__(self, assets_path: str): + self.assets_path = Path(assets_path) / "rippled" + self.xrp_network = os.environ.get("XRP_NETWORK", "mainnet") + self.server_ports = rippledconfig.xrp_defaults["server_ports"] + self.node_db_defaults = rippledconfig.xrp_defaults["db_defaults"] + self.network_defaults = rippledconfig.xrp_defaults["network_defaults"] + + def load_config_files(self) -> Tuple[configparser.ConfigParser, configparser.ConfigParser]: + """Load and parse configuration template files""" + ripple_cfg = self._create_config_parser() + validator_cfg = self._create_config_parser() + + ripple_cfg.read_string(self._read_template_file("rippled.cfg.template")) + validator_cfg.read_string(self._read_template_file("validators.txt.template")) + + return ripple_cfg, validator_cfg + + def _read_template_file(self, filename: str) -> str: + """Read a template file from the assets directory""" + try: + with open(self.assets_path / filename) as f: + return f.read() + except FileNotFoundError as e: + raise FileNotFoundError(f"Template file {filename} not found in {self.assets_path}") from e + + @staticmethod + def _create_config_parser() -> configparser.ConfigParser: + """Create a configured ConfigParser instance""" + parser = configparser.ConfigParser( + allow_no_value=True, + delimiters="=", + empty_lines_in_values=False + ) + parser.optionxform = str + return parser + + def apply_network_configuration(self, ripple_cfg: configparser.ConfigParser, + validator_cfg: configparser.ConfigParser) -> None: + """Apply network-specific configuration settings""" + network_config = self.network_defaults[self.xrp_network] + + if self.xrp_network == "mainnet": + self._configure_mainnet(ripple_cfg, validator_cfg, network_config) + elif self.xrp_network == "testnet": + self._configure_testnet(ripple_cfg, validator_cfg, network_config) + + def _configure_mainnet(self, ripple_cfg: configparser.ConfigParser, + validator_cfg: configparser.ConfigParser, + network_config: Dict[str, Any]) -> None: + """Configure settings for mainnet""" + ripple_cfg.remove_section("ips") + ripple_cfg.set("network_id", network_config["network_id"]) + ripple_cfg['ssl_verify'].clear() + ripple_cfg.set("ssl_verify", network_config["ssl_verify"]) + self._apply_common_config(ripple_cfg, validator_cfg, network_config) + + def _configure_testnet(self, ripple_cfg: configparser.ConfigParser, + validator_cfg: configparser.ConfigParser, + network_config: Dict[str, Any]) -> None: + """Configure settings for testnet""" + ripple_cfg.set("ips", network_config["ips"]) + ripple_cfg.set("network_id", network_config["network_id"]) + ripple_cfg['ssl_verify'].clear() + ripple_cfg.set("ssl_verify", network_config["ssl_verify"]) + self._apply_common_config(ripple_cfg, validator_cfg, network_config) + + def _apply_common_config(self, ripple_cfg: configparser.ConfigParser, + validator_cfg: configparser.ConfigParser, + network_config: Dict[str, Any]) -> None: + """Apply common configuration settings""" + self._configure_server_ports(ripple_cfg) + self._configure_node_db(ripple_cfg) + self._configure_validators(validator_cfg, network_config) + + def _configure_server_ports(self, config: configparser.ConfigParser) -> None: + """Configure server ports settings""" + for section, settings in self.server_ports.items(): + for key, value in settings.items(): + config.set(section, key, value) + + def _configure_node_db(self, config: configparser.ConfigParser) -> None: + """Configure node database settings""" + for section, settings in self.node_db_defaults.items(): + for key, value in settings.items(): + config.set(section, key, value) + + def _configure_validators(self, config: configparser.ConfigParser, + network_config: Dict[str, Any]) -> None: + """Configure validator settings""" + for section in config.sections(): + config[section].clear() + config.set(section, "\n".join(map(str, network_config[section]))) + +def main(): + """Main function to generate Rippled configuration""" + try: + assets_path = sys.argv[1] + config_handler = RippledConfig(assets_path) + + ripple_cfg, validator_cfg = config_handler.load_config_files() + config_handler.apply_network_configuration(ripple_cfg, validator_cfg) + + # Write configurations to files + with open(rippledconfig.rippled_cfg_file, "w") as r_cfg: + ripple_cfg.write(r_cfg, space_around_delimiters=True) + with open(rippledconfig.rippled_validator_file, "w") as val_cfg: + validator_cfg.write(val_cfg, space_around_delimiters=True) + + except IndexError: + print("Error: Please provide the assets path as a command line argument") + sys.exit(1) + except Exception as e: + print(f"Error: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/lib/xrp/lib/assets/rippled/ripple.repo b/lib/xrp/lib/assets/rippled/ripple.repo new file mode 100644 index 00000000..64e370fc --- /dev/null +++ b/lib/xrp/lib/assets/rippled/ripple.repo @@ -0,0 +1,7 @@ +[ripple-stable] +name=XRP Ledger Packages +enabled=1 +gpgcheck=0 +repo_gpgcheck=1 +baseurl=https://repos.ripple.com/repos/rippled-rpm/stable/ +gpgkey=https://repos.ripple.com/repos/rippled-rpm/stable/repodata/repomd.xml.key \ No newline at end of file diff --git a/lib/xrp/lib/assets/rippled/rippled.cfg b/lib/xrp/lib/assets/rippled/rippled.cfg new file mode 100644 index 00000000..0b16c1e7 --- /dev/null +++ b/lib/xrp/lib/assets/rippled/rippled.cfg @@ -0,0 +1,1507 @@ +#------------------------------------------------------------------------------- +# +# +#------------------------------------------------------------------------------- +# +# Contents +# +# 1. Server +# +# 2. Peer Protocol +# +# 3. Ripple Protocol +# +# 4. HTTPS Client +# +# 5. +# +# 6. Database +# +# 7. Diagnostics +# +# 8. Voting +# +# 9. Misc Settings +# +# 10. Example Settings +# +#------------------------------------------------------------------------------- +# +# Purpose +# +# This file documents and provides examples of all rippled server process +# configuration options. When the rippled server instance is launched, it +# looks for a file with the following name: +# +# rippled.cfg +# +# For more information on where the rippled server instance searches for the +# file, visit: +# +# https://xrpl.org/commandline-usage.html#generic-options +# +# This file should be named rippled.cfg. This file is UTF-8 with DOS, UNIX, +# or Mac style end of lines. Blank lines and lines beginning with '#' are +# ignored. Undefined sections are reserved. No escapes are currently defined. +# +# Notation +# +# In this document a simple BNF notation is used. Angle brackets denote +# required elements, square brackets denote optional elements, and single +# quotes indicate string literals. A vertical bar separating 1 or more +# elements is a logical "or"; any one of the elements may be chosen. +# Parentheses are notational only, and used to group elements; they are not +# part of the syntax unless they appear in quotes. White space may always +# appear between elements, it has no effect on values. +# +# A required identifier +# '=' The equals sign character +# | Logical "or" +# ( ) Used for grouping +# +# +# An identifier is a string of upper or lower case letters, digits, or +# underscores subject to the requirement that the first character of an +# identifier must be a letter. Identifiers are not case sensitive (but +# values may be). +# +# Some configuration sections contain key/value pairs. A line containing +# a key/value pair has this syntax: +# +# '=' +# +# Depending on the section and key, different value types are possible: +# +# A signed integer +# An unsigned integer +# A boolean. 1 = true/yes/on, 0 = false/no/off. +# +# Consult the documentation on the key in question to determine the possible +# value types. +# +# +# +#------------------------------------------------------------------------------- +# +# 1. Server +# +#---------- +# +# +# +# rippled offers various server protocols to clients making inbound +# connections. The listening ports rippled uses are "universal" ports +# which may be configured to handshake in one or more of the available +# supported protocols. These universal ports simplify administration: +# A single open port can be used for multiple protocols. +# +# NOTE At least one server port must be defined in order +# to accept incoming network connections. +# +# +# [server] +# +# A list of port names and key/value pairs. A port name must start with a +# letter and contain only letters and numbers. The name is not case-sensitive. +# For each name in this list, rippled will look for a configuration file +# section with the same name and use it to create a listening port. The +# name is informational only; the choice of name does not affect the function +# of the listening port. +# +# Key/value pairs specified in this section are optional, and apply to all +# listening ports unless the port overrides the value in its section. They +# may be considered default values. +# +# Suggestion: +# +# To avoid a conflict with port names and future configuration sections, +# we recommend prepending "port_" to the port name. This prefix is not +# required, but suggested. +# +# This example defines two ports with different port numbers and settings: +# +# [server] +# port_public +# port_private +# port = 80 +# +# [port_public] +# ip = 0.0.0.0 +# port = 443 +# protocol = peer,https +# +# [port_private] +# ip = 127.0.0.1 +# protocol = http +# +# When rippled is used as a command line client (for example, issuing a +# server stop command), the first port advertising the http or https +# protocol will be used to make the connection. +# +# +# +# [] +# +# A series of key/value pairs that define the settings for the port with +# the corresponding name. These keys are possible: +# +# ip = +# +# Required. Determines the IP address of the network interface to bind +# to. To bind to all available IPv4 interfaces, use 0.0.0.0 +# To binding to all IPv4 and IPv6 interfaces, use :: +# +# NOTE if the ip value is ::, then any incoming IPv4 connections will +# be made as mapped IPv4 addresses. +# +# port = +# +# Required. Sets the port number to use for this port. +# +# protocol = [ http, https, peer ] +# +# Required. A comma-separated list of protocols to support: +# +# http JSON-RPC over HTTP +# https JSON-RPC over HTTPS +# ws Websockets +# wss Secure Websockets +# peer Peer Protocol +# +# Restrictions: +# +# Only one port may be configured to support the peer protocol. +# A port cannot have websocket and non websocket protocols at the +# same time. It is possible have both Websockets and Secure Websockets +# together in one port. +# +# NOTE If no ports support the peer protocol, rippled cannot +# receive incoming peer connections or become a superpeer. +# +# limit = +# +# Optional. An integer value that will limit the number of connected +# clients that the port will accept. Once the limit is reached, new +# connections will be refused until other clients disconnect. +# Omit or set to 0 to allow unlimited numbers of clients. +# +# user = +# password = +# +# When set, these credentials will be required on HTTP/S requests. +# The credentials must be provided using HTTP's Basic Authentication +# headers. If either or both fields are empty, then no credentials are +# required. IP address restrictions, if any, will be checked in addition +# to the credentials specified here. +# +# When acting in the client role, rippled will supply these credentials +# using HTTP's Basic Authentication headers when making outbound HTTP/S +# requests. +# +# admin = [ IP, IP, IP, ... ] +# +# A comma-separated list of IP addresses or subnets. Subnets +# should be represented in "slash" notation, such as: +# 10.0.0.0/8 +# 172.16.0.0/12 +# 192.168.0.0/16 +# Those examples are ipv4, but ipv6 is also supported. +# When configuring subnets, the address must match the +# underlying network address. Otherwise, the desired IP range is +# ambiguous. For example, 10.1.2.3/24 has a network address of +# 10.1.2.0. Therefore, that subnet should be configured as +# 10.1.2.0/24. +# +# When set, grants administrative command access to the specified +# addresses. These commands may be issued over http, https, ws, or wss +# if configured on the port. If not provided, the default is to not allow +# administrative commands. +# +# NOTE A common configuration value for the admin field is "localhost". +# If you are listening on all IPv4/IPv6 addresses by specifing +# ip = :: then you can use admin = ::ffff:127.0.0.1,::1 to allow +# administrative access from both IPv4 and IPv6 localhost +# connections. +# +# *SECURITY WARNING* +# 0.0.0.0 or :: may be used to allow access from any IP address. It must +# be the only address specified and cannot be combined with other IPs. +# Use of this address can compromise server security, please consider its +# use carefully. +# +# admin_user = +# admin_password = +# +# When set, clients must provide these credentials in the submitted +# JSON for any administrative command requests submitted to the HTTP/S, +# WS, or WSS protocol interfaces. If administrative commands are +# disabled for a port, these credentials have no effect. +# +# When acting in the client role, rippled will supply these credentials +# in the submitted JSON for any administrative command requests when +# invoking JSON-RPC commands on remote servers. +# +# secure_gateway = [ IP, IP, IP, ... ] +# +# A comma-separated list of IP addresses or subnets. See the +# details for the "admin" option above. +# +# When set, allows the specified addresses to pass HTTP headers +# containing username and remote IP address for each session. If a +# non-empty username is passed in this way, then resource controls +# such as often resulting in "tooBusy" errors will be lifted. However, +# administrative RPC commands such as "stop" will not be allowed. +# The HTTP headers that secure_gateway hosts can set are X-User and +# X-Forwarded-For. Only the X-User header affects resource controls. +# However, both header values are logged to help identify user activity. +# If no X-User header is passed, or if its value is empty, then +# resource controls will default to those for non-administrative users. +# +# The secure_gateway IP addresses are intended to represent +# proxies. Since rippled trusts these hosts, they must be +# responsible for properly authenticating the remote user. +# +# If some IP addresses are included for both "admin" and +# "secure_gateway" connections, then they will be treated as +# "admin" addresses. +# +# ssl_key = +# ssl_cert = +# ssl_chain = +# +# Use the specified files when configuring SSL on the port. +# +# NOTE If no files are specified and secure protocols are selected, +# rippled will generate an internal self-signed certificate. +# +# The files have these meanings: +# +# ssl_key +# +# Specifies the filename holding the SSL key in PEM format. +# +# ssl_cert +# +# Specifies the path to the SSL certificate file in PEM format. +# This is not needed if the chain includes it. Use ssl_chain if +# your certificate includes one or more intermediates. +# +# ssl_chain +# +# If you need a certificate chain, specify the path to the +# certificate chain here. The chain may include the end certificate. +# This must be used if the certificate includes intermediates. +# +# ssl_ciphers = +# +# Control the ciphers which the server will support over SSL on the port, +# specified using the OpenSSL "cipher list format". +# +# NOTE If unspecified, rippled will automatically configure a modern +# cipher suite. This default suite should be widely supported. +# +# You should not modify this string unless you have a specific +# reason and cryptographic expertise. Incorrect modification may +# keep rippled from connecting to other instances of rippled or +# prevent RPC and WebSocket clients from connecting. +# +# send_queue_limit = [1..65535] +# +# A Websocket will disconnect when its send queue exceeds this limit. +# The default is 100. A larger value may help with erratic disconnects but +# may adversely affect server performance. +# +# WebSocket permessage-deflate extension options +# +# These settings configure the optional permessage-deflate extension +# options and may appear on any port configuration entry. They are meaningful +# only to ports which have enabled a WebSocket protocol. +# +# permessage_deflate = +# +# Determines if permessage_deflate extension negotiations are enabled. +# When enabled, clients may request the extension and the server will +# offer the enabled extension in response. +# +# client_max_window_bits = [9..15] +# server_max_window_bits = [9..15] +# client_no_context_takeover = +# server_no_context_takeover = +# +# These optional settings control options related to the permessage-deflate +# extension negotiation. For precise definitions of these fields please see +# the RFC 7692, "Compression Extensions for WebSocket": +# https://tools.ietf.org/html/rfc7692 +# +# compress_level = [0..9] +# +# When set, determines the amount of compression attempted, where 0 is +# the least amount and 9 is the most amount. Higher levels require more +# CPU resources. Levels 1 through 3 use a fast compression algorithm, +# while levels 4 through 9 use a more compact algorithm which uses more +# CPU resources. If unspecified, a default of 3 is used. +# +# memory_level = [1..9] +# +# When set, determines the relative amount of memory used to hold +# intermediate compression data. Higher numbers can give better compression +# ratios at the cost of higher memory and CPU resources. +# +# [rpc_startup] +# +# Specify a list of RPC commands to run at startup. +# +# Examples: +# { "command" : "server_info" } +# { "command" : "log_level", "partition" : "ripplecalc", "severity" : "trace" } +# +# +# +# [websocket_ping_frequency] +# +# +# +# The amount of time to wait in seconds, before sending a websocket 'ping' +# message. Ping messages are used to determine if the remote end of the +# connection is no longer available. +# +# +# [server_domain] +# +# domain name +# +# The domain under which a TOML file applicable to this server can be +# found. A server may lie about its domain so the TOML should contain +# a reference to this server by pubkey in the [nodes] array. +# +# +#------------------------------------------------------------------------------- +# +# 2. Peer Protocol +# +#----------------- +# +# These settings control security and access attributes of the Peer to Peer +# server section of the rippled process. Peer Protocol implements the +# Ripple Payment protocol. It is over peer connections that transactions +# and validations are passed from to machine to machine, to determine the +# contents of validated ledgers. +# +# +# +# [compression] +# +# true or false +# +# true - enables compression +# false - disables compression [default]. +# +# The rippled server can save bandwidth by compressing its peer-to-peer communications, +# at a cost of greater CPU usage. If you enable link compression, +# the server automatically compresses communications with peer servers +# that also have link compression enabled. +# https://xrpl.org/enable-link-compression.html +# +# +# +# [ips] +# +# List of hostnames or ips where the Ripple protocol is served. A default +# starter list is included in the code and used if no other hostnames are +# available. +# +# One address or domain name per line is allowed. A port may must be +# specified after adding a space to the address. The ordering of entries +# does not generally matter. +# +# The default list of entries is: +# - r.ripple.com 51235 +# - sahyadri.isrdc.in 51235 +# - hubs.xrpkuwait.com 51235 +# +# Examples: +# +# [ips] +# 192.168.0.1 +# 192.168.0.1 2459 +# r.ripple.com 51235 +# +# +# [ips_fixed] +# +# List of IP addresses or hostnames to which rippled should always attempt to +# maintain peer connections with. This is useful for manually forming private +# networks, for example to configure a validation server that connects to the +# Ripple network through a public-facing server, or for building a set +# of cluster peers. +# +# One address or domain names per line is allowed. A port must be specified +# after adding a space to the address. +# +# +# +# [peer_private] +# +# 0 or 1. +# +# 0: Request peers to broadcast your address. Normal outbound peer connections [default] +# 1: Request peers not broadcast your address. Only connect to configured peers. +# +# +# +# [peers_max] +# +# The largest number of desired peer connections (incoming or outgoing). +# Cluster and fixed peers do not count towards this total. There are +# implementation-defined lower limits imposed on this value for security +# purposes. +# +# +# +# [node_seed] +# +# This is used for clustering. To force a particular node seed or key, the +# key can be set here. The format is the same as the validation_seed field. +# To obtain a validation seed, use the validation_create command. +# +# Examples: RASH BUSH MILK LOOK BAD BRIM AVID GAFF BAIT ROT POD LOVE +# shfArahZT9Q9ckTf3s1psJ7C7qzVN +# +# +# +# [cluster_nodes] +# +# To extend full trust to other nodes, place their node public keys here. +# Generally, you should only do this for nodes under common administration. +# Node public keys start with an 'n'. To give a node a name for identification +# place a space after the public key and then the name. +# +# +# +# [max_transactions] +# +# Configure the maximum number of transactions to have in the job queue +# +# Must be a number between 100 and 1000, defaults to 250 +# +# +# [overlay] +# +# Controls settings related to the peer to peer overlay. +# +# A set of key/value pair parameters to configure the overlay. +# +# public_ip = +# +# If the server has a known, fixed public IPv4 address, +# specify that IP address here in dotted decimal notation. +# Peers will use this information to reject attempt to proxy +# connections to or from this server. +# +# ip_limit = +# +# The maximum number of incoming peer connections allowed by a single +# IP that isn't classified as "private" in RFC1918. The implementation +# imposes some hard and soft upper limits on this value to prevent a +# single host from consuming all inbound slots. If the value is not +# present the server will autoconfigure an appropriate limit. +# +# max_unknown_time = +# +# The maximum amount of time, in seconds, that an outbound connection +# is allowed to stay in the "unknown" tracking state. This option can +# take any value between 300 and 1800 seconds, inclusive. If the option +# is not present the server will autoconfigure an appropriate limit. +# +# The current default (which is subject to change) is 600 seconds. +# +# max_diverged_time = +# +# The maximum amount of time, in seconds, that an outbound connection +# is allowed to stay in the "diverged" tracking state. The option can +# take any value between 60 and 900 seconds, inclusive. If the option +# is not present the server will autoconfigure an appropriate limit. +# +# The current default (which is subject to change) is 300 seconds. +# +# +# [transaction_queue] EXPERIMENTAL +# +# This section is EXPERIMENTAL, and should not be +# present for production configuration settings. +# +# A set of key/value pair parameters to tune the performance of the +# transaction queue. +# +# ledgers_in_queue = +# +# The queue will be limited to this of average ledgers' +# worth of transactions. If the queue fills up, the transactions +# with the lowest fee levels will be dropped from the queue any +# time a transaction with a higher fee level is added. +# Default: 20. +# +# minimum_queue_size = +# +# The queue will always be able to hold at least this of +# transactions, regardless of recent ledger sizes or the value of +# ledgers_in_queue. Default: 2000. +# +# retry_sequence_percent = +# +# If a client replaces a transaction in the queue (same sequence +# number as a transaction already in the queue), the new +# transaction's fee must be more than percent higher +# than the original transaction's fee, or meet the current open +# ledger fee to be considered. Default: 25. +# +# minimum_escalation_multiplier = +# +# At ledger close time, the median fee level of the transactions +# in that ledger is used as a multiplier in escalation +# calculations of the next ledger. This minimum value ensures that +# the escalation is significant. Default: 500. +# +# minimum_txn_in_ledger = +# +# Minimum number of transactions that must be allowed into the +# ledger at the minimum required fee before the required fee +# escalates. Default: 5. +# +# minimum_txn_in_ledger_standalone = +# +# Like minimum_txn_in_ledger when rippled is running in standalone +# mode. Default: 1000. +# +# target_txn_in_ledger = +# +# Number of transactions allowed into the ledger at the minimum +# required fee that the queue will "work toward" as long as +# consensus stays healthy. The limit will grow quickly until it +# reaches or exceeds this number. After that the limit may still +# change, but will stay above the target. If consensus is not +# healthy, the limit will be clamped to this value or lower. +# Default: 50. +# +# maximum_txn_in_ledger = +# +# (Optional) Maximum number of transactions that will be allowed +# into the ledger at the minimum required fee before the required +# fee escalates. Default: no maximum. +# +# normal_consensus_increase_percent = +# +# (Optional) When the ledger has more transactions than "expected", +# and performance is humming along nicely, the expected ledger size +# is updated to the previous ledger size plus this percentage. +# Default: 20 +# +# slow_consensus_decrease_percent = +# +# (Optional) When consensus takes longer than appropriate, the +# expected ledger size is updated to the minimum of the previous +# ledger size or the "expected" ledger size minus this percentage. +# Default: 50 +# +# maximum_txn_per_account = +# +# Maximum number of transactions that one account can have in the +# queue at any given time. Default: 10. +# +# minimum_last_ledger_buffer = +# +# If a transaction has a LastLedgerSequence, it must be at least +# this much larger than the current open ledger sequence number. +# Default: 2. +# +# zero_basefee_transaction_feelevel = +# +# So we don't deal with infinite fee levels, treat any transaction +# with a 0 base fee (ie. SetRegularKey password recovery) as +# having this fee level. +# Default: 256000. +# +# +#------------------------------------------------------------------------------- +# +# 3. Protocol +# +#------------------- +# +# These settings affect the behavior of the server instance with respect +# to protocol level activities such as validating and closing ledgers +# adjusting fees in response to server overloads. +# +# +# +# +# [relay_proposals] +# +# Controls the relay and processing behavior for proposals received by this +# server that are issued by validators that are not on the server's UNL. +# +# Legal values are: +# "all" - Relay and process all incoming proposals +# "trusted" - Relay only trusted proposals, but locally process all +# "drop_untrusted" - Relay only trusted proposals, do not process untrusted +# +# The default is "trusted". +# +# +# [relay_validations] +# +# Controls the relay and processing behavior for validations received by this +# server that are issued by validators that are not on the server's UNL. +# +# Legal values are: +# "all" - Relay and process all incoming validations +# "trusted" - Relay only trusted validations, but locally process all +# "drop_untrusted" - Relay only trusted validations, do not process untrusted +# +# The default is "all". +# +# +# +# +# +# [ledger_history] +# +# The number of past ledgers to acquire on server startup and the minimum to +# maintain while running. +# +# To serve clients, servers need historical ledger data. Servers that don't +# need to serve clients can set this to "none". Servers that want complete +# history can set this to "full". +# +# This must be less than or equal to online_delete (if online_delete is used) +# +# The default is: 256 +# +# +# +# [fetch_depth] +# +# The number of past ledgers to serve to other peers that request historical +# ledger data (or "full" for no limit). +# +# Servers that require low latency and high local performance may wish to +# restrict the historical ledgers they are willing to serve. Setting this +# below 32 can harm network stability as servers require easy access to +# recent history to stay in sync. Values below 128 are not recommended. +# +# The default is: full +# +# +# +# [validation_seed] +# +# To perform validation, this section should contain either a validation seed +# or key. The validation seed is used to generate the validation +# public/private key pair. To obtain a validation seed, use the +# validation_create command. +# +# Examples: RASH BUSH MILK LOOK BAD BRIM AVID GAFF BAIT ROT POD LOVE +# shfArahZT9Q9ckTf3s1psJ7C7qzVN +# +# +# +# [validator_token] +# +# This is an alternative to [validation_seed] that allows rippled to perform +# validation without having to store the validator keys on the network +# connected server. The field should contain a single token in the form of a +# base64-encoded blob. +# An external tool is available for generating validator keys and tokens. +# +# +# +# [validator_key_revocation] +# +# If a validator's secret key has been compromised, a revocation must be +# generated and added to this field. The revocation notifies peers that it is +# no longer safe to trust the revoked key. The field should contain a single +# revocation in the form of a base64-encoded blob. +# An external tool is available for generating and revoking validator keys. +# +# +# +# [validators_file] +# +# Path or name of a file that determines the nodes to always accept as validators. +# +# The contents of the file should include a [validators] and/or +# [validator_list_sites] and [validator_list_keys] entries. +# [validators] should be followed by a list of validation public keys of +# nodes, one per line. +# [validator_list_sites] should be followed by a list of URIs each serving a +# list of recommended validators. +# [validator_list_keys] should be followed by a list of keys belonging to +# trusted validator list publishers. Validator lists fetched from configured +# sites will only be considered if the list is accompanied by a valid +# signature from a trusted publisher key. +# +# Specify the file by its name or path. +# Unless an absolute path is specified, it will be considered relative to +# the folder in which the rippled.cfg file is located. +# +# Examples: +# /home/ripple/validators.txt +# C:/home/ripple/validators.txt +# +# Example content: +# [validators] +# n949f75evCHwgyP4fPVgaHqNHxUVN15PsJEZ3B3HnXPcPjcZAoy7 +# n9MD5h24qrQqiyBC8aeqqCWvpiBiYQ3jxSr91uiDvmrkyHRdYLUj +# n9L81uNCaPgtUJfaHh89gmdvXKAmSt5Gdsw2g1iPWaPkAHW5Nm4C +# n9KiYM9CgngLvtRCQHZwgC2gjpdaZcCcbt3VboxiNFcKuwFVujzS +# n9LdgEtkmGB9E2h3K4Vp7iGUaKuq23Zr32ehxiU8FWY7xoxbWTSA +# +# +# +# [path_search] +# When searching for paths, the default search aggressiveness. This can take +# exponentially more resources as the size is increased. +# +# The recommended value to support advanced pathfinding is: 7 +# +# The default is: 2 +# +# [path_search_fast] +# [path_search_max] +# When searching for paths, the minimum and maximum search aggressiveness. +# +# If you do not need pathfinding, you can set path_search_max to zero to +# disable it and avoid some expensive bookkeeping. +# +# To support advanced pathfinding the recommended value for +# 'path_search_fast' is 2, and for 'path_search_max' is 10. +# +# The default for 'path_search_fast' is 2. The default for 'path_search_max' is 3. +# +# [path_search_old] +# +# For clients that use the legacy path finding interfaces, the search +# aggressiveness to use. +# +# The recommended value to support advanced pathfinding is: 7. +# +# The default is: 2 +# +# +# +# [fee_default] +# +# Sets the base cost of a transaction in drops. Used when the server has +# no other source of fee information, such as signing transactions offline. +# +# +# +# [workers] +# +# Configures the number of threads for processing work submitted by peers +# and clients. If not specified, then the value is automatically set to the +# number of processor threads plus 2 for networked nodes. Nodes running in +# stand alone mode default to 1 worker. +# +# [io_workers] +# +# Configures the number of threads for processing raw inbound and outbound IO. +# +# [prefetch_workers] +# +# Configures the number of threads for performing nodestore prefetching. +# +# +# +# [network_id] +# +# Specify the network which this server is configured to connect to and +# track. If set, the server will not establish connections with servers +# that are explicitly configured to track another network. +# +# Network identifiers are usually unsigned integers in the range 0 to +# 4294967295 inclusive. The server also maps the following well-known +# names to the corresponding numerical identifier: +# +# main -> 0 +# testnet -> 1 +# devnet -> 2 +# +# If this value is not specified the server is not explicitly configured +# to track a particular network. +# +# +# [ledger_replay] +# +# 0 or 1. +# +# 0: Disable the ledger replay feature [default] +# 1: Enable the ledger replay feature. With this feature enabled, when +# acquiring a ledger from the network, a rippled node only downloads +# the ledger header and the transactions instead of the whole ledger. +# And the ledger is built by applying the transactions to the parent +# ledger. +# +#------------------------------------------------------------------------------- +# +# 4. HTTPS Client +# +#---------------- +# +# The rippled server instance uses HTTPS GET requests in a variety of +# circumstances, including but not limited to contacting trusted domains to +# fetch information such as mapping an email address to a Ripple Payment +# Network address. +# +# [ssl_verify] +# +# 0 or 1. +# +# 0. HTTPS client connections will not verify certificates. +# 1. Certificates will be checked for HTTPS client connections. +# +# If not specified, this parameter defaults to 1. +# +# +# +# [ssl_verify_file] +# +# +# +# A file system path leading to the certificate verification file for +# HTTPS client requests. +# +# +# +# [ssl_verify_dir] +# +# +# +# +# A file system path leading to a file or directory containing the root +# certificates that the server will accept for verifying HTTP servers. +# Used only for outbound HTTPS client connections. +# +#------------------------------------------------------------------------------- +# +# 6. Database +# +#------------ +# +# rippled creates 4 SQLite database to hold bookkeeping information +# about transactions, local credentials, and various other things. +# It also creates the NodeDB, which holds all the objects that +# make up the current and historical ledgers. +# +# The size of the NodeDB grows in proportion to the amount of new data and the +# amount of historical data (a configurable setting) so the performance of the +# underlying storage media where the NodeDB is placed can significantly affect +# the performance of the server. +# +# Partial pathnames will be considered relative to the location of +# the rippled.cfg file. +# +# [node_db] Settings for the Node Database (required) +# +# Format (without spaces): +# One or more lines of case-insensitive key / value pairs: +# '=' +# ... +# +# Example: +# type=nudb +# path=db/nudb +# +# The "type" field must be present and controls the choice of backend: +# +# type = NuDB +# +# NuDB is a high-performance database written by Ripple Labs and optimized +# for rippled and solid-state drives. +# +# NuDB maintains its high speed regardless of the amount of history +# stored. Online delete may be selected, but is not required. NuDB is +# available on all platforms that rippled runs on. +# +# type = RocksDB +# +# RocksDB is an open-source, general-purpose key/value store - see +# http://rocksdb.org/ for more details. +# +# RocksDB is an alternative backend for systems that don't use solid-state +# drives. Because RocksDB's performance degrades as it stores more data, +# keeping full history is not advised, and using online delete is +# recommended. +# +# Required keys for NuDB and RocksDB: +# +# path Location to store the database +# +# Optional keys +# +# cache_size Size of cache for database records. Default is 16384. +# Setting this value to 0 will use the default value. +# +# cache_age Length of time in minutes to keep database records +# cached. Default is 5 minutes. Setting this value to +# 0 will use the default value. +# +# Note: if neither cache_size nor cache_age is +# specified, the cache for database records will not +# be created. If only one of cache_size or cache_age +# is specified, the cache will be created using the +# default value for the unspecified parameter. +# +# Note: the cache will not be created if online_delete +# is specified. +# +# fast_load Boolean. If set, load the last persisted ledger +# from disk upon process start before syncing to +# the network. This is likely to improve performance +# if sufficient IOPS capacity is available. +# Default 0. +# +# Optional keys for NuDB or RocksDB: +# +# earliest_seq The default is 32570 to match the XRP ledger +# network's earliest allowed sequence. Alternate +# networks may set this value. Minimum value of 1. +# +# online_delete Minimum value of 256. Enable automatic purging +# of older ledger information. Maintain at least this +# number of ledger records online. Must be greater +# than or equal to ledger_history. +# +# These keys modify the behavior of online_delete, and thus are only +# relevant if online_delete is defined and non-zero: +# +# advisory_delete 0 for disabled, 1 for enabled. If set, the +# administrative RPC call "can_delete" is required +# to enable online deletion of ledger records. +# Online deletion does not run automatically if +# non-zero and the last deletion was on a ledger +# greater than the current "can_delete" setting. +# Default is 0. +# +# delete_batch When automatically purging, SQLite database +# records are deleted in batches. This value +# controls the maximum size of each batch. Larger +# batches keep the databases locked for more time, +# which may cause other functions to fall behind, +# and thus cause the node to lose sync. +# Default is 100. +# +# back_off_milliseconds +# Number of milliseconds to wait between +# online_delete batches to allow other functions +# to catch up. +# Default is 100. +# +# age_threshold_seconds +# The online delete process will only run if the +# latest validated ledger is younger than this +# number of seconds. +# Default is 60. +# +# recovery_wait_seconds +# The online delete process checks periodically +# that rippled is still in sync with the network, +# and that the validated ledger is less than +# 'age_threshold_seconds' old. If not, then continue +# sleeping for this number of seconds and +# checking until healthy. +# Default is 5. +# +# Notes: +# The 'node_db' entry configures the primary, persistent storage. +# +# The 'import_db' is used with the '--import' command line option to +# migrate the specified database into the current database given +# in the [node_db] section. +# +# [import_db] Settings for performing a one-time import (optional) +# [database_path] Path to the book-keeping databases. +# +# The server creates and maintains 4 to 5 bookkeeping SQLite databases in +# the 'database_path' location. If you omit this configuration setting, +# the server creates a directory called "db" located in the same place as +# your rippled.cfg file. +# Partial pathnames are relative to the location of the rippled executable. +# +# [sqlite] Tuning settings for the SQLite databases (optional) +# +# Format (without spaces): +# One or more lines of case-insensitive key / value pairs: +# '=' +# ... +# +# Example 1: +# safety_level=low +# +# Example 2: +# journal_mode=off +# synchronous=off +# +# WARNING: These settings can have significant effects on data integrity, +# particularly in systemic failure scenarios. It is strongly recommended +# that they be left at their defaults unless the server is having +# performance issues during normal operation or during automatic purging +# (online_delete) operations. A warning will be logged on startup if +# 'ledger_history' is configured to store more than 10,000,000 ledgers and +# any of these settings are less safe than the default. This is due to the +# inordinate amount of time and bandwidth it will take to safely rebuild a +# corrupted database of that size from other peers. +# +# Optional keys: +# +# safety_level Valid values: high, low +# The default is "high", which tunes the SQLite +# databases in the most reliable mode, and is +# equivalent to: +# journal_mode=wal +# synchronous=normal +# temp_store=file +# "low" is equivalent to: +# journal_mode=memory +# synchronous=off +# temp_store=memory +# These "low" settings trade speed and reduced I/O +# for a higher risk of data loss. See the +# individual settings below for more information. +# This setting may not be combined with any of the +# other tuning settings: "journal_mode", +# "synchronous", or "temp_store". +# +# journal_mode Valid values: delete, truncate, persist, memory, wal, off +# The default is "wal", which uses a write-ahead +# log to implement database transactions. +# Alternately, "memory" saves disk I/O, but if +# rippled crashes during a transaction, the +# database is likely to be corrupted. +# See https://www.sqlite.org/pragma.html#pragma_journal_mode +# for more details about the available options. +# This setting may not be combined with the +# "safety_level" setting. +# +# synchronous Valid values: off, normal, full, extra +# The default is "normal", which works well with +# the "wal" journal mode. Alternatively, "off" +# allows rippled to continue as soon as data is +# passed to the OS, which can significantly +# increase speed, but risks data corruption if +# the host computer crashes before writing that +# data to disk. +# See https://www.sqlite.org/pragma.html#pragma_synchronous +# for more details about the available options. +# This setting may not be combined with the +# "safety_level" setting. +# +# temp_store Valid values: default, file, memory +# The default is "file", which will use files +# for temporary database tables and indices. +# Alternatively, "memory" may save I/O, but +# rippled does not currently use many, if any, +# of these temporary objects. +# See https://www.sqlite.org/pragma.html#pragma_temp_store +# for more details about the available options. +# This setting may not be combined with the +# "safety_level" setting. +# +# page_size Valid values: integer (MUST be power of 2 between 512 and 65536) +# The default is 4096 bytes. This setting determines +# the size of a page in the transaction.db file. +# See https://www.sqlite.org/pragma.html#pragma_page_size +# for more details about the available options. +# +# journal_size_limit Valid values: integer +# The default is 1582080. This setting limits +# the size of the journal for transaction.db file. When the limit is +# reached, older entries will be deleted. +# See https://www.sqlite.org/pragma.html#pragma_journal_size_limit +# for more details about the available options. +# +# +#------------------------------------------------------------------------------- +# +# 7. Diagnostics +# +#--------------- +# +# These settings are designed to help server administrators diagnose +# problems, and obtain detailed information about the activities being +# performed by the rippled process. +# +# +# +# [debug_logfile] +# +# Specifies where a debug logfile is kept. By default, no debug log is kept. +# Unless absolute, the path is relative the directory containing this file. +# +# Example: debug.log +# +# +# +# [insight] +# +# Configuration parameters for the Beast. Insight stats collection module. +# +# Insight is a module that collects information from the areas of rippled +# that have instrumentation. The configuration parameters control where the +# collection metrics are sent. The parameters are expressed as key = value +# pairs with no white space. The main parameter is the choice of server: +# +# "server" +# +# Choice of server to send metrics to. Currently the only choice is +# "statsd" which sends UDP packets to a StatsD daemon, which must be +# running while rippled is running. More information on StatsD is +# available here: +# https://github.com/b/statsd_spec +# +# When server=statsd, these additional keys are used: +# +# "address" The UDP address and port of the listening StatsD server, +# in the format, n.n.n.n:port. +# +# "prefix" A string prepended to each collected metric. This is used +# to distinguish between different running instances of rippled. +# +# If this section is missing, or the server type is unspecified or unknown, +# statistics are not collected or reported. +# +# Example: +# +# [insight] +# server=statsd +# address=192.168.0.95:4201 +# prefix=my_validator +# +# [perf] +# +# Configuration of performance logging. If enabled, write Json-formatted +# performance-oriented data periodically to a distinct log file. +# +# "perf_log" A string specifying the pathname of the performance log +# file. A relative pathname will log relative to the +# configuration directory. Required to enable +# performance logging. +# +# "log_interval" Integer value for number of seconds between writing +# to performance log. Default 1. +# +# Example: +# [perf] +# perf_log=/var/log/rippled/perf.log +# log_interval=2 +# +#------------------------------------------------------------------------------- +# +# 8. Voting +# +#---------- +# +# The vote settings configure settings for the entire Ripple network. +# While a single instance of rippled cannot unilaterally enforce network-wide +# settings, these choices become part of the instance's vote during the +# consensus process for each voting ledger. +# +# [voting] +# +# A set of key/value pair parameters used during voting ledgers. +# +# reference_fee = +# +# The cost of the reference transaction fee, specified in drops. +# The reference transaction is the simplest form of transaction. +# It represents an XRP payment between two parties. +# +# If this parameter is unspecified, rippled will use an internal +# default. Don't change this without understanding the consequences. +# +# Example: +# reference_fee = 10 # 10 drops +# +# account_reserve = +# +# The account reserve requirement is specified in drops. The portion of an +# account's XRP balance that is at or below the reserve may only be +# spent on transaction fees, and not transferred out of the account. +# +# If this parameter is unspecified, rippled will use an internal +# default. Don't change this without understanding the consequences. +# +# Example: +# account_reserve = 10000000 # 10 XRP +# +# owner_reserve = +# +# The owner reserve is the amount of XRP reserved in the account for +# each ledger item owned by the account. Ledger items an account may +# own include trust lines, open orders, and tickets. +# +# If this parameter is unspecified, rippled will use an internal +# default. Don't change this without understanding the consequences. +# +# Example: +# owner_reserve = 2000000 # 2 XRP +# +#------------------------------------------------------------------------------- +# +# 9. Misc Settings +# +#----------------- +# +# [node_size] +# +# Tunes the servers based on the expected load and available memory. Legal +# sizes are "tiny", "small", "medium", "large", and "huge". We recommend +# you start at the default and raise the setting if you have extra memory. +# +# The code attempts to automatically determine the appropriate size for +# this parameter based on the amount of RAM and the number of execution +# cores available to the server. The current decision matrix is: +# +# | | Cores | +# |---------|------------------------| +# | RAM | 1 | 2 or 3 | ≥ 4 | +# |---------|------|--------|--------| +# | < ~8GB | tiny | tiny | tiny | +# | < ~12GB | tiny | small | small | +# | < ~16GB | tiny | small | medium | +# | < ~24GB | tiny | small | large | +# | < ~32GB | tiny | small | huge | +# +# [signing_support] +# +# Specifies whether the server will accept "sign" and "sign_for" commands +# from remote users. Even if the commands are sent over a secure protocol +# like secure websocket, this should generally be discouraged, because it +# requires sending the secret to use for signing to the server. In order +# to sign transactions, users should prefer to use a standalone signing +# tool instead. +# +# This flag has no effect on the "sign" and "sign_for" command line options +# that rippled makes available. +# +# The default value of this field is "false" +# +# Example: +# +# [signing_support] +# true +# +# [crawl] +# +# List of options to control what data is reported through the /crawl endpoint +# See https://xrpl.org/peer-crawler.html +# +# +# +# Enable or disable access to /crawl requests. Default is '1' which +# enables access. +# +# overlay = +# +# Report information about peers this server is connected to, similar +# to the "peers" RPC API. Default is '1' which means to report peer +# overlay info. +# +# server = +# +# Report information about the local server, similar to the "server_state" +# RPC API. Default is '1' which means to report local server info. +# +# counts = +# +# Report information about the local server health counters, similar to +# the "get_counts" RPC API. Default is '0' which means not to report +# server counts. +# +# unl = +# +# Report information about the local server's validator lists, similar to +# the "validators" and "validator_list_sites" RPC APIs. Default is '1' +# which means to report server validator lists. +# +# Examples: +# +# [crawl] +# 0 +# +# [crawl] +# overlay = 1 +# server = 1 +# counts = 0 +# unl = 1 +# +# [vl] +# +# Options to control what data is reported through the /vl endpoint +# See [...] +# +# enable = +# +# Enable or disable access to /vl requests. Default is '1' which +# enables access. +# +# [beta_rpc_api] +# +# 0 or 1. +# +# 0: Disable the beta API version for JSON-RPC and WebSocket [default] +# 1: Enable the beta API version for testing. The beta API version +# contains breaking changes that require a new API version number. +# They are not ready for public consumption. +# +#------------------------------------------------------------------------------- +# +# 10. Example Settings +# +#-------------------- +# +# Administrators can use these values as a starting point for configuring +# their instance of rippled, but each value should be checked to make sure +# it meets the business requirements for the organization. +# +# Server +# +# These example configuration settings create these ports: +# +# "peer" +# +# Peer protocol open to everyone. This is required to accept +# incoming rippled connections. This does not affect automatic +# or manual outgoing Peer protocol connections. +# +# "rpc" +# +# Administrative RPC commands over HTTPS, when originating from +# the same machine (via the loopback adapter at 127.0.0.1). +# +# "wss_admin" +# +# Admin level API commands over Secure Websockets, when originating +# from the same machine (via the loopback adapter at 127.0.0.1). +# +# "grpc" +# +# ETL commands for Clio. We recommend setting secure_gateway +# in this section to a comma-separated list of the addresses +# of your Clio servers, in order to bypass rippled's rate limiting. +# +# This port is commented out but can be enabled by removing +# the '#' from each corresponding line including the entry under [server] +# +# "wss_public" +# +# Guest level API commands over Secure Websockets, open to everyone. +# +# For HTTPS and Secure Websockets ports, if no certificate and key file +# are specified then a self-signed certificate will be generated on startup. +# If you have a certificate and key file, uncomment the corresponding lines +# and ensure the paths to the files are correct. +# +# NOTE +# +# To accept connections on well known ports such as 80 (HTTP) or +# 443 (HTTPS), most operating systems will require rippled to +# run with administrator privileges, or else rippled will not start. + +[server] +port_rpc_admin_local +port_peer +port_ws_admin_local +port_ws_public +#ssl_key = /etc/ssl/private/server.key +#ssl_cert = /etc/ssl/certs/server.crt + +[port_rpc_admin_local] +port = 5005 +ip = 127.0.0.1 +admin = 127.0.0.1 +protocol = http + +[port_peer] +port = 51235 +ip = 0.0.0.0 +# alternatively, to accept connections on IPv4 + IPv6, use: +#ip = :: +protocol = peer + +[port_ws_admin_local] +port = 6006 +ip = 127.0.0.1 +admin = 127.0.0.1 +protocol = ws +send_queue_limit = 500 + +[port_grpc] +port = 50051 +ip = 127.0.0.1 +secure_gateway = 127.0.0.1 + +[port_ws_public] +port = 6005 +ip = 0.0.0.0 +protocol = wss,ws,http +send_queue_limit = 500 + +#------------------------------------------------------------------------------- + +# This is primary persistent datastore for rippled. This includes transaction +# metadata, account states, and ledger headers. Helpful information can be +# found at https://xrpl.org/capacity-planning.html#node-db-type +# type=NuDB is recommended for non-validators with fast SSDs. Validators or +# slow / spinning disks should use RocksDB. Caution: Spinning disks are +# not recommended. They do not perform well enough to consistently remain +# synced to the network. +# online_delete=512 is recommended to delete old ledgers while maintaining at +# least 512. +# advisory_delete=0 allows the online delete process to run automatically +# when the node has approximately two times the "online_delete" value of +# ledgers. No external administrative command is required to initiate +# deletion. + +[node_db] +type=NuDB +path=/var/lib/rippled/db/nudb +online_delete=<> +advisory_delete=<> + +[database_path] +/var/lib/rippled/db + + +# This needs to be an absolute directory reference, not a relative one. +# Modify this value as required. +[debug_logfile] +/var/log/rippled/debug.log + +# To use the XRP test network +# (see https://xrpl.org/connect-your-rippled-to-the-xrp-test-net.html), +# use the following [ips] section: +# [ips] +# r.altnet.rippletest.net 51235 +[ips] +<> +[network_id] +<> +# File containing trusted validator keys or validator list publishers. +# Unless an absolute path is specified, it will be considered relative to the +# folder in which the rippled.cfg file is located. +[validators_file] +validators.txt + +# Turn down default logging to save disk space in the long run. +# Valid values here are trace, debug, info, warning, error, and fatal +[rpc_startup] +{ "command": "log_level", "severity": "warning" } + +# If ssl_verify is 1, certificates will be validated. +# To allow the use of self-signed certificates for development or internal use, +# set to ssl_verify to 0. +[ssl_verify] +1 +[crawl] +1 diff --git a/lib/xrp/lib/assets/rippled/rippled.cfg.template b/lib/xrp/lib/assets/rippled/rippled.cfg.template new file mode 100644 index 00000000..01b32809 --- /dev/null +++ b/lib/xrp/lib/assets/rippled/rippled.cfg.template @@ -0,0 +1,31 @@ +[server] +port_peer +port_rpc_admin_local +port_ws_admin_local +[port_rpc_admin_local] +[port_peer] +[port_ws_admin_local] + +[node_db] +[database_path] +/var/lib/rippled/db +# This needs to be an absolute directory reference, not a relative one. +# Modify this value as required. +[debug_logfile] +/var/log/rippled/debug.log +[ips] +[network_id] +[validators_file] +validators.txt + +# Turn down default logging to save disk space in the long run. +# Valid values here are trace, debug, info, warning, error, and fatal +[rpc_startup] +{ "command": "log_level", "severity": "warning" } +# If ssl_verify is 1, certificates will be validated. +# To allow the use of self-signed certificates for development or internal use, +# set to ssl_verify to 0. +[ssl_verify] +1 +[crawl] +1 \ No newline at end of file diff --git a/lib/xrp/lib/assets/rippled/rippledconfig.py b/lib/xrp/lib/assets/rippled/rippledconfig.py new file mode 100644 index 00000000..a8328352 --- /dev/null +++ b/lib/xrp/lib/assets/rippled/rippledconfig.py @@ -0,0 +1,51 @@ +# amazonq-ignore-next-line +rippled_cfg_file = "/opt/ripple/etc/rippled.cfg" +rippled_validator_file = "/opt/ripple/etc/validators.txt" +xrp_defaults = { + "server_ports": { + "port_peer": { + "port": "51235", + "protocol": "peer", + "ip": "0.0.0.0", + }, + "port_rpc_admin_local": { + "port": "5005", + "ip": "127.0.0.1", + "admin": "127.0.0.1", + "protocol": "http,https", + }, + "port_ws_admin_local": { + "port": "6006", + "ip": "127.0.0.1", + "admin": "127.0.0.1", + "protocol": "ws,wss", + }, + }, + "db_defaults": { + "node_db": { + "type": "NuDB", + "path": "/var/lib/rippled/db/nudb", + "online_delete": "512", + "advisory_delete": "1", + } + }, + "network_defaults": { + "mainnet": { + "network_id": "mainnet", + "ssl_verify": "1", + "validator_list_sites": ["https://vl.ripple.com"], + "validator_list_keys": [ + "ED2677ABFFD1B33AC6FBC3062B71F1E8397C1505E1C42C64D11AD1B28FF73F4734" + ], + }, + "testnet": { + "network_id": "testnet", + "ssl_verify": "0", + "ips": "s.altnet.rippletest.net 51235", + "validator_list_sites": ["https://vl.altnet.rippletest.net"], + "validator_list_keys": [ + "ED264807102805220DA0F312E71FC2C69E1552C9C5790F6C25E3729DEB573D5860" + ], + }, + }, +} diff --git a/lib/xrp/lib/assets/rippled/validators.txt.template b/lib/xrp/lib/assets/rippled/validators.txt.template new file mode 100644 index 00000000..bd4f936c --- /dev/null +++ b/lib/xrp/lib/assets/rippled/validators.txt.template @@ -0,0 +1,59 @@ +# +# Default validators.txt +# +# This file is located in the same folder as your rippled.cfg file +# and defines which validators your server trusts not to collude. +# +# This file is UTF-8 with DOS, UNIX, or Mac style line endings. +# Blank lines and lines starting with a '#' are ignored. +# +# +# +# [validators] +# +# List of the validation public keys of nodes to always accept as validators. +# +# Manually listing validator keys is not recommended for production networks. +# See validator_list_sites and validator_list_keys below. +# +# Examples: +# n9KorY8QtTdRx7TVDpwnG9NvyxsDwHUKUEeDLY3AkiGncVaSXZi5 +# n9MqiExBcoG19UXwoLjBJnhsxEhAZMuWwJDRdkyDz1EkEkwzQTNt +# +# [validator_list_sites] +# +# List of URIs serving lists of recommended validators. +# +# Examples: +# https://vl.ripple.com +# https://vl.xrplf.org +# http://127.0.0.1:8000 +# file:///etc/opt/ripple/vl.txt +# +# [validator_list_keys] +# +# List of keys belonging to trusted validator list publishers. +# Validator lists fetched from configured sites will only be considered +# if the list is accompanied by a valid signature from a trusted +# publisher key. +# Validator list keys should be hex-encoded. +# +# Examples: +# ED2677ABFFD1B33AC6FBC3062B71F1E8397C1505E1C42C64D11AD1B28FF73F4734 +# ED307A760EE34F2D0CAA103377B1969117C38B8AA0AA1E2A24DAC1F32FC97087ED +# + +# The default validator list publishers that the rippled instance +# trusts. +# +# WARNING: Changing these values can cause your rippled instance to see a +# validated ledger that contradicts other rippled instances' +# validated ledgers (aka a ledger fork) if your validator list(s) +# do not sufficiently overlap with the list(s) used by others. +# See: https://arxiv.org/pdf/1802.07242.pdf + +[validator_list_sites] +<> + +[validator_list_keys] +<> \ No newline at end of file diff --git a/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh b/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh new file mode 100644 index 00000000..fc21cae0 --- /dev/null +++ b/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh @@ -0,0 +1,215 @@ +#!/bin/bash +############################################################################### +# check_xrp_sequence.sh +# +# This script retrieves the current validated ledger sequence number from a local +# XRP node and sends it to AWS CloudWatch as a metric. Includes retry logic, +# proper error handling, and ensures only one instance runs at a time. +# +# Requirements: +# - AWS CLI +# - jq +# - curl +# - Local rippled node running on port 5005 +# +# The script is idempotent and includes the following features: +# - Lockfile to prevent multiple concurrent executions +# - Retry mechanism for all external calls +# - Proper signal handling and cleanup +# - Consistent logging +# - Comprehensive error handling +############################################################################### + +set -euo pipefail + +# Configuration +MAX_RETRIES=3 +RETRY_DELAY=5 +NAMESPACE="CWAgent" +METRIC_NAME="XRP_Sequence" +LOCKFILE="/tmp/check_xrp_sequence.lock" +LOCK_FD=200 + +# Logging functions +log() { + local level=$1 + local message=$2 + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [${level}] ${message}" +} + +log_info() { + log "INFO" "$1" +} + +log_error() { + log "ERROR" "$1" +} + +log_warning() { + log "WARN" "$1" +} + +# Error handling +handle_error() { + local exit_code=$1 + local error_msg=$2 + log_error "${error_msg}" + exit "${exit_code}" +} + +# Function to clean up lock file +cleanup() { + local exit_code=$? + log_info "Cleaning up..." + # Release lock file + flock -u ${LOCK_FD} + rm -f "${LOCKFILE}" + exit ${exit_code} +} + +# Handle signals +trap cleanup EXIT +trap 'exit 1' INT TERM +# Get instance metadata with retries +get_metadata() { + local endpoint=$1 + local retry_count=0 + local result + + while [[ ${retry_count} -lt ${MAX_RETRIES} ]]; do + if result=$(curl -s -f -H "X-aws-ec2-metadata-token: $(curl -s -f -X PUT 'http://169.254.169.254/latest/api/token' -H 'X-aws-ec2-metadata-token-ttl-seconds: 21600')" "http://169.254.169.254/latest/meta-data/${endpoint}"); then + echo "${result}" + return 0 + fi + log_warning "Failed to get metadata from ${endpoint}, attempt $((retry_count + 1))/${MAX_RETRIES}" + retry_count=$((retry_count + 1)) + sleep ${RETRY_DELAY} + done + + log_error "Failed to retrieve metadata from ${endpoint} after ${MAX_RETRIES} attempts" + return 1 +} + +# Check dependencies +check_dependencies() { + log_info "Checking dependencies..." + local missing_deps=() + + for cmd in aws jq curl; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + missing_deps+=("${cmd}") + fi + done + + if [[ ${#missing_deps[@]} -gt 0 ]]; then + log_error "Missing required dependencies: ${missing_deps[*]}" + return 1 + fi + + log_info "All dependencies satisfied" + return 0 +} + +# Function to get current sequence from rippled with retries +get_current_sequence() { + local retry_count=0 + local seq + + while [[ ${retry_count} -lt ${MAX_RETRIES} ]]; do + if seq=$(curl -s -f -H 'Content-Type: application/json' \ + -d '{"method":"server_info","params":[{}]}' \ + http://localhost:5005/ | \ + jq -e '.result.info.validated_ledger.seq // 0'); then + if [[ "${seq}" != "0" ]]; then + echo "${seq}" + return 0 + fi + fi + log_warning "Failed to get sequence, attempt $((retry_count + 1))/${MAX_RETRIES}" + retry_count=$((retry_count + 1)) + sleep ${RETRY_DELAY} + done + + log_error "Failed to get current sequence after ${MAX_RETRIES} attempts" + return 1 +} + +# Function to send metric to CloudWatch with retries +send_to_cloudwatch() { + local sequence=$1 + local retry_count=0 + + while [[ ${retry_count} -lt ${MAX_RETRIES} ]]; do + if aws cloudwatch put-metric-data \ + --namespace "${NAMESPACE}" \ + --metric-name "${METRIC_NAME}" \ + --value "${sequence}" \ + --region "${REGION}" \ + --dimensions "InstanceId=${INSTANCE_ID}" \ + --timestamp "${TIMESTAMP}"; then + log_info "Successfully sent sequence ${sequence} to CloudWatch" + return 0 + fi + log_warning "Failed to send metrics to CloudWatch, attempt $((retry_count + 1))/${MAX_RETRIES}" + retry_count=$((retry_count + 1)) + sleep ${RETRY_DELAY} + done + + log_error "Failed to send metrics to CloudWatch after ${MAX_RETRIES} attempts" + return 1 +} + +# Initialize environment variables +init_environment() { + log_info "Initializing environment variables" + REGION=$(get_metadata "placement/region") || return 1 + INSTANCE_ID=$(get_metadata "instance-id") || return 1 + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + return 0 +} + +# Main function +main() { + local sequence + + log_info "Starting XRP sequence check" + + # Ensure only one instance is running + exec {LOCK_FD}>"${LOCKFILE}" + if ! flock -n "${LOCK_FD}"; then + log_error "Another instance is already running" + return 1 + fi + + # Check dependencies first + if ! check_dependencies; then + return 1 + fi + + # Initialize environment variables + if ! init_environment; then + return 1 + fi + + # Get current sequence + if ! sequence=$(get_current_sequence); then + return 1 + fi + + log_info "Retrieved sequence: ${sequence}" + + # Send to CloudWatch + if ! send_to_cloudwatch "${sequence}"; then + return 1 + fi + + log_info "XRP sequence check completed successfully" + return 0 +} + +# Execute main function +if ! main; then + handle_error 1 "Failed to complete XRP sequence check" +fi + +exit 0 \ No newline at end of file diff --git a/lib/xrp/lib/assets/user-data/node.sh b/lib/xrp/lib/assets/user-data/node.sh new file mode 100644 index 00000000..c87fbc7c --- /dev/null +++ b/lib/xrp/lib/assets/user-data/node.sh @@ -0,0 +1,447 @@ +#!/bin/bash + +# Enable error handling and debugging +set -eo pipefail + +exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1 +################### +# Constants +################### +readonly RIPPLED_CONFIG_DIR="/opt/ripple/etc" +readonly YUM_REPO_DIR="/etc/yum.repos.d" +readonly ENV_FILE="/etc/environment" +readonly RIPPLED_USER="rippled" +readonly RIPPLED_GROUP="rippled" +readonly RIPPLED_UID=1111 +readonly RIPPLED_GID=1111 +readonly MOUNT_POINT="/var/lib/rippled" +readonly MAX_RETRIES=3 +readonly RETRY_DELAY=5 +readonly DATA_VOLUME_NAME="/dev/sdf" +readonly ASSETS_DIR="/root/assets" +readonly ASSETS_ZIP="/root/assets.zip" + +################### +# Logging Functions +################### +log() { + local level="$1" + local message="$2" + echo "[$(date +'%Y-%m-%d %H:%M:%S')] [${level}] ${message}" | tee -a /var/log/rippled-setup.log +} + +log_info() { + log "INFO" "$1" +} + +log_error() { + log "ERROR" "$1" >&2 +} + +log_warning() { + log "WARNING" "$1" +} + +################### +# Error Handling +################### +handle_error() { + local exit_code=$? + local line_number=$1 + log_error "Failed at line ${line_number} with exit code ${exit_code}" + + exit "${exit_code}" +} + +trap 'handle_error ${LINENO}' ERR + +################### +# Environment Setup +################### +setup_environment() { + log_info "Setting up environment variables" + + # Backup existing environment file + if [[ -f "${ENV_FILE}" ]]; then + cp "${ENV_FILE}" "${ENV_FILE}.$(date +%Y%m%d_%H%M%S).backup" + fi + + declare -A env_vars=( + ["AWS_REGION"]="_AWS_REGION_" + ["ASSETS_S3_PATH"]="_ASSETS_S3_PATH_" + ["STACK_NAME"]="_STACK_NAME_" + ["STACK_ID"]="_STACK_ID_" + ["RESOURCE_ID"]="_NODE_CF_LOGICAL_ID_" + # ["HUB_NETWORK_IP"]="_HUB_NETWORK_IP_" + ["XRP_NETWORK"]="_HUB_NETWORK_ID_" + # ["VALIDATOR_LIST_SITES"]="_VALIDATOR_LIST_SITES_" + # ["VALIDATOR_LIST_KEYS"]="_VALIDATOR_LIST_KEYS_" + # ["ONLINE_DELETE"]="_ONLINE_DELETE_" + # ["ADVISORY_DELETE"]="_ADVISORY_DELETE_" + ["DATA_VOLUME_TYPE"]="_DATA_VOLUME_TYPE_" + ["DATA_VOLUME_SIZE"]="_DATA_VOLUME_SIZE_" + ["LIFECYCLE_HOOK_NAME"]="_LIFECYCLE_HOOK_NAME_" + ["ASG_NAME"]="_ASG_NAME_" + ) + + # Clear and recreate environment file + : >"${ENV_FILE}" + + for key in "${!env_vars[@]}"; do + local value="${env_vars[${key}]}" + if [[ "${value}" =~ [[:space:]] || "${value}" =~ [^a-zA-Z0-9_./-] ]]; then + echo "export ${key}=\"${value}\"" >>"${ENV_FILE}" + else + echo "export ${key}=${value}" >>"${ENV_FILE}" + fi + done + + # Source the environment file + # shellcheck source=/dev/null + source "${ENV_FILE}" +} +install_rippled() { + log_info "Installing/updating rippled on Amazon Linux 2..." + setup_environment + + # Setup repository if not exists + if [[ ! -f "$YUM_REPO_DIR/ripple.repo" ]]; then + log_info "Setting up ripple repository..." + sudo cp ${ASSETS_DIR}/rippled/ripple.repo "$YUM_REPO_DIR/ripple.repo" + fi + + sudo yum -y update + + # Install/update rippled if needed + if ! rpm -q rippled &>/dev/null; then + log_info "Installing rippled package..." + sudo yum install -y rippled + else + log_info "rippled package already installed, checking for updates..." + sudo yum update -y rippled + fi + + log_info "build out and write rippled.cfg and validaotrs.txt" + python3 ${ASSETS_DIR}/rippled/configBuilder.py ${ASSETS_DIR} + +} + +# Function to start and verify rippled service +start_rippled() { + echo "Starting rippled service..." + sudo systemctl enable --now rippled + sudo systemctl start rippled + + # Verify service status + if ! sudo systemctl status rippled; then + echo "Failed to start rippled service" + return 1 + fi + echo "rippled service started successfully" +} + +################### +# System Setup +################### +install_dependencies() { + log_info "Installing system dependencies" + + local packages=( + "cmake" + "git" + "gcc-c++" + "snappy-devel" + "libicu-devel" + "zlib-devel" + "jq" + "unzip" + "amazon-cloudwatch-agent" + "openssl-devel" + "libffi-devel" + "bzip2-devel" + "wget" + ) + + # Check for packages that need to be installed + local packages_to_install=() + for package in "${packages[@]}"; do + if ! rpm -q "$package" &>/dev/null; then + log_info "Package $package needs to be installed" + packages_to_install+=("$package") + else + log_info "Package $package is already installed" + fi + done + + # If no packages need installation, we're done + if [ ${#packages_to_install[@]} -eq 0 ]; then + log_info "All required packages are already installed" + return 0 + fi + + local retry_count=0 + while [[ ${retry_count} -lt ${MAX_RETRIES} ]]; do + if sudo yum update -y && + sudo yum groupinstall -y "Development Tools" && + sudo yum install -y "${packages[@]}"; then + return 0 + fi + + retry_count=$((retry_count + 1)) + log_warning "Retry ${retry_count}/${MAX_RETRIES} for package installation" + sleep "${RETRY_DELAY}" + done + log_error "Failed to install dependencies after ${MAX_RETRIES} attempts" + return 1 +} + +################### +# User Management +################### +setup_user_and_group() { + log_info "Setting up rippled user and group" + + # Create group if it doesn't exist + if ! getent group "${RIPPLED_GROUP}" >/dev/null; then + sudo groupadd -g "${RIPPLED_GID}" "${RIPPLED_GROUP}" + fi + + # Create user if it doesn't exist + if ! getent passwd "${RIPPLED_USER}" >/dev/null; then + sudo useradd -u "${RIPPLED_UID}" -g "${RIPPLED_GID}" -m -s /bin/bash "${RIPPLED_USER}" + fi + + # Ensure home directory permissions are correct + sudo chown -R "${RIPPLED_USER}:${RIPPLED_GROUP}" "/home/${RIPPLED_USER}" +} + +################### +# Asset Management +################### +setup_assets() { + log_info "Downloading and extracting assets" + + # Clean up any existing assets + rm -rf "${ASSETS_DIR}" "${ASSETS_ZIP}" + + # Download and extract assets with retry logic + local retry_count=0 + while [[ ${retry_count} -lt ${MAX_RETRIES} ]]; do + if aws s3 cp "${ASSETS_S3_PATH}" "${ASSETS_ZIP}" --region "${AWS_REGION}" && + unzip -q "${ASSETS_ZIP}" -d "${ASSETS_DIR}"; then + return 0 + fi + + retry_count=$((retry_count + 1)) + log_warning "Retry ${retry_count}/${MAX_RETRIES} for asset download" + sleep "${RETRY_DELAY}" + done + + log_error "Failed to setup assets after ${MAX_RETRIES} attempts" + return 1 +} + +################### +# Volume Management +################### +get_data_volume_id() { + local volume_size="${1}" + lsblk -lnb | awk -v VOLUME_SIZE_BYTES="$DATA_VOLUME_SIZE" '{if ($4== ${volume_size}) {print $1}}' +} + +setup_data_volume() { + log_info "Setting up data volume" + + local volume_id + volume_id="$DATA_VOLUME_NAME" + + log_info "Data volume ID: ${volume_id}" + + # Verify volume exists + if [[ -z "${volume_id}" ]]; then + log_error "Data volume not found" + return 1 + fi + + # Check if device exists + local device="${volume_id}" + if [[ ! -b "${device}" ]]; then + log_error "Device ${device} not found" + return 1 + fi + + # Check if already mounted + if is_volume_mounted "${MOUNT_POINT}"; then + log_info "Data volume already mounted at ${MOUNT_POINT}" + # Verify correct permissions even if already mounted + sudo chown "${RIPPLED_USER}:${RIPPLED_GROUP}" "${MOUNT_POINT}" + return 0 + fi + + # Ensure mount point exists + if [[ ! -d "${MOUNT_POINT}" ]]; then + log_info "Creating mount point directory ${MOUNT_POINT}" + sudo mkdir -p "${MOUNT_POINT}" + fi + + # Format and mount + if ! format_and_mount_volume "${volume_id}"; then + log_error "Failed to format and mount volume ${volume_id}" + return 1 + fi + + log_info "Data volume setup completed successfully" + return 0 +} + +is_volume_mounted() { + local mount_point="${1}" + mountpoint -q "${mount_point}" +} + +format_and_mount_volume() { + local volume_id="${1}" + local device="${volume_id}" + local fstype="xfs" + + # Check if filesystem already exists + if ! blkid "${device}" | grep -q "${fstype}"; then + log_info "Formatting volume ${device} with ${fstype}" + if ! sudo mkfs.${fstype} "${device}"; then + log_error "Failed to format volume ${device}" + return 1 + fi + # Wait for filesystem to be ready + sleep 5 + else + log_info "Volume ${device} already formatted with ${fstype}" + fi + + # Get UUID + local volume_uuid + volume_uuid=$(lsblk -fn -o UUID "${volume_id}") + + if [[ -z "${volume_uuid}" ]]; then + log_error "Failed to get UUID for volume ${volume_id}" + return 1 + fi + + local fstab_entry="UUID=${volume_uuid} ${MOUNT_POINT} xfs defaults 0 2" + + # Update fstab + update_fstab "${fstab_entry}" + + # Create mount point and mount + sudo mkdir -p "${MOUNT_POINT}/db" + sudo chown -R "${RIPPLED_USER}:${RIPPLED_GROUP}" "${MOUNT_POINT}" + sudo mount -a + + # Set permissions + sudo chown -R "${RIPPLED_USER}:${RIPPLED_GROUP}" "${MOUNT_POINT}" +} + +update_fstab() { + local fstab_entry="${1}" + + # Backup fstab + sudo cp /etc/fstab "/etc/fstab.$(date +%Y%m%d_%H%M%S).backup" + + if grep -q "${MOUNT_POINT}" /etc/fstab; then + local line_num + line_num=$(grep -n "${MOUNT_POINT}" /etc/fstab | cut -d: -f1) + sudo sed -i "${line_num}s#.*#${fstab_entry}#" /etc/fstab + else + echo "${fstab_entry}" | sudo tee -a /etc/fstab + fi +} + +check_volume() { + local volume="$1" + local max_attempts=10 + local attempt=1 + local sleep_time=5 + + while ! blockdev --getro "$volume" 2>/dev/null; do + if [ $attempt -ge $max_attempts ]; then + log_error "Volume $volume not ready after $max_attempts attempts" + return 1 + fi + log_info "Waiting for volume $volume (attempt $attempt/$max_attempts)" + sleep $((sleep_time * attempt)) # Exponential backoff + ((attempt++)) + done + return 0 +} + +setup_cloud_watch() { + sudo cp ${ASSETS_DIR}/cw-agent.json "/opt/aws/amazon-cloudwatch-agent/etc/custom-amazon-cloudwatch-agent.json" + /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \ + -a fetch-config -c file:/opt/aws/amazon-cloudwatch-agent/etc/custom-amazon-cloudwatch-agent.json -m ec2 -s + systemctl restart amazon-cloudwatch-agent + + systemctl daemon-reload +} + +setup_seq_check() { + + echo "Configuring xrp ledger synch script" + + sudo cp ${ASSETS_DIR}/user-data/check_xrp_sequence.sh "/opt/check_xrp_sequence.sh" + sudo chmod +x /opt/check_xrp_sequence.sh + sudo chown rippled:rippled /opt/check_xrp_sequence.sh + + sudo cp "$ASSETS_DIR/user-data/synch-check.service" /etc/systemd/system/synch-check.service + sudo cp "$ASSETS_DIR/user-data/synch-check.timer" /etc/systemd/system/synch-check.timer + + sudo systemctl start synch-check.timer + sudo systemctl enable synch-check.timer + +} + +################### +# Main Function +################### +main() { + log_info "Starting rippled node installation" + setup_environment + if [[ "$RESOURCE_ID" != "none" ]]; then + cfn-signal --stack "${STACK_NAME}" --resource "${RESOURCE_ID}" --region "${AWS_REGION}" + fi + + #Check volume availability + if ! check_volume "${DATA_VOLUME_NAME}"; then + log_error "Volume check failed" + return 1 + fi + + local steps=( + install_dependencies + setup_user_and_group + setup_assets + setup_data_volume + setup_cloud_watch + install_rippled + start_rippled + setup_seq_check + ) + + for step in "${steps[@]}"; do + log_info "Executing step: ${step}" + if ! ${step}; then + log_error "Step ${step} failed" + return 1 + fi + done + if [[ "$LIFECYCLE_HOOK_NAME" != "none" ]]; then + setup_environment + echo "Signaling ASG lifecycle hook to complete" + TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600") + INSTANCE_ID=$(curl -H "X-aws-ec2-metadata-token: $TOKEN" -s http://169.254.169.254/latest/meta-data/instance-id) + aws autoscaling complete-lifecycle-action --lifecycle-action-result CONTINUE --instance-id "${INSTANCE_ID}" --lifecycle-hook-name "${LIFECYCLE_HOOK_NAME}" --auto-scaling-group-name "${ASG_NAME}" --region "${AWS_REGION}" + fi + + log_info "rippled installation completed successfully" +} + +# Execute main function +main diff --git a/lib/xrp/lib/assets/user-data/synch-check.service b/lib/xrp/lib/assets/user-data/synch-check.service new file mode 100644 index 00000000..a2a2c5e2 --- /dev/null +++ b/lib/xrp/lib/assets/user-data/synch-check.service @@ -0,0 +1,7 @@ +[Unit] +Description="XRP ledger sync status; gets current seq this ledger is on" +After=rippled.service + +[Service] +Type=oneshot +ExecStart=/opt/check_xrp_sequence.sh \ No newline at end of file diff --git a/lib/xrp/lib/assets/user-data/synch-check.timer b/lib/xrp/lib/assets/user-data/synch-check.timer new file mode 100644 index 00000000..97f78b05 --- /dev/null +++ b/lib/xrp/lib/assets/user-data/synch-check.timer @@ -0,0 +1,10 @@ +[Unit] +Description="Run Sync check service every 1 min" + +[Timer] +OnBootSec=1min +OnUnitActiveSec=1min +Unit=synch-check.service + +[Install] +WantedBy=timers.target \ No newline at end of file diff --git a/lib/xrp/lib/common-stack.ts b/lib/xrp/lib/common-stack.ts new file mode 100644 index 00000000..71b99a88 --- /dev/null +++ b/lib/xrp/lib/common-stack.ts @@ -0,0 +1,74 @@ +import * as cdk from "aws-cdk-lib"; +import * as cdkConstructs from "constructs"; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as nag from "cdk-nag"; + +export interface XRPCommonStackProps extends cdk.StackProps { + +} + +export class XRPCommonStack extends cdk.Stack { + AWS_STACKNAME = cdk.Stack.of(this).stackName; + AWS_ACCOUNT_ID = cdk.Stack.of(this).account; + instanceRole: iam.Role; + + constructor(scope: cdkConstructs.Construct, id: string, props: XRPCommonStackProps) { + super(scope, id, props); + + const region = cdk.Stack.of(this).region; + + this.instanceRole = new iam.Role(this, `node-role`, { + assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName("SecretsManagerReadWrite"), + iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"), + iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchAgentServerPolicy"), + ], + }); + + this.instanceRole.addToPolicy(new iam.PolicyStatement({ + // Can't target specific stack: https://github.com/aws/aws-cdk/issues/22657 + resources: ["*"], + actions: ["cloudformation:SignalResource"], + })); + + this.instanceRole.addToPolicy(new iam.PolicyStatement({ + resources: [`arn:aws:autoscaling:${region}:${this.AWS_ACCOUNT_ID}:autoScalingGroup:*:autoScalingGroupName/xrp-*`], + actions: ["autoscaling:CompleteLifecycleAction"], + })); + + this.instanceRole.addToPolicy( + new iam.PolicyStatement({ + resources: [ + `arn:aws:s3:::cloudformation-examples`, + `arn:aws:s3:::cloudformation-examples/*`, + ], + actions: ["s3:ListBucket", "s3:*Object", "s3:GetBucket*"], + }) + ); + + new cdk.CfnOutput(this, "Instance Role ARN", { + value: this.instanceRole.roleArn, + exportName: "XRPNodeInstanceRoleArn", + }); + + /** + * cdk-nag suppressions + */ + + nag.NagSuppressions.addResourceSuppressions( + this, + [ + { + id: "AwsSolutions-IAM4", + reason: "AmazonSSMManagedInstanceCore and CloudWatchAgentServerPolicy are restrictive enough", + }, + { + id: "AwsSolutions-IAM5", + reason: "Can't target specific stack: https://github.com/aws/aws-cdk/issues/22657", + }, + ], + true + ); + } +} diff --git a/lib/xrp/lib/config/XRPConfig.interface.ts b/lib/xrp/lib/config/XRPConfig.interface.ts new file mode 100644 index 00000000..c87cb4f1 --- /dev/null +++ b/lib/xrp/lib/config/XRPConfig.interface.ts @@ -0,0 +1,18 @@ +import * as configTypes from "../../../constructs/config.interface"; + +export interface XRPBaseNodeConfig extends configTypes.BaseNodeConfig { + hubNetworkID: string; + // hubNetworkIP: string; + // onlineDelete: string; + // advisoryDelete: string; + // validatorListSites: string; + // validatorListKeys: string; + dataVolume: configTypes.DataVolumeConfig; +} + +export interface HAXRPBaseNodeConfig extends XRPBaseNodeConfig { + albHealthCheckGracePeriodMin: number; + heartBeatDelayMin: number; + numberOfNodes: number; +} + diff --git a/lib/xrp/lib/config/XRPConfig.ts b/lib/xrp/lib/config/XRPConfig.ts new file mode 100644 index 00000000..abb6e9ff --- /dev/null +++ b/lib/xrp/lib/config/XRPConfig.ts @@ -0,0 +1,55 @@ +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as configTypes from "../../../constructs/config.interface"; +import * as constants from "../../../constructs/constants"; +import * as xrp from "./XRPConfig.interface" +import { BaseNodeConfig } from "../../../constructs/config.interface"; + + +const parseDataVolumeType = (dataVolumeType: string) => { + switch (dataVolumeType) { + case "gp3": + return ec2.EbsDeviceVolumeType.GP3; + case "io2": + return ec2.EbsDeviceVolumeType.IO2; + case "io1": + return ec2.EbsDeviceVolumeType.IO1; + case "instance-store": + return constants.InstanceStoreageDeviceVolumeType; + default: + return ec2.EbsDeviceVolumeType.GP3; + } +} + +export const baseConfig: configTypes.BaseConfig = { + accountId: process.env.AWS_ACCOUNT_ID || "xxxxxxxxxxx", + region: process.env.AWS_REGION || "us-east-2", +} + + + +export const baseNodeConfig: xrp.XRPBaseNodeConfig = { + instanceType: new ec2.InstanceType(process.env.XRP_INSTANCE_TYPE ? process.env.XRP_INSTANCE_TYPE : "r6a.8xlarge"), + instanceCpuType: process.env.XRP_CPU_TYPE?.toLowerCase() == "x86_64" ? ec2.AmazonLinuxCpuType.X86_64 : ec2.AmazonLinuxCpuType.ARM_64, + dataVolume: { + sizeGiB: process.env.DATA_VOL_SIZE ? parseInt(process.env.DATA_VOL_SIZE): 2000, + type: parseDataVolumeType(process.env.DATA_VOL_TYPE?.toLowerCase() ? process.env.DATA_VOL_TYPE?.toLowerCase() : "gp3"), + iops: process.env.DATA_VOL_IOPS ? parseInt(process.env.DATA_VOL_IOPS): 12000, + throughput: process.env.DATA_VOL_THROUGHPUT ? parseInt(process.env.DATA_VOL_THROUGHPUT): 700, + }, + // hubNetworkIP: process.env.HUB_NETWORK_IP || "s.altnet.rippletest.net 51235", //testnet + hubNetworkID: process.env.HUB_NETWORK_ID || "testnet", + // onlineDelete: process.env.ONLINE_DELETE || "512", + // advisoryDelete: process.env.ADVISORY_DELETE || "1", + // validatorListSites: process.env.VALIDATOR_LIST_SITES || "https://vl.altnet.rippletest.net", //testnet + // validatorListKeys: process.env.VALIDATOR_LIST_KEYS || "https://vl.altnet.rippletest.net" //testnet + +}; + + + +export const haNodeConfig: xrp.HAXRPBaseNodeConfig = { + ...baseNodeConfig, + albHealthCheckGracePeriodMin: process.env.XRP_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN ? parseInt(process.env.XRP_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN) : 10, + heartBeatDelayMin: process.env.XRP_HA_NODES_HEARTBEAT_DELAY_MIN ? parseInt(process.env.XRP_HA_NODES_HEARTBEAT_DELAY_MIN) : 40, + numberOfNodes: process.env.XRP_HA_NUMBER_OF_NODES ? parseInt(process.env.XRP_HA_NUMBER_OF_NODES) : 2, +}; diff --git a/lib/xrp/lib/config/createIniFile.ts b/lib/xrp/lib/config/createIniFile.ts new file mode 100644 index 00000000..3e8b8068 --- /dev/null +++ b/lib/xrp/lib/config/createIniFile.ts @@ -0,0 +1,49 @@ +import * as fs from 'fs'; +interface RippledConfig { + [section: string]: string[] | Record; +} +export function parseRippledConfig(filePath: string): RippledConfig { + const config: RippledConfig = {}; + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const lines = fileContent.split(/\r?\n/); + let currentSection: string | null = null; + lines.forEach((line) => { + line = line.trim(); + // Ignore empty lines and comments + if (!line || line.startsWith('#') || line.startsWith(';')) { + return; + } + // Section header + if (line.startsWith('[') && line.endsWith(']')) { + currentSection = line.slice(1, -1).trim(); + config[currentSection] = []; + } else if (currentSection) { + // Handle list-like sections (e.g., `[ips]`) + if (!line.includes('=')) { + (config[currentSection] as string[]).push(line); + } else { + // Handle key-value pairs + const [key, value] = line.split('=').map((part) => part.trim()); + if (typeof config[currentSection] === 'object') { + (config[currentSection] as Record)[key] = value || ''; + } + } + } + }); + return config; +} + + + + + + + + + + + + + + + diff --git a/lib/xrp/lib/constructs/node-cw-dashboard.ts b/lib/xrp/lib/constructs/node-cw-dashboard.ts new file mode 100644 index 00000000..99fdb674 --- /dev/null +++ b/lib/xrp/lib/constructs/node-cw-dashboard.ts @@ -0,0 +1,218 @@ +export const SingleNodeCWDashboardJSON = { + "widgets": [ + { + "height": 6, + "width": 8, + "y": 0, + "x": 0, + "type": "metric", + "properties": { + "view": "timeSeries", + "stat": "Average", + "period": 300, + "stacked": false, + "yAxis": { + "left": { + "min": 0 + } + }, + "region": "${REGION}", + "metrics": [ + [ "AWS/EC2", "CPUUtilization","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + ], + "title": "CPU utilization (%)" + } + }, + { + "height": 6, + "width": 8, + "y": 0, + "x": 8, + "type": "metric", + "properties": { + "metrics": [ + [ { "expression": "m7/PERIOD(m7)", "label": "Read", "id": "e7" } ], + [ "CWAgent", "diskio_reads","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7", "visible": false, "stat": "Sum", "period": 60 } ], + [ { "expression": "m8/PERIOD(m8)", "label": "Write", "id": "e8" } ], + [ "CWAgent", "diskio_writes","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m8", "visible": false, "stat": "Sum", "period": 60 } ] + ], + "view": "timeSeries", + "stacked": false, + "region": "${REGION}", + "stat": "Sum", + "period": 60, + "title": "nvme1n1 Volume Read/Write (IO/sec)" + } + }, + { + "height": 6, + "width": 8, + "y": 0, + "x": 16, + "type": "metric", + "properties": { + "sparkline": false, + "view": "singleValue", + "region": "${REGION}", + "stacked": false, + "singleValueFullPrecision": true, + "liveData": true, + "setPeriodToTimeRange": false, + "trend": true, + "metrics": [ + [ "CWAgent", "XRP_Sequence","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + ], + "title": "XRP Sequence" + } + }, + { + "height": 6, + "width": 8, + "y": 6, + "x": 0, + "type": "metric", + "properties": { + "view": "timeSeries", + "stat": "Average", + "period": 300, + "stacked": false, + "yAxis": { + "left": { + "min": 0 + } + }, + "region": "${REGION}", + "metrics": [ + [ "AWS/EC2", "NetworkIn","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + ], + "title": "Network in (bytes)" + } + }, + { + "height": 6, + "width": 8, + "y": 6, + "x": 8, + "type": "metric", + "properties": { + "view": "timeSeries", + "stacked": false, + "region": "${REGION}", + "stat": "Average", + "period": 300, + "metrics": [ + [ "CWAgent", "cpu_usage_iowait","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + ], + "title": "CPU Usage IO wait (%)" + } + }, + { + "height": 6, + "width": 8, + "y": 6, + "x": 16, + "type": "metric", + "properties": { + "view": "timeSeries", + "stat": "Sum", + "period": 60, + "stacked": false, + "yAxis": { + "left": { + "min": 0 + } + }, + "region": "${REGION}", + "metrics": [ + [ { "expression": "IF(m7_2 !=0, (m7_1 / m7_2), 0)", "label": "Read", "id": "e7" } ], + [ "CWAgent", "diskio_read_time","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_1", "visible": false, "stat": "Sum", "period": 60 } ], + [ "CWAgent", "diskio_reads","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_2", "visible": false, "stat": "Sum", "period": 60 } ], + [ { "expression": "IF(m7_4 !=0, (m7_3 / m7_4), 0)", "label": "Write", "id": "e8" } ], + [ "CWAgent", "diskio_write_time","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_3", "visible": false, "stat": "Sum", "period": 60 } ], + [ "CWAgent", "diskio_writes","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_4", "visible": false, "stat": "Sum", "period": 60 } ] + ], + "title": "nvme1n1 Volume Read/Write latency (ms/op)" + } + }, + { + "height": 6, + "width": 8, + "y": 12, + "x": 0, + "type": "metric", + "properties": { + "view": "timeSeries", + "stat": "Average", + "period": 300, + "stacked": false, + "yAxis": { + "left": { + "min": 0 + } + }, + "region": "${REGION}", + "metrics": [ + [ "AWS/EC2", "NetworkOut","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + ], + "title": "Network out (bytes)" + } + }, + { + "height": 6, + "width": 8, + "y": 12, + "x": 8, + "type": "metric", + "properties": { + "view": "timeSeries", + "stacked": false, + "region": "${REGION}", + "stat": "Average", + "period": 300, + "metrics": [ + [ "CWAgent", "mem_used_percent","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + ], + "title": "Mem Used (%)" + } + }, + { + "height": 6, + "width": 8, + "y": 12, + "x": 16, + "type": "metric", + "properties": { + "metrics": [ + [ { "expression": "m2/PERIOD(m2)", "label": "Read", "id": "e2", "period": 60, "region": "us-east-1" } ], + [ "CWAgent", "diskio_read_bytes","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m2", "stat": "Sum", "visible": false, "period": 60 } ], + [ { "expression": "m3/PERIOD(m3)", "label": "Write", "id": "e3", "period": 60, "region": "us-east-1" } ], + [ "CWAgent", "diskio_write_bytes","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m3", "stat": "Sum", "visible": false, "period": 60 } ] + ], + "view": "timeSeries", + "stacked": false, + "region": "${REGION}", + "stat": "Average", + "period": 60, + "title": "nvme1n1 Volume Read/Write throughput (bytes/sec)" + } + }, + { + "height": 6, + "width": 8, + "y": 18, + "x": 0, + "type": "metric", + "properties": { + "metrics": [ + [ "CWAgent", "disk_used_percent","InstanceId", "${INSTANCE_ID}", "device", "nvme1n1", "path", "/var/lib/rippled", "fstype", "xfs", { "region": "${REGION}", "label": "/var/lib/rippled" } ] + ], + "sparkline": true, + "view": "singleValue", + "region": "${REGION}", + "title": "nvme1n1 Disk Used (%)", + "period": 60, + "stat": "Average" + } + } + ] +} \ No newline at end of file diff --git a/lib/xrp/lib/constructs/xrp-node-security-group.ts b/lib/xrp/lib/constructs/xrp-node-security-group.ts new file mode 100644 index 00000000..ac6f8374 --- /dev/null +++ b/lib/xrp/lib/constructs/xrp-node-security-group.ts @@ -0,0 +1,51 @@ +import * as cdk from "aws-cdk-lib"; +import * as cdkContructs from "constructs"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as nag from "cdk-nag"; + +export interface XRPNodeSecurityGroupConstructProps { + vpc: cdk.aws_ec2.IVpc; +} + +export class XRPNodeSecurityGroupConstruct extends cdkContructs.Construct { + public securityGroup: cdk.aws_ec2.ISecurityGroup; + + constructor(scope: cdkContructs.Construct, id: string, props: XRPNodeSecurityGroupConstructProps) { + super(scope, id); + + const { + vpc + } = props; + + const sg = new ec2.SecurityGroup(this, `rpc-node-security-group`, { + vpc, + description: "Security Group for Blockchain nodes", + allowAllOutbound: true + }); + + // Public ports + sg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcpRange(51235, 51235), "P2P protocols"); + sg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcpRange(2459, 2459), "P2P protocols"); + + + // Private ports restricted only to the VPC IP range + sg.addIngressRule(ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Port.tcp(6005), "RPC port HTTP (user access needs to be restricted. Allowed access only from internal IPs)"); + + this.securityGroup = sg; + + /** + * cdk-nag suppressions + */ + + nag.NagSuppressions.addResourceSuppressions( + this, + [ + { + id: "AwsSolutions-EC23", + reason: "Need to use wildcard for P2P ports" + } + ], + true + ); + } +} \ No newline at end of file diff --git a/lib/xrp/lib/ha-nodes-stack.ts b/lib/xrp/lib/ha-nodes-stack.ts new file mode 100644 index 00000000..d47de0e3 --- /dev/null +++ b/lib/xrp/lib/ha-nodes-stack.ts @@ -0,0 +1,152 @@ +import * as cdk from "aws-cdk-lib"; +import * as cdkConstructs from "constructs"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as s3Assets from "aws-cdk-lib/aws-s3-assets"; +import * as nag from "cdk-nag"; +import * as path from "path"; +import * as fs from "fs"; +import { HANodesConstruct } from "../../constructs/ha-rpc-nodes-with-alb"; +import * as constants from "../../constructs/constants"; +import { XRPSingleNodeStackProps } from "./single-node-stack"; +import { XRPNodeSecurityGroupConstruct } from "./constructs/xrp-node-security-group"; +import { SingleNodeCWDashboardJSON } from "./constructs/node-cw-dashboard"; +import * as cw from 'aws-cdk-lib/aws-cloudwatch'; + +export interface XRPHANodesStackProps extends XRPSingleNodeStackProps { + albHealthCheckGracePeriodMin: number; + heartBeatDelayMin: number; + numberOfNodes: number; +} + +export class XRPHANodesStack extends cdk.Stack { + constructor(scope: cdkConstructs.Construct, id: string, props: XRPHANodesStackProps) { + super(scope, id, props); + + // Setting up necessary environment variables + const REGION = cdk.Stack.of(this).region; + const STACK_NAME = cdk.Stack.of(this).stackName; + const STACK_ID = cdk.Stack.of(this).stackId; + const lifecycleHookName = STACK_NAME; + const autoScalingGroupName = STACK_NAME; + + // Getting our config from initialization properties + const { + instanceType, + instanceCpuType, + dataVolume: dataVolume, + stackName, + hubNetworkID, + // hubNetworkIP, + // validatorListSites, + // validatorListKeys, + // onlineDelete, + // advisoryDelete, + albHealthCheckGracePeriodMin, + heartBeatDelayMin, + numberOfNodes, + } = props; + + // Using default VPC + const vpc = ec2.Vpc.fromLookup(this, "vpc", { isDefault: true }); + + // Setting up the security group for the node from Solana-specific construct + const instanceSG = new XRPNodeSecurityGroupConstruct(this, "security-group", { + vpc: vpc, + }); + + // Making our scripts and configis from the local "assets" directory available for instance to download + const asset = new s3Assets.Asset(this, "assets", { + path: path.join(__dirname, "assets"), + }); + + // Getting the IAM role ARN from the common stack + const importedInstanceRoleArn = cdk.Fn.importValue("SolanaNodeInstanceRoleArn"); + + const instanceRole = props.instanceRole; //iam.Role.fromRoleArn(this, "iam-role", importedInstanceRoleArn); + + // Making sure our instance will be able to read the assets + asset.bucket.grantRead(instanceRole); + + // Setting up the node using generic Single Node constract + if (instanceCpuType === ec2.AmazonLinuxCpuType.ARM_64) { + throw new Error("ARM_64 is not yet supported"); + } + + // Parsing user data script and injecting necessary variables + const nodeStartScript = fs.readFileSync(path.join(__dirname, "assets", "user-data", "node.sh")).toString(); + const dataVolumeSizeBytes = dataVolume.sizeGiB * constants.GibibytesToBytesConversionCoefficient; + + const modifiedInitNodeScript = cdk.Token.asString( + cdk.Lazy.string({ + produce: () => { + return nodeStartScript + .replace("_AWS_REGION_", REGION) + .replace("_ASSETS_S3_PATH_", `s3://${asset.s3BucketName}/${asset.s3ObjectKey}`) + .replace("_STACK_NAME_", STACK_NAME) + .replace("_STACK_ID_", STACK_ID) + .replace("_NODE_CF_LOGICAL_ID_", constants.NoneValue) + .replace("_DATA_VOLUME_TYPE_", dataVolume.type) + .replace("_DATA_VOLUME_SIZE_", dataVolumeSizeBytes.toString()) + .replace("_HUB_NETWORK_ID_", hubNetworkID) + .replace("_LIFECYCLE_HOOK_NAME_", lifecycleHookName) + .replace("_ASG_NAME_", autoScalingGroupName); + }, + }) + ); + + const healthCheckPath = "/"; + const nodeASG = new HANodesConstruct(this, "stock-server-node", { + instanceType, + dataVolumes: [dataVolume], + rootDataVolumeDeviceName: "/dev/xvda", + machineImage: new ec2.AmazonLinuxImage({ + generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2, + cpuType: ec2.AmazonLinuxCpuType.X86_64, + }), + vpc, + role: instanceRole, + securityGroup: instanceSG.securityGroup, + userData: modifiedInitNodeScript, + numberOfNodes, + albHealthCheckGracePeriodMin, + healthCheckPath, + heartBeatDelayMin, + lifecycleHookName: lifecycleHookName, + autoScalingGroupName: autoScalingGroupName, + rpcPortForALB: 6005, + }); + + + + + + // Making sure we output the URL of our Applicaiton Load Balancer + new cdk.CfnOutput(this, "alb-url", { + value: nodeASG.loadBalancerDnsName, + }); + + // Adding suppressions to the stack + nag.NagSuppressions.addResourceSuppressions( + this, + [ + { + id: "AwsSolutions-AS3", + reason: "No notifications needed", + }, + { + id: "AwsSolutions-S1", + reason: "No access log needed for ALB logs bucket", + }, + { + id: "AwsSolutions-EC28", + reason: "Using basic monitoring to save costs", + }, + { + id: "AwsSolutions-IAM5", + reason: "Need read access to the S3 bucket with assets", + }, + ], + true + ); + } +} diff --git a/lib/xrp/lib/single-node-stack.ts b/lib/xrp/lib/single-node-stack.ts new file mode 100644 index 00000000..c4f79020 --- /dev/null +++ b/lib/xrp/lib/single-node-stack.ts @@ -0,0 +1,147 @@ +import * as cdk from "aws-cdk-lib"; +import * as cdkConstructs from "constructs"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as s3Assets from "aws-cdk-lib/aws-s3-assets"; +import * as path from "path"; +import * as fs from "fs"; +import * as cw from "aws-cdk-lib/aws-cloudwatch"; +import * as nag from "cdk-nag"; +import { SingleNodeConstruct } from "../../constructs/single-node"; +import { XRPNodeSecurityGroupConstruct } from "./constructs/xrp-node-security-group"; +import { SingleNodeCWDashboardJSON } from "./constructs/node-cw-dashboard"; +import { DataVolumeConfig } from "../../constructs/config.interface"; +import * as constants from "../../constructs/constants"; +import { parseRippledConfig } from "./config/createIniFile"; + + +export interface XRPSingleNodeStackProps extends cdk.StackProps { + instanceType: ec2.InstanceType; + instanceCpuType: ec2.AmazonLinuxCpuType; + dataVolume: DataVolumeConfig; + stackName: string; + hubNetworkID: string; + instanceRole: iam.Role; + +} + +export class XRPSingleNodeStack extends cdk.Stack { + constructor(scope: cdkConstructs.Construct, id: string, props: XRPSingleNodeStackProps) { + super(scope, id, props); + + // Setting up necessary environment variables + const REGION = cdk.Stack.of(this).region; + const STACK_NAME = cdk.Stack.of(this).stackName; + const STACK_ID = cdk.Stack.of(this).stackId; + const availabilityZones = cdk.Stack.of(this).availabilityZones; + const chosenAvailabilityZone = availabilityZones.slice(0, 1)[0]; + + // Getting our config from initialization properties + const { + instanceType, + instanceCpuType, + dataVolume: dataVolume, + stackName, + hubNetworkID + } = props; + + // Using default VPC + const vpc = ec2.Vpc.fromLookup(this, "vpc", { isDefault: true }); + + // Setting up the security group for the node from XRP-specific construct + const instanceSG = new XRPNodeSecurityGroupConstruct(this, "security-group", { + vpc: vpc + }); + + // Making our scripts and configis from the local "assets" directory available for instance to download + const asset = new s3Assets.Asset(this, "assets", { + path: path.join(__dirname, "assets") + }); + + // Getting the IAM role ARN from the common stack + const importedInstanceRoleArn = cdk.Fn.importValue("XRPNodeInstanceRoleArn"); + + const instanceRole = props.instanceRole; //iam.Role.fromRoleArn(this, "iam-role", importedInstanceRoleArn); + + // Making sure our instance will be able to read the assets + asset.bucket.grantRead(instanceRole); + + // Setting up the node using generic Single Node constract + if (instanceCpuType === ec2.AmazonLinuxCpuType.ARM_64) { + throw new Error("ARM_64 is not yet supported"); + } + + + const node = new SingleNodeConstruct(this, "stock-server-node", { + instanceName: STACK_NAME, + instanceType, + dataVolumes: [dataVolume], + rootDataVolumeDeviceName: "/dev/xvda", + machineImage: new ec2.AmazonLinuxImage({ + generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2, + cpuType: ec2.AmazonLinuxCpuType.X86_64 + }), + vpc, + availabilityZone: chosenAvailabilityZone, + role: instanceRole, + securityGroup: instanceSG.securityGroup, + vpcSubnets: { + subnetType: ec2.SubnetType.PUBLIC + } + }); + + + // Parsing user data script and injecting necessary variables + const nodeStartScript = fs.readFileSync(path.join(__dirname, "assets", "user-data", "node.sh")).toString(); + const dataVolumeSizeBytes = dataVolume.sizeGiB * constants.GibibytesToBytesConversionCoefficient; + + const modifiedInitNodeScript = cdk.Token.asString( + cdk.Lazy.string({ + produce: () => { + return nodeStartScript + .replace("_AWS_REGION_", REGION) + .replace("_ASSETS_S3_PATH_", `s3://${asset.s3BucketName}/${asset.s3ObjectKey}`) + .replace("_STACK_NAME_", STACK_NAME) + .replace("_STACK_ID_", STACK_ID) + .replace("_NODE_CF_LOGICAL_ID_", node.nodeCFLogicalId) + .replace("_DATA_VOLUME_TYPE_", dataVolume.type) + .replace("_DATA_VOLUME_SIZE_", dataVolumeSizeBytes.toString()) + .replace("_HUB_NETWORK_ID_", hubNetworkID) + .replace("_LIFECYCLE_HOOK_NAME_", constants.NoneValue); + } + }) + ); + + const userData = ec2.UserData.forLinux(); + userData.addCommands(modifiedInitNodeScript); + node.instance.addUserData(userData.render()); + + // Adding CloudWatch dashboard to the node + const dashboardString = cdk.Fn.sub(JSON.stringify(SingleNodeCWDashboardJSON), { + INSTANCE_ID: node.instanceId, + INSTANCE_NAME: STACK_NAME, + REGION: REGION + }); + + new cw.CfnDashboard(this, "xrp-cw-dashboard", { + dashboardName: `${STACK_NAME}-${node.instanceId}`, + dashboardBody: dashboardString + }); + + new cdk.CfnOutput(this, "node-instance-id", { + value: node.instanceId + }); + + // Adding suppressions to the stack + nag.NagSuppressions.addResourceSuppressions( + this, + [ + { + id: "AwsSolutions-IAM5", + reason: "Need read access to the S3 bucket with assets" + } + ], + true + ); + } +} \ No newline at end of file From 526c1babd95dabc58f14395ac5cb3dffa6794b86 Mon Sep 17 00:00:00 2001 From: Pedro Aceves Date: Wed, 5 Feb 2025 21:09:07 -0700 Subject: [PATCH 02/27] missed commits and minor cleanup --- .gitignore | 1 + lib/xrp/app.ts | 49 ++++++++++++++++++++++ lib/xrp/lib/config/XRPConfig.ts | 11 +---- lib/xrp/lib/ha-nodes-stack.ts | 39 ++++++----------- lib/xrp/lib/single-node-stack.ts | 4 -- lib/xrp/package.json | 17 ++++++++ lib/xrp/sample-configs/.env-sample-testnet | 13 ++++++ lib/xrp/tsconfig.json | 31 ++++++++++++++ 8 files changed, 126 insertions(+), 39 deletions(-) create mode 100644 lib/xrp/app.ts create mode 100644 lib/xrp/package.json create mode 100644 lib/xrp/sample-configs/.env-sample-testnet create mode 100644 lib/xrp/tsconfig.json diff --git a/.gitignore b/.gitignore index 46ce6cd2..738a7d47 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ ha-nodes-deploy*.json .env .idea .vscode +.venv diff --git a/lib/xrp/app.ts b/lib/xrp/app.ts new file mode 100644 index 00000000..e74d2a80 --- /dev/null +++ b/lib/xrp/app.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import "dotenv/config"; +import * as cdk from "aws-cdk-lib"; +import * as nag from "cdk-nag"; +import * as config from "./lib/config/XRPConfig"; + +import { XRPSingleNodeStack } from "./lib/single-node-stack"; +import { XRPCommonStack } from "./lib/common-stack"; +import { XRPHANodesStack } from "./lib/ha-nodes-stack"; + +const app = new cdk.App(); +cdk.Tags.of(app).add("Project", "AWSXRP"); + +const commonStack = new XRPCommonStack(app, "XRP-common", { + stackName: `XRP-nodes-common`, + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, +}); + +new XRPSingleNodeStack(app, "XRP-single-node", { + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + stackName: `XRP-single-node`, + instanceType: config.baseNodeConfig.instanceType, + instanceCpuType: config.baseNodeConfig.instanceCpuType, + dataVolume: config.baseNodeConfig.dataVolume, + hubNetworkID: config.baseNodeConfig.hubNetworkID, + instanceRole: commonStack.instanceRole, +}); + + new XRPHANodesStack(app, "XRP-ha-nodes", { + stackName: "xrp-ha-nodes", + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + instanceType: config.baseNodeConfig.instanceType, + instanceCpuType: config.baseNodeConfig.instanceCpuType, + dataVolume: config.baseNodeConfig.dataVolume, + hubNetworkID: config.baseNodeConfig.hubNetworkID, + instanceRole: commonStack.instanceRole, + albHealthCheckGracePeriodMin: config.haNodeConfig.albHealthCheckGracePeriodMin, + heartBeatDelayMin: config.haNodeConfig.heartBeatDelayMin, + numberOfNodes: config.haNodeConfig.numberOfNodes, + }); + +// Security Check +cdk.Aspects.of(app).add( + new nag.AwsSolutionsChecks({ + verbose: false, + reports: true, + logIgnores: false, + }) +); diff --git a/lib/xrp/lib/config/XRPConfig.ts b/lib/xrp/lib/config/XRPConfig.ts index abb6e9ff..93b0ea68 100644 --- a/lib/xrp/lib/config/XRPConfig.ts +++ b/lib/xrp/lib/config/XRPConfig.ts @@ -1,8 +1,7 @@ import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as configTypes from "../../../constructs/config.interface"; import * as constants from "../../../constructs/constants"; -import * as xrp from "./XRPConfig.interface" -import { BaseNodeConfig } from "../../../constructs/config.interface"; +import * as xrp from "./XRPConfig.interface"; const parseDataVolumeType = (dataVolumeType: string) => { @@ -36,13 +35,7 @@ export const baseNodeConfig: xrp.XRPBaseNodeConfig = { iops: process.env.DATA_VOL_IOPS ? parseInt(process.env.DATA_VOL_IOPS): 12000, throughput: process.env.DATA_VOL_THROUGHPUT ? parseInt(process.env.DATA_VOL_THROUGHPUT): 700, }, - // hubNetworkIP: process.env.HUB_NETWORK_IP || "s.altnet.rippletest.net 51235", //testnet - hubNetworkID: process.env.HUB_NETWORK_ID || "testnet", - // onlineDelete: process.env.ONLINE_DELETE || "512", - // advisoryDelete: process.env.ADVISORY_DELETE || "1", - // validatorListSites: process.env.VALIDATOR_LIST_SITES || "https://vl.altnet.rippletest.net", //testnet - // validatorListKeys: process.env.VALIDATOR_LIST_KEYS || "https://vl.altnet.rippletest.net" //testnet - + hubNetworkID: process.env.HUB_NETWORK_ID || "testnet" }; diff --git a/lib/xrp/lib/ha-nodes-stack.ts b/lib/xrp/lib/ha-nodes-stack.ts index d47de0e3..bef01b7e 100644 --- a/lib/xrp/lib/ha-nodes-stack.ts +++ b/lib/xrp/lib/ha-nodes-stack.ts @@ -9,8 +9,6 @@ import { HANodesConstruct } from "../../constructs/ha-rpc-nodes-with-alb"; import * as constants from "../../constructs/constants"; import { XRPSingleNodeStackProps } from "./single-node-stack"; import { XRPNodeSecurityGroupConstruct } from "./constructs/xrp-node-security-group"; -import { SingleNodeCWDashboardJSON } from "./constructs/node-cw-dashboard"; -import * as cw from 'aws-cdk-lib/aws-cloudwatch'; export interface XRPHANodesStackProps extends XRPSingleNodeStackProps { albHealthCheckGracePeriodMin: number; @@ -36,14 +34,9 @@ export class XRPHANodesStack extends cdk.Stack { dataVolume: dataVolume, stackName, hubNetworkID, - // hubNetworkIP, - // validatorListSites, - // validatorListKeys, - // onlineDelete, - // advisoryDelete, albHealthCheckGracePeriodMin, heartBeatDelayMin, - numberOfNodes, + numberOfNodes } = props; // Using default VPC @@ -51,18 +44,15 @@ export class XRPHANodesStack extends cdk.Stack { // Setting up the security group for the node from Solana-specific construct const instanceSG = new XRPNodeSecurityGroupConstruct(this, "security-group", { - vpc: vpc, + vpc: vpc }); // Making our scripts and configis from the local "assets" directory available for instance to download const asset = new s3Assets.Asset(this, "assets", { - path: path.join(__dirname, "assets"), + path: path.join(__dirname, "assets") }); - // Getting the IAM role ARN from the common stack - const importedInstanceRoleArn = cdk.Fn.importValue("SolanaNodeInstanceRoleArn"); - - const instanceRole = props.instanceRole; //iam.Role.fromRoleArn(this, "iam-role", importedInstanceRoleArn); + const instanceRole = props.instanceRole; // Making sure our instance will be able to read the assets asset.bucket.grantRead(instanceRole); @@ -90,7 +80,7 @@ export class XRPHANodesStack extends cdk.Stack { .replace("_HUB_NETWORK_ID_", hubNetworkID) .replace("_LIFECYCLE_HOOK_NAME_", lifecycleHookName) .replace("_ASG_NAME_", autoScalingGroupName); - }, + } }) ); @@ -101,7 +91,7 @@ export class XRPHANodesStack extends cdk.Stack { rootDataVolumeDeviceName: "/dev/xvda", machineImage: new ec2.AmazonLinuxImage({ generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2, - cpuType: ec2.AmazonLinuxCpuType.X86_64, + cpuType: ec2.AmazonLinuxCpuType.X86_64 }), vpc, role: instanceRole, @@ -113,16 +103,13 @@ export class XRPHANodesStack extends cdk.Stack { heartBeatDelayMin, lifecycleHookName: lifecycleHookName, autoScalingGroupName: autoScalingGroupName, - rpcPortForALB: 6005, + rpcPortForALB: 6005 }); - - - // Making sure we output the URL of our Applicaiton Load Balancer new cdk.CfnOutput(this, "alb-url", { - value: nodeASG.loadBalancerDnsName, + value: nodeASG.loadBalancerDnsName }); // Adding suppressions to the stack @@ -131,20 +118,20 @@ export class XRPHANodesStack extends cdk.Stack { [ { id: "AwsSolutions-AS3", - reason: "No notifications needed", + reason: "No notifications needed" }, { id: "AwsSolutions-S1", - reason: "No access log needed for ALB logs bucket", + reason: "No access log needed for ALB logs bucket" }, { id: "AwsSolutions-EC28", - reason: "Using basic monitoring to save costs", + reason: "Using basic monitoring to save costs" }, { id: "AwsSolutions-IAM5", - reason: "Need read access to the S3 bucket with assets", - }, + reason: "Need read access to the S3 bucket with assets" + } ], true ); diff --git a/lib/xrp/lib/single-node-stack.ts b/lib/xrp/lib/single-node-stack.ts index c4f79020..ef9b8824 100644 --- a/lib/xrp/lib/single-node-stack.ts +++ b/lib/xrp/lib/single-node-stack.ts @@ -12,7 +12,6 @@ import { XRPNodeSecurityGroupConstruct } from "./constructs/xrp-node-security-gr import { SingleNodeCWDashboardJSON } from "./constructs/node-cw-dashboard"; import { DataVolumeConfig } from "../../constructs/config.interface"; import * as constants from "../../constructs/constants"; -import { parseRippledConfig } from "./config/createIniFile"; export interface XRPSingleNodeStackProps extends cdk.StackProps { @@ -58,9 +57,6 @@ export class XRPSingleNodeStack extends cdk.Stack { path: path.join(__dirname, "assets") }); - // Getting the IAM role ARN from the common stack - const importedInstanceRoleArn = cdk.Fn.importValue("XRPNodeInstanceRoleArn"); - const instanceRole = props.instanceRole; //iam.Role.fromRoleArn(this, "iam-role", importedInstanceRoleArn); // Making sure our instance will be able to read the assets diff --git a/lib/xrp/package.json b/lib/xrp/package.json new file mode 100644 index 00000000..dba6bc71 --- /dev/null +++ b/lib/xrp/package.json @@ -0,0 +1,17 @@ +{ + "name": "aws-blockchain-node-runners-xrp", + "version": "0.2.0", + "scripts": { + "build": "npx tsc", + "watch": "npx tsc -w", + "test": "npx jest --detectOpenHandles", + "cdk": "npx cdk", + "scan-cdk": "npx cdk synth" + }, + "dependencies": { + "ini": "^4.1.1" + }, + "devDependencies": { + "@types/ini": "^4.1.1" + } +} diff --git a/lib/xrp/sample-configs/.env-sample-testnet b/lib/xrp/sample-configs/.env-sample-testnet new file mode 100644 index 00000000..a8d82ee2 --- /dev/null +++ b/lib/xrp/sample-configs/.env-sample-testnet @@ -0,0 +1,13 @@ +AWS_ACCOUNT_ID="xxxxxxxxxxx" +AWS_REGION="xxxxxxxxxx" +XRP_INSTANCE_TYPE="r7a.12xlarge" +XRP_CPU_TYPE="x86_64" # All options: "x86_64". ARM currently not supported +DATA_VOL_TYPE="gp3" # Other options: "io1" | "io2" | "gp3" | "instance-store" . IMPORTANT: Use "instance-store" option only with instance types that support that feature, like popular for node im4gn, d3, i3en, and i4i instance families +DATA_VOL_SIZE="2000" # Current required data size to keep both smapshot archive and unarchived version of it +DATA_VOL_IOPS="12000" # Max IOPS for EBS volumes (not applicable for "instance-store") +DATA_VOL_THROUGHPUT="700" +XRP_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN="60" +XRP_HA_NODES_HEARTBEAT_DELAY_MIN="5" +XRP_HA_NUMBER_OF_NODES="2" +HUB_NETWORK_ID="testnet" + diff --git a/lib/xrp/tsconfig.json b/lib/xrp/tsconfig.json new file mode 100644 index 00000000..8e1979f3 --- /dev/null +++ b/lib/xrp/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": [ + "es2020", + "dom" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": [ + "../../node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +} From 4940106f248b6202ad8cfb94abd170f27df6c65d Mon Sep 17 00:00:00 2001 From: Pedro Aceves Date: Thu, 6 Feb 2025 09:17:03 -0700 Subject: [PATCH 03/27] add tests --- lib/xrp/jest.config.ts | 11 +++ lib/xrp/test/.env-test | 13 +++ lib/xrp/test/common-stack.test.ts | 76 ++++++++++++++++ lib/xrp/test/single-node-stack.test.ts | 118 +++++++++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 lib/xrp/jest.config.ts create mode 100644 lib/xrp/test/.env-test create mode 100644 lib/xrp/test/common-stack.test.ts create mode 100644 lib/xrp/test/single-node-stack.test.ts diff --git a/lib/xrp/jest.config.ts b/lib/xrp/jest.config.ts new file mode 100644 index 00000000..7aa1be29 --- /dev/null +++ b/lib/xrp/jest.config.ts @@ -0,0 +1,11 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + }, + setupFiles: [ + 'dotenv/config' + ] +}; \ No newline at end of file diff --git a/lib/xrp/test/.env-test b/lib/xrp/test/.env-test new file mode 100644 index 00000000..a8d82ee2 --- /dev/null +++ b/lib/xrp/test/.env-test @@ -0,0 +1,13 @@ +AWS_ACCOUNT_ID="xxxxxxxxxxx" +AWS_REGION="xxxxxxxxxx" +XRP_INSTANCE_TYPE="r7a.12xlarge" +XRP_CPU_TYPE="x86_64" # All options: "x86_64". ARM currently not supported +DATA_VOL_TYPE="gp3" # Other options: "io1" | "io2" | "gp3" | "instance-store" . IMPORTANT: Use "instance-store" option only with instance types that support that feature, like popular for node im4gn, d3, i3en, and i4i instance families +DATA_VOL_SIZE="2000" # Current required data size to keep both smapshot archive and unarchived version of it +DATA_VOL_IOPS="12000" # Max IOPS for EBS volumes (not applicable for "instance-store") +DATA_VOL_THROUGHPUT="700" +XRP_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN="60" +XRP_HA_NODES_HEARTBEAT_DELAY_MIN="5" +XRP_HA_NUMBER_OF_NODES="2" +HUB_NETWORK_ID="testnet" + diff --git a/lib/xrp/test/common-stack.test.ts b/lib/xrp/test/common-stack.test.ts new file mode 100644 index 00000000..f0ba40f6 --- /dev/null +++ b/lib/xrp/test/common-stack.test.ts @@ -0,0 +1,76 @@ +import { Template } from "aws-cdk-lib/assertions"; +import * as cdk from "aws-cdk-lib"; +import * as dotenv from "dotenv"; +import * as config from "../lib/config/XRPConfig"; +import { XRPCommonStack } from "../lib/common-stack"; + +dotenv.config({ path: './test/.env-test' }); + +describe("SolanaCommonStack", () => { + test("synthesizes the way we expect", () => { + const app = new cdk.App(); + + // Create the SolanaCommonStack. + const xrpCommonStack = new XRPCommonStack(app, "xrp-common", { + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + stackName: `xrp-nodes-common`, + }); + + // Prepare the stack for assertions. + const template = Template.fromStack(xrpCommonStack); + + // Has EC2 instance role. + template.hasResourceProperties("AWS::IAM::Role", { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: "sts:AssumeRole", + Effect: "Allow", + Principal: { + Service: "ec2.amazonaws.com" + } + } + ] + }, + ManagedPolicyArns: [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/SecretsManagerReadWrite" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition" + }, + ":iam::aws:policy/AmazonSSMManagedInstanceCore" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/CloudWatchAgentServerPolicy" + ] + ] + } + ] + }) + + }); +}); \ No newline at end of file diff --git a/lib/xrp/test/single-node-stack.test.ts b/lib/xrp/test/single-node-stack.test.ts new file mode 100644 index 00000000..6f875a83 --- /dev/null +++ b/lib/xrp/test/single-node-stack.test.ts @@ -0,0 +1,118 @@ +import { Match, Template } from "aws-cdk-lib/assertions"; +import * as cdk from "aws-cdk-lib"; +import * as dotenv from "dotenv"; +import * as config from "../lib/config/XRPConfig"; +import { XRPCommonStack } from "../lib/common-stack"; +import { XRPSingleNodeStack } from "../lib/single-node-stack"; + +dotenv.config({ path: './test/.env-test' }); + +describe("SolanaSingleNodeStack", () => { + test("synthesizes the way we expect", () => { + const app = new cdk.App(); + const xrpCommonStack = new XRPCommonStack(app, "xrp-common", { + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + stackName: `xrp-nodes-common`, + }); + + // Create the XRPSingleNodeStack. + const xrpSingleNodeStack = new XRPSingleNodeStack(app, "XRP-sync-node", { + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + stackName: `XRP-single-node`, + instanceType: config.baseNodeConfig.instanceType, + instanceCpuType: config.baseNodeConfig.instanceCpuType, + dataVolume: config.baseNodeConfig.dataVolume, + hubNetworkID: config.baseNodeConfig.hubNetworkID, + instanceRole: xrpCommonStack.instanceRole, + }); + + // Prepare the stack for assertions. + const template = Template.fromStack(xrpSingleNodeStack); + + // Has EC2 instance security group. + template.hasResourceProperties("AWS::EC2::SecurityGroup", { + GroupDescription: Match.anyValue(), + VpcId: Match.anyValue(), + SecurityGroupEgress: [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + SecurityGroupIngress: [ + { + "CidrIp": "0.0.0.0/0", + "Description": "P2P protocols", + "FromPort": 51235, + "IpProtocol": "tcp", + "ToPort": 51235 + }, + { + "CidrIp": "0.0.0.0/0", + "Description": "P2P protocols", + "FromPort": 2459, + "IpProtocol": "tcp", + "ToPort": 2459 + }, + { + "CidrIp": "1.2.3.4/5", + "Description": "RPC port HTTP (user access needs to be restricted. Allowed access only from internal IPs)", + "FromPort": 6005, + "IpProtocol": "tcp", + "ToPort": 6005 + } + ] + }) + + // Has EC2 instance with node configuration + template.hasResourceProperties("AWS::EC2::Instance", { + AvailabilityZone: Match.anyValue(), + UserData: Match.anyValue(), + BlockDeviceMappings: [ + { + DeviceName: "/dev/xvda", + Ebs: { + DeleteOnTermination: true, + Encrypted: true, + Iops: 3000, + VolumeSize: 46, + VolumeType: "gp3" + } + } + ], + IamInstanceProfile: Match.anyValue(), + ImageId: Match.anyValue(), + InstanceType: "r7a.12xlarge", + Monitoring: true, + PropagateTagsToVolumeOnCreation: true, + SecurityGroupIds: Match.anyValue(), + SubnetId: Match.anyValue(), + }) + + // Has EBS data volume. + template.hasResourceProperties("AWS::EC2::Volume", { + AvailabilityZone: Match.anyValue(), + Encrypted: true, + Iops: 12000, + MultiAttachEnabled: false, + Size: 2000, + Throughput: 700, + VolumeType: "gp3" + }) + + // Has EBS data volume attachment. + template.hasResourceProperties("AWS::EC2::VolumeAttachment", { + Device: "/dev/sdf", + InstanceId: Match.anyValue(), + VolumeId: Match.anyValue(), + }) + + // Has CloudWatch dashboard. + template.hasResourceProperties("AWS::CloudWatch::Dashboard", { + DashboardBody: Match.anyValue(), + DashboardName: {"Fn::Join": ["", ["XRP-single-node-",{ "Ref": Match.anyValue() }]]} + }) + + }); +}); \ No newline at end of file From a1a3f4827316babb298f2374ab2dddd986d2386e Mon Sep 17 00:00:00 2001 From: Pedro Aceves Date: Thu, 6 Feb 2025 09:39:00 -0700 Subject: [PATCH 04/27] clean up --- lib/xrp/jest.config.ts | 12 ++++++------ lib/xrp/lib/ha-nodes-stack.ts | 2 +- lib/xrp/test/common-stack.test.ts | 10 +++++----- lib/xrp/test/single-node-stack.test.ts | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/xrp/jest.config.ts b/lib/xrp/jest.config.ts index 7aa1be29..9b9c4172 100644 --- a/lib/xrp/jest.config.ts +++ b/lib/xrp/jest.config.ts @@ -1,11 +1,11 @@ module.exports = { - testEnvironment: 'node', - roots: ['/test'], - testMatch: ['**/*.test.ts'], + testEnvironment: "node", + roots: ["/test"], + testMatch: ["**/*.test.ts"], transform: { - '^.+\\.tsx?$': 'ts-jest' + "^.+\\.tsx?$": "ts-jest" }, setupFiles: [ - 'dotenv/config' + "dotenv/config" ] -}; \ No newline at end of file +}; diff --git a/lib/xrp/lib/ha-nodes-stack.ts b/lib/xrp/lib/ha-nodes-stack.ts index bef01b7e..ca9a0078 100644 --- a/lib/xrp/lib/ha-nodes-stack.ts +++ b/lib/xrp/lib/ha-nodes-stack.ts @@ -42,7 +42,7 @@ export class XRPHANodesStack extends cdk.Stack { // Using default VPC const vpc = ec2.Vpc.fromLookup(this, "vpc", { isDefault: true }); - // Setting up the security group for the node from Solana-specific construct + // Setting up the security group for the node from xrp-specific construct const instanceSG = new XRPNodeSecurityGroupConstruct(this, "security-group", { vpc: vpc }); diff --git a/lib/xrp/test/common-stack.test.ts b/lib/xrp/test/common-stack.test.ts index f0ba40f6..82cbba17 100644 --- a/lib/xrp/test/common-stack.test.ts +++ b/lib/xrp/test/common-stack.test.ts @@ -4,16 +4,16 @@ import * as dotenv from "dotenv"; import * as config from "../lib/config/XRPConfig"; import { XRPCommonStack } from "../lib/common-stack"; -dotenv.config({ path: './test/.env-test' }); +dotenv.config({ path: "./test/.env-test" }); -describe("SolanaCommonStack", () => { +describe("XRPCommonStack", () => { test("synthesizes the way we expect", () => { const app = new cdk.App(); - // Create the SolanaCommonStack. + // Create the XRPCommonStack. const xrpCommonStack = new XRPCommonStack(app, "xrp-common", { env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, - stackName: `xrp-nodes-common`, + stackName: `xrp-nodes-common` }); // Prepare the stack for assertions. @@ -70,7 +70,7 @@ describe("SolanaCommonStack", () => { ] } ] - }) + }); }); }); \ No newline at end of file diff --git a/lib/xrp/test/single-node-stack.test.ts b/lib/xrp/test/single-node-stack.test.ts index 6f875a83..5b921f1d 100644 --- a/lib/xrp/test/single-node-stack.test.ts +++ b/lib/xrp/test/single-node-stack.test.ts @@ -7,7 +7,7 @@ import { XRPSingleNodeStack } from "../lib/single-node-stack"; dotenv.config({ path: './test/.env-test' }); -describe("SolanaSingleNodeStack", () => { +describe("XRPSingleNodeStack", () => { test("synthesizes the way we expect", () => { const app = new cdk.App(); const xrpCommonStack = new XRPCommonStack(app, "xrp-common", { From b95052d80d30abf1ea7fd9647f5062fc1454903a Mon Sep 17 00:00:00 2001 From: Pedro Aceves Date: Thu, 6 Feb 2025 10:02:10 -0700 Subject: [PATCH 05/27] formatting --- lib/xrp/doc/README.md | 4 +--- lib/xrp/lib/assets/rippled/configBuilder.py | 2 +- lib/xrp/lib/assets/rippled/ripple.repo | 2 +- lib/xrp/lib/assets/rippled/rippled.cfg.template | 2 +- lib/xrp/lib/assets/rippled/validators.txt.template | 2 +- lib/xrp/lib/assets/user-data/check_xrp_sequence.sh | 2 +- lib/xrp/lib/assets/user-data/synch-check.service | 2 +- lib/xrp/lib/assets/user-data/synch-check.timer | 2 +- lib/xrp/lib/config/XRPConfig.interface.ts | 1 - lib/xrp/lib/config/createIniFile.ts | 8 -------- lib/xrp/lib/constructs/node-cw-dashboard.ts | 2 +- lib/xrp/lib/constructs/xrp-node-security-group.ts | 2 +- lib/xrp/lib/single-node-stack.ts | 2 +- lib/xrp/sample-configs/.env-sample-testnet | 1 - lib/xrp/test/.env-test | 1 - lib/xrp/test/common-stack.test.ts | 2 +- lib/xrp/test/single-node-stack.test.ts | 2 +- 17 files changed, 13 insertions(+), 26 deletions(-) diff --git a/lib/xrp/doc/README.md b/lib/xrp/doc/README.md index 11b26853..1505a358 100644 --- a/lib/xrp/doc/README.md +++ b/lib/xrp/doc/README.md @@ -144,6 +144,4 @@ HUB_NETWORK_ID="testnet" ``` b. lib/xrp/lib/assets/rippled/rippledconfig.py file. Here you can setup listners an network configuration for the network specified in part "a" - - - + \ No newline at end of file diff --git a/lib/xrp/lib/assets/rippled/configBuilder.py b/lib/xrp/lib/assets/rippled/configBuilder.py index da04e703..bb94b7e8 100644 --- a/lib/xrp/lib/assets/rippled/configBuilder.py +++ b/lib/xrp/lib/assets/rippled/configBuilder.py @@ -130,4 +130,4 @@ def main(): sys.exit(1) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/lib/xrp/lib/assets/rippled/ripple.repo b/lib/xrp/lib/assets/rippled/ripple.repo index 64e370fc..da3c851d 100644 --- a/lib/xrp/lib/assets/rippled/ripple.repo +++ b/lib/xrp/lib/assets/rippled/ripple.repo @@ -4,4 +4,4 @@ enabled=1 gpgcheck=0 repo_gpgcheck=1 baseurl=https://repos.ripple.com/repos/rippled-rpm/stable/ -gpgkey=https://repos.ripple.com/repos/rippled-rpm/stable/repodata/repomd.xml.key \ No newline at end of file +gpgkey=https://repos.ripple.com/repos/rippled-rpm/stable/repodata/repomd.xml.key diff --git a/lib/xrp/lib/assets/rippled/rippled.cfg.template b/lib/xrp/lib/assets/rippled/rippled.cfg.template index 01b32809..b17f5eee 100644 --- a/lib/xrp/lib/assets/rippled/rippled.cfg.template +++ b/lib/xrp/lib/assets/rippled/rippled.cfg.template @@ -28,4 +28,4 @@ validators.txt [ssl_verify] 1 [crawl] -1 \ No newline at end of file +1 diff --git a/lib/xrp/lib/assets/rippled/validators.txt.template b/lib/xrp/lib/assets/rippled/validators.txt.template index bd4f936c..df9f68cf 100644 --- a/lib/xrp/lib/assets/rippled/validators.txt.template +++ b/lib/xrp/lib/assets/rippled/validators.txt.template @@ -56,4 +56,4 @@ <> [validator_list_keys] -<> \ No newline at end of file +<> diff --git a/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh b/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh index fc21cae0..0713eee9 100644 --- a/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh +++ b/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh @@ -212,4 +212,4 @@ if ! main; then handle_error 1 "Failed to complete XRP sequence check" fi -exit 0 \ No newline at end of file +exit 0 diff --git a/lib/xrp/lib/assets/user-data/synch-check.service b/lib/xrp/lib/assets/user-data/synch-check.service index a2a2c5e2..cdb736b6 100644 --- a/lib/xrp/lib/assets/user-data/synch-check.service +++ b/lib/xrp/lib/assets/user-data/synch-check.service @@ -4,4 +4,4 @@ After=rippled.service [Service] Type=oneshot -ExecStart=/opt/check_xrp_sequence.sh \ No newline at end of file +ExecStart=/opt/check_xrp_sequence.sh diff --git a/lib/xrp/lib/assets/user-data/synch-check.timer b/lib/xrp/lib/assets/user-data/synch-check.timer index 97f78b05..1eacbee5 100644 --- a/lib/xrp/lib/assets/user-data/synch-check.timer +++ b/lib/xrp/lib/assets/user-data/synch-check.timer @@ -7,4 +7,4 @@ OnUnitActiveSec=1min Unit=synch-check.service [Install] -WantedBy=timers.target \ No newline at end of file +WantedBy=timers.target diff --git a/lib/xrp/lib/config/XRPConfig.interface.ts b/lib/xrp/lib/config/XRPConfig.interface.ts index c87cb4f1..0b71d58a 100644 --- a/lib/xrp/lib/config/XRPConfig.interface.ts +++ b/lib/xrp/lib/config/XRPConfig.interface.ts @@ -15,4 +15,3 @@ export interface HAXRPBaseNodeConfig extends XRPBaseNodeConfig { heartBeatDelayMin: number; numberOfNodes: number; } - diff --git a/lib/xrp/lib/config/createIniFile.ts b/lib/xrp/lib/config/createIniFile.ts index 3e8b8068..f635bcaf 100644 --- a/lib/xrp/lib/config/createIniFile.ts +++ b/lib/xrp/lib/config/createIniFile.ts @@ -39,11 +39,3 @@ export function parseRippledConfig(filePath: string): RippledConfig { - - - - - - - - diff --git a/lib/xrp/lib/constructs/node-cw-dashboard.ts b/lib/xrp/lib/constructs/node-cw-dashboard.ts index 99fdb674..9121eed3 100644 --- a/lib/xrp/lib/constructs/node-cw-dashboard.ts +++ b/lib/xrp/lib/constructs/node-cw-dashboard.ts @@ -215,4 +215,4 @@ export const SingleNodeCWDashboardJSON = { } } ] -} \ No newline at end of file +} diff --git a/lib/xrp/lib/constructs/xrp-node-security-group.ts b/lib/xrp/lib/constructs/xrp-node-security-group.ts index ac6f8374..f0cbb6a6 100644 --- a/lib/xrp/lib/constructs/xrp-node-security-group.ts +++ b/lib/xrp/lib/constructs/xrp-node-security-group.ts @@ -48,4 +48,4 @@ export class XRPNodeSecurityGroupConstruct extends cdkContructs.Construct { true ); } -} \ No newline at end of file +} diff --git a/lib/xrp/lib/single-node-stack.ts b/lib/xrp/lib/single-node-stack.ts index ef9b8824..03b1f0fc 100644 --- a/lib/xrp/lib/single-node-stack.ts +++ b/lib/xrp/lib/single-node-stack.ts @@ -140,4 +140,4 @@ export class XRPSingleNodeStack extends cdk.Stack { true ); } -} \ No newline at end of file +} diff --git a/lib/xrp/sample-configs/.env-sample-testnet b/lib/xrp/sample-configs/.env-sample-testnet index a8d82ee2..7a1d1c90 100644 --- a/lib/xrp/sample-configs/.env-sample-testnet +++ b/lib/xrp/sample-configs/.env-sample-testnet @@ -10,4 +10,3 @@ XRP_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN="60" XRP_HA_NODES_HEARTBEAT_DELAY_MIN="5" XRP_HA_NUMBER_OF_NODES="2" HUB_NETWORK_ID="testnet" - diff --git a/lib/xrp/test/.env-test b/lib/xrp/test/.env-test index a8d82ee2..7a1d1c90 100644 --- a/lib/xrp/test/.env-test +++ b/lib/xrp/test/.env-test @@ -10,4 +10,3 @@ XRP_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN="60" XRP_HA_NODES_HEARTBEAT_DELAY_MIN="5" XRP_HA_NUMBER_OF_NODES="2" HUB_NETWORK_ID="testnet" - diff --git a/lib/xrp/test/common-stack.test.ts b/lib/xrp/test/common-stack.test.ts index 82cbba17..75570240 100644 --- a/lib/xrp/test/common-stack.test.ts +++ b/lib/xrp/test/common-stack.test.ts @@ -73,4 +73,4 @@ describe("XRPCommonStack", () => { }); }); -}); \ No newline at end of file +}); diff --git a/lib/xrp/test/single-node-stack.test.ts b/lib/xrp/test/single-node-stack.test.ts index 5b921f1d..f98ed141 100644 --- a/lib/xrp/test/single-node-stack.test.ts +++ b/lib/xrp/test/single-node-stack.test.ts @@ -115,4 +115,4 @@ describe("XRPSingleNodeStack", () => { }) }); -}); \ No newline at end of file +}); From 4eb61921fd5354f178c9c8994cea04a90fe66aaa Mon Sep 17 00:00:00 2001 From: Pedro Aceves Date: Thu, 6 Feb 2025 10:28:27 -0700 Subject: [PATCH 06/27] sigh, more formatting --- lib/xrp/doc/README.md | 1 - lib/xrp/lib/assets/rippled/configBuilder.py | 10 +++---- lib/xrp/lib/assets/rippled/rippled.cfg | 8 ++--- .../assets/user-data/check_xrp_sequence.sh | 30 +++++++++---------- lib/xrp/lib/assets/user-data/node.sh | 2 +- lib/xrp/lib/config/createIniFile.ts | 7 ----- 6 files changed, 25 insertions(+), 33 deletions(-) diff --git a/lib/xrp/doc/README.md b/lib/xrp/doc/README.md index 1505a358..9bd459a9 100644 --- a/lib/xrp/doc/README.md +++ b/lib/xrp/doc/README.md @@ -144,4 +144,3 @@ HUB_NETWORK_ID="testnet" ``` b. lib/xrp/lib/assets/rippled/rippledconfig.py file. Here you can setup listners an network configuration for the network specified in part "a" - \ No newline at end of file diff --git a/lib/xrp/lib/assets/rippled/configBuilder.py b/lib/xrp/lib/assets/rippled/configBuilder.py index bb94b7e8..30e7e8dd 100644 --- a/lib/xrp/lib/assets/rippled/configBuilder.py +++ b/lib/xrp/lib/assets/rippled/configBuilder.py @@ -13,7 +13,7 @@ class RippledConfig: """Class to handle Rippled configuration settings""" assets_path: Path xrp_network: str - + def __init__(self, assets_path: str): self.assets_path = Path(assets_path) / "rippled" self.xrp_network = os.environ.get("XRP_NETWORK", "mainnet") @@ -28,7 +28,7 @@ def load_config_files(self) -> Tuple[configparser.ConfigParser, configparser.Con ripple_cfg.read_string(self._read_template_file("rippled.cfg.template")) validator_cfg.read_string(self._read_template_file("validators.txt.template")) - + return ripple_cfg, validator_cfg def _read_template_file(self, filename: str) -> str: @@ -50,11 +50,11 @@ def _create_config_parser() -> configparser.ConfigParser: parser.optionxform = str return parser - def apply_network_configuration(self, ripple_cfg: configparser.ConfigParser, + def apply_network_configuration(self, ripple_cfg: configparser.ConfigParser, validator_cfg: configparser.ConfigParser) -> None: """Apply network-specific configuration settings""" network_config = self.network_defaults[self.xrp_network] - + if self.xrp_network == "mainnet": self._configure_mainnet(ripple_cfg, validator_cfg, network_config) elif self.xrp_network == "testnet": @@ -112,7 +112,7 @@ def main(): try: assets_path = sys.argv[1] config_handler = RippledConfig(assets_path) - + ripple_cfg, validator_cfg = config_handler.load_config_files() config_handler.apply_network_configuration(ripple_cfg, validator_cfg) diff --git a/lib/xrp/lib/assets/rippled/rippled.cfg b/lib/xrp/lib/assets/rippled/rippled.cfg index 0b16c1e7..fd568927 100644 --- a/lib/xrp/lib/assets/rippled/rippled.cfg +++ b/lib/xrp/lib/assets/rippled/rippled.cfg @@ -396,8 +396,8 @@ # true - enables compression # false - disables compression [default]. # -# The rippled server can save bandwidth by compressing its peer-to-peer communications, -# at a cost of greater CPU usage. If you enable link compression, +# The rippled server can save bandwidth by compressing its peer-to-peer communications, +# at a cost of greater CPU usage. If you enable link compression, # the server automatically compresses communications with peer servers # that also have link compression enabled. # https://xrpl.org/enable-link-compression.html @@ -1008,7 +1008,7 @@ # that rippled is still in sync with the network, # and that the validated ledger is less than # 'age_threshold_seconds' old. If not, then continue -# sleeping for this number of seconds and +# sleeping for this number of seconds and # checking until healthy. # Default is 5. # @@ -1110,7 +1110,7 @@ # page_size Valid values: integer (MUST be power of 2 between 512 and 65536) # The default is 4096 bytes. This setting determines # the size of a page in the transaction.db file. -# See https://www.sqlite.org/pragma.html#pragma_page_size +# See https://www.sqlite.org/pragma.html#pragma_page_size # for more details about the available options. # # journal_size_limit Valid values: integer diff --git a/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh b/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh index 0713eee9..aefadb48 100644 --- a/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh +++ b/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh @@ -85,7 +85,7 @@ get_metadata() { retry_count=$((retry_count + 1)) sleep ${RETRY_DELAY} done - + log_error "Failed to retrieve metadata from ${endpoint} after ${MAX_RETRIES} attempts" return 1 } @@ -94,18 +94,18 @@ get_metadata() { check_dependencies() { log_info "Checking dependencies..." local missing_deps=() - + for cmd in aws jq curl; do if ! command -v "${cmd}" >/dev/null 2>&1; then missing_deps+=("${cmd}") fi done - + if [[ ${#missing_deps[@]} -gt 0 ]]; then log_error "Missing required dependencies: ${missing_deps[*]}" return 1 fi - + log_info "All dependencies satisfied" return 0 } @@ -129,7 +129,7 @@ get_current_sequence() { retry_count=$((retry_count + 1)) sleep ${RETRY_DELAY} done - + log_error "Failed to get current sequence after ${MAX_RETRIES} attempts" return 1 } @@ -138,7 +138,7 @@ get_current_sequence() { send_to_cloudwatch() { local sequence=$1 local retry_count=0 - + while [[ ${retry_count} -lt ${MAX_RETRIES} ]]; do if aws cloudwatch put-metric-data \ --namespace "${NAMESPACE}" \ @@ -154,7 +154,7 @@ send_to_cloudwatch() { retry_count=$((retry_count + 1)) sleep ${RETRY_DELAY} done - + log_error "Failed to send metrics to CloudWatch after ${MAX_RETRIES} attempts" return 1 } @@ -171,38 +171,38 @@ init_environment() { # Main function main() { local sequence - + log_info "Starting XRP sequence check" - + # Ensure only one instance is running exec {LOCK_FD}>"${LOCKFILE}" if ! flock -n "${LOCK_FD}"; then log_error "Another instance is already running" return 1 fi - + # Check dependencies first if ! check_dependencies; then return 1 fi - + # Initialize environment variables if ! init_environment; then return 1 fi - + # Get current sequence if ! sequence=$(get_current_sequence); then return 1 fi - + log_info "Retrieved sequence: ${sequence}" - + # Send to CloudWatch if ! send_to_cloudwatch "${sequence}"; then return 1 fi - + log_info "XRP sequence check completed successfully" return 0 } diff --git a/lib/xrp/lib/assets/user-data/node.sh b/lib/xrp/lib/assets/user-data/node.sh index c87fbc7c..240d8cbd 100644 --- a/lib/xrp/lib/assets/user-data/node.sh +++ b/lib/xrp/lib/assets/user-data/node.sh @@ -120,7 +120,7 @@ install_rippled() { log_info "rippled package already installed, checking for updates..." sudo yum update -y rippled fi - + log_info "build out and write rippled.cfg and validaotrs.txt" python3 ${ASSETS_DIR}/rippled/configBuilder.py ${ASSETS_DIR} diff --git a/lib/xrp/lib/config/createIniFile.ts b/lib/xrp/lib/config/createIniFile.ts index f635bcaf..6e828327 100644 --- a/lib/xrp/lib/config/createIniFile.ts +++ b/lib/xrp/lib/config/createIniFile.ts @@ -32,10 +32,3 @@ export function parseRippledConfig(filePath: string): RippledConfig { }); return config; } - - - - - - - From a1be97b7bb4c3ec9bbfccd2d89fb6dab34bd05b3 Mon Sep 17 00:00:00 2001 From: Pedro Aceves Date: Thu, 6 Feb 2025 11:03:04 -0700 Subject: [PATCH 07/27] file rename --- lib/xrp/{jest.config.ts => jest.config.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/xrp/{jest.config.ts => jest.config.js} (100%) diff --git a/lib/xrp/jest.config.ts b/lib/xrp/jest.config.js similarity index 100% rename from lib/xrp/jest.config.ts rename to lib/xrp/jest.config.js From 8a218d07fec24bf4b6a53050ed5f3f3653b65ccc Mon Sep 17 00:00:00 2001 From: Pedro Aceves Date: Thu, 6 Feb 2025 11:09:15 -0700 Subject: [PATCH 08/27] use explicit test config if jest config exists --- scripts/run-all-cdk-tests.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/run-all-cdk-tests.sh b/scripts/run-all-cdk-tests.sh index 57fb1374..2d9304b7 100755 --- a/scripts/run-all-cdk-tests.sh +++ b/scripts/run-all-cdk-tests.sh @@ -10,6 +10,12 @@ run_test(){ local workdir=$1 cd "$workdir" || exit 1 echo "Running tests for $workdir" + if [ -f "jest.config.js" ]; then + echo "Using jest configuration at ${workdir}jest.config.js" + npx jest --config jest.config.js + else + npx jest + fi npm run test if [ $? -ne 0 ]; then echo "Tests failed for $workdir" From ee6abf101571c7a013e60c11a49753b12d19d1e6 Mon Sep 17 00:00:00 2001 From: Pedro Aceves Date: Thu, 6 Feb 2025 11:22:56 -0700 Subject: [PATCH 09/27] move dotenv, it's not getting picked up for some reason. works local... --- lib/xrp/test/single-node-stack.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/xrp/test/single-node-stack.test.ts b/lib/xrp/test/single-node-stack.test.ts index f98ed141..2c7b7282 100644 --- a/lib/xrp/test/single-node-stack.test.ts +++ b/lib/xrp/test/single-node-stack.test.ts @@ -5,10 +5,10 @@ import * as config from "../lib/config/XRPConfig"; import { XRPCommonStack } from "../lib/common-stack"; import { XRPSingleNodeStack } from "../lib/single-node-stack"; -dotenv.config({ path: './test/.env-test' }); describe("XRPSingleNodeStack", () => { test("synthesizes the way we expect", () => { + dotenv.config({ path: './test/.env-test' }); const app = new cdk.App(); const xrpCommonStack = new XRPCommonStack(app, "xrp-common", { env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, From cc4af0ec7d8b36898e8d26d592e88de6479dfc96 Mon Sep 17 00:00:00 2001 From: Pedro Aceves Date: Thu, 6 Feb 2025 13:31:01 -0700 Subject: [PATCH 10/27] fix env file loading issue --- lib/xrp/test/single-node-stack.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/xrp/test/single-node-stack.test.ts b/lib/xrp/test/single-node-stack.test.ts index 2c7b7282..69ca6cdc 100644 --- a/lib/xrp/test/single-node-stack.test.ts +++ b/lib/xrp/test/single-node-stack.test.ts @@ -1,6 +1,7 @@ import { Match, Template } from "aws-cdk-lib/assertions"; import * as cdk from "aws-cdk-lib"; import * as dotenv from "dotenv"; +dotenv.config({ path: './test/.env-test' }); import * as config from "../lib/config/XRPConfig"; import { XRPCommonStack } from "../lib/common-stack"; import { XRPSingleNodeStack } from "../lib/single-node-stack"; @@ -8,7 +9,6 @@ import { XRPSingleNodeStack } from "../lib/single-node-stack"; describe("XRPSingleNodeStack", () => { test("synthesizes the way we expect", () => { - dotenv.config({ path: './test/.env-test' }); const app = new cdk.App(); const xrpCommonStack = new XRPCommonStack(app, "xrp-common", { env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, From ba4a5cf4f861a2bd485ba8999d420f98e023d586 Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 11:24:13 -0500 Subject: [PATCH 11/27] Fixed typo in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8aa4971f..ef1eecce 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ If you'd like propose a Node Runner Blueprint for your node, see [Adding new Nod - `lib/constructs` - [CDK constructs](https://docs.aws.amazon.com/cdk/v2/guide/constructs.html) used in Node Runner Blueprints - `lib/your-chain` - Node Runner Blueprint for a specific chain - `website` - Content for the project web site built with [Docusaurus](https://docusaurus.io/) -- `website/docs` - Place for the new blueprint deployment instructions. (If you are adding a new blueprint, use on of the existing examples to refer to the `README.md` file within your Node Runner Blueprint directory inside `lib`). +- `website/docs` - Place for the new blueprint deployment instructions. (If you are adding a new blueprint, use one of the existing examples to refer to the `README.md` file within your Node Runner Blueprint directory inside `lib`). ### License This repository uses MIT License. See more in [LICENSE](./LICENSE). From 00b459bbb6b478af402a936940a7a000f8b7d684 Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 13:09:38 -0500 Subject: [PATCH 12/27] incorporated changes for PR. opened up port 6005 for RPC requests from within the vpc --- lib/xrp/{doc => }/README.md | 17 +++++++++-------- lib/xrp/lib/assets/rippled/rippled.cfg.template | 2 ++ lib/xrp/lib/assets/rippled/rippledconfig.py | 7 ++++++- lib/xrp/package.json | 8 +------- website/docs/Blueprints/XRP.md | 8 ++++++++ 5 files changed, 26 insertions(+), 16 deletions(-) rename lib/xrp/{doc => }/README.md (92%) create mode 100644 website/docs/Blueprints/XRP.md diff --git a/lib/xrp/doc/README.md b/lib/xrp/README.md similarity index 92% rename from lib/xrp/doc/README.md rename to lib/xrp/README.md index 9bd459a9..70830c73 100644 --- a/lib/xrp/doc/README.md +++ b/lib/xrp/README.md @@ -59,7 +59,7 @@ aws ec2 create-default-vpc Create your own copy of `.env` file and edit it to update with your AWS Account ID and Region: ```bash cd lib/xrp -cp ./sample-configs/.env-xrp-testnet .env +cp ./sample-configs/.env-sample-testnet .env nano .env ``` > **NOTE:** *You can find more examples inside `sample-configs` * @@ -99,7 +99,7 @@ npx cdk deploy XRP-ha-nodes --json --outputs-file ha-nodes-deploy.json > **NOTE:** *By default and for security reasons the load balancer is available only from within the default VPC in the region where it is deployed. It is not available from the Internet and is not open for external connections. Before opening it up please make sure you protect your RPC APIs.* -### Clearing up and undeploy everything +### Cleaning up and undeploying everything Destroy HA Nodes, Single Nodes and Common stacks @@ -129,7 +129,7 @@ cdk destroy XRP-common pwd # Make sure you are in aws-blockchain-node-runners/lib/xrp -export INSTANCE_ID=$(cat single-node-deploy.json | jq -r '..|.node-instance-id? | select(. != null)') +export INSTANCE_ID=$(cat single-node-deploy.json | jq -r '.["XRP-single-node"].nodeinstanceid') echo "INSTANCE_ID=" $INSTANCE_ID aws ssm start-session --target $INSTANCE_ID --region $AWS_REGION sudo cat /var/log/cloud-init-output.log @@ -138,9 +138,10 @@ sudo cat /var/log/user-data.log 2. How can I change rippled (XRP) configuration? There are two places of configuration for the xrp nodes: - a. .env file. Here is where you specify the xrp network you want. This is the key into the config hash in part b -```bash -HUB_NETWORK_ID="testnet" -``` + a. `.env` file. Here is where you specify the xrp network you want. This is the key for the config in part b + + ```bash + HUB_NETWORK_ID="testnet" + ``` - b. lib/xrp/lib/assets/rippled/rippledconfig.py file. Here you can setup listners an network configuration for the network specified in part "a" + b. `lib/xrp/lib/assets/rippled/rippledconfig.py` file. Here you can setup listeners and network configuration for the network specified in part "a" diff --git a/lib/xrp/lib/assets/rippled/rippled.cfg.template b/lib/xrp/lib/assets/rippled/rippled.cfg.template index b17f5eee..7637f60c 100644 --- a/lib/xrp/lib/assets/rippled/rippled.cfg.template +++ b/lib/xrp/lib/assets/rippled/rippled.cfg.template @@ -2,6 +2,8 @@ port_peer port_rpc_admin_local port_ws_admin_local +port_ws_public +[port_ws_public] [port_rpc_admin_local] [port_peer] [port_ws_admin_local] diff --git a/lib/xrp/lib/assets/rippled/rippledconfig.py b/lib/xrp/lib/assets/rippled/rippledconfig.py index a8328352..9e3ae5b0 100644 --- a/lib/xrp/lib/assets/rippled/rippledconfig.py +++ b/lib/xrp/lib/assets/rippled/rippledconfig.py @@ -20,6 +20,11 @@ "admin": "127.0.0.1", "protocol": "ws,wss", }, + "port_ws_public": { + "port": "6005", + "ip": "0.0.0.0", + "protocol": "ws,wss,http", + }, }, "db_defaults": { "node_db": { @@ -31,7 +36,7 @@ }, "network_defaults": { "mainnet": { - "network_id": "mainnet", + "network_id": "main", "ssl_verify": "1", "validator_list_sites": ["https://vl.ripple.com"], "validator_list_keys": [ diff --git a/lib/xrp/package.json b/lib/xrp/package.json index dba6bc71..b777d2b9 100644 --- a/lib/xrp/package.json +++ b/lib/xrp/package.json @@ -7,11 +7,5 @@ "test": "npx jest --detectOpenHandles", "cdk": "npx cdk", "scan-cdk": "npx cdk synth" - }, - "dependencies": { - "ini": "^4.1.1" - }, - "devDependencies": { - "@types/ini": "^4.1.1" } -} +} \ No newline at end of file diff --git a/website/docs/Blueprints/XRP.md b/website/docs/Blueprints/XRP.md new file mode 100644 index 00000000..16e53e26 --- /dev/null +++ b/website/docs/Blueprints/XRP.md @@ -0,0 +1,8 @@ +--- +sidebar_label: XRP +--- +# + +import Readme from '../../../lib/xrp/README.md'; + + From cd6036301c336345a5531454a559c8d495a138fc Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 13:14:57 -0500 Subject: [PATCH 13/27] fixed arch images in README --- lib/xrp/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index 70830c73..583e56c9 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -10,7 +10,7 @@ XRP node deployment on AWS. All nodes are configure as ["Stock Servers"](https:/ ### Single node setup -![Single Node Deployment](./assets/Architecture-Single%20node.drawio.png) +![Single Node Deployment](./doc/assets/Architecture-Single%20node.drawio.png) 1. A XRP node deployed in the [Default VPC](https://docs.aws.amazon.com/vpc/latest/userguide/default-vpc.html) continuously synchronizes with the rest of nodes on the configured xrp network through [Internet Gateway](https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Internet_Gateway.html). 2. The XRP node is used by dApps or development tools internally from within the Default VPC. RPC API is not exposed to the Internet directly to protect nodes from unauthorized access. @@ -18,7 +18,7 @@ XRP node deployment on AWS. All nodes are configure as ["Stock Servers"](https:/ ### HA setup -![Highly Available Nodes Deployment](./assets/Architecture-HA%20Nodes.drawio.png) +![Highly Available Nodes Deployment](./doc/assets/assets/Architecture-HA%20Nodes.drawio.png) 1. A set of XRP nodes are deployed within an [Auto Scaling Group](https://docs.aws.amazon.com/autoscaling/ec2/userguide/auto-scaling-groups.html) in the [Default VPC](https://docs.aws.amazon.com/vpc/latest/userguide/default-vpc.html) continuously synchronizing with the rest of nodes on the configured xrp network through [Internet Gateway](https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Internet_Gateway.html). 2. The XRP nodes are accessed by dApps or development tools internally through [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). RPC API is not exposed to the Internet to protect nodes from unauthorized access. From 23e63ad9d5464b51403e1e346b30db68caf742e9 Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 13:16:38 -0500 Subject: [PATCH 14/27] fixed arch paths pt2 --- lib/xrp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index 583e56c9..7e8a9d14 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -18,7 +18,7 @@ XRP node deployment on AWS. All nodes are configure as ["Stock Servers"](https:/ ### HA setup -![Highly Available Nodes Deployment](./doc/assets/assets/Architecture-HA%20Nodes.drawio.png) +![Highly Available Nodes Deployment](./doc/assets/Architecture-HA%20Nodes.drawio.png) 1. A set of XRP nodes are deployed within an [Auto Scaling Group](https://docs.aws.amazon.com/autoscaling/ec2/userguide/auto-scaling-groups.html) in the [Default VPC](https://docs.aws.amazon.com/vpc/latest/userguide/default-vpc.html) continuously synchronizing with the rest of nodes on the configured xrp network through [Internet Gateway](https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Internet_Gateway.html). 2. The XRP nodes are accessed by dApps or development tools internally through [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). RPC API is not exposed to the Internet to protect nodes from unauthorized access. From 384f333cd40a6413a92b73118fab8cf4dc739ca5 Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 13:41:32 -0500 Subject: [PATCH 15/27] added instructions for performing single node rpc request --- lib/xrp/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index 7e8a9d14..fba73fe9 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -85,6 +85,37 @@ npx cdk deploy XRP-single-node --json --outputs-file single-node-deploy.json - Navigate to [CloudWatch service](https://console.aws.amazon.com/cloudwatch/) (make sure you are in the region you have specified for `AWS_REGION`) - Open `Dashboards` and select dashboard that starts with `XRP-single-node` from the list of dashboards. +3. Once the initial synchronization is done, you should be able to access the RPC API of that node from within the same VPC. The RPC port is not exposed to the Internet. Run the following query against the private IP of the single RPC node you deployed: + +```bash +export INSTANCE_ID=$(cat single-node-deploy.json | jq -r '.["XRP-single-node"].nodeinstanceid') + NODE_INTERNAL_IP=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID --query 'Reservations[*].Instances[*].PrivateIpAddress' --output text) +echo "NODE_INTERNAL_IP=$NODE_INTERNAL_IP" +``` + +Copy output from the last `echo` command with `NODE_INTERNAL_IP=` and open [CloudShell tab with VPC environment](https://docs.aws.amazon.com/cloudshell/latest/userguide/creating-vpc-environment.html) to access internal IP address space. Paste `NODE_INTERNAL_IP=` into the new CloudShell tab. + +Then query the RPC API to receive the latest block height: + +``` bash +# IMPORTANT: Run from CloudShell VPC environment tab +curl -X POST -H "Content-Type: application/json" http://$NODE_INTERNAL_IP:6005/ -d '{ + "method": "ledger_current", + "params": [{}] +}' +``` +You will get a response similar to this: + +```json +{"result":{"ledger_current_index":5147254,"status":"success"}} +``` + +Note: If the node is still syncing, you will receive the following response: + +```json +{"result":{"error":"noNetwork","error_code":17,"error_message":"Not synced to the network.","request":{"command":"ledger_current"},"status":"error"}} +``` + ### Deploy HA Nodes 1. Deploy multiple HA Nodes From 8375bafbacfa700257cd5caf87821fd6cdb70bbe Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 13:57:55 -0500 Subject: [PATCH 16/27] added load balancer rpc instructions --- lib/xrp/README.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index fba73fe9..be1f1eed 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -126,7 +126,30 @@ pwd npx cdk deploy XRP-ha-nodes --json --outputs-file ha-nodes-deploy.json ``` -2. Give the new nodes time to initialize +2. Give the new nodes time to initialize + +3. To perform an RPC request to your load balancer, run the following command to retrieve the ALB URL: + +```bash +export XRP_RPC_ALB_URL=$(cat ha-nodes-deploy.json | jq -r '..|.alburl? | select(. != null)') +echo XRP_RPC_ALB_URL=$XRP_RPC_ALB_URL +``` + +Copy output from the last `echo` command with `XRP_RPC_ALB_URL=` and open [CloudShell tab with VPC environment](https://docs.aws.amazon.com/cloudshell/latest/userguide/creating-vpc-environment.html) to access internal IP address space. Paste `XRP_RPC_ALB_URL=` into the VPC CloudShell tab. + +Then query the load balancer to retrieve the current block height: + +```bash +curl -X POST -H "Content-Type: application/json" http://$XRP_RPC_ALB_URL:6005/ -d '{ + "method": "ledger_current", + "params": [{}] + ``` + +You will get a response similar to this: + +```json +{"result":{"ledger_current_index":5147300,"status":"success"}} +``` > **NOTE:** *By default and for security reasons the load balancer is available only from within the default VPC in the region where it is deployed. It is not available from the Internet and is not open for external connections. Before opening it up please make sure you protect your RPC APIs.* From afc8a7459b3c9bd8a1fb8a1de280fbcd2c9a17a1 Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 13:59:19 -0500 Subject: [PATCH 17/27] fixed rpc alb command --- lib/xrp/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index be1f1eed..9f38d402 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -143,6 +143,7 @@ Then query the load balancer to retrieve the current block height: curl -X POST -H "Content-Type: application/json" http://$XRP_RPC_ALB_URL:6005/ -d '{ "method": "ledger_current", "params": [{}] + }' ``` You will get a response similar to this: From 4b10a07ce08a472d161f4556bfa659c1531c2e06 Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 14:13:29 -0500 Subject: [PATCH 18/27] downgraded instance type, and added mainnet sample config file --- lib/xrp/README.md | 31 ++++++++++++++++++++++ lib/xrp/sample-configs/.env-sample-mainnet | 12 +++++++++ lib/xrp/sample-configs/.env-sample-testnet | 2 +- 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 lib/xrp/sample-configs/.env-sample-mainnet diff --git a/lib/xrp/README.md b/lib/xrp/README.md index 9f38d402..90e6c443 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -24,6 +24,37 @@ XRP node deployment on AWS. All nodes are configure as ["Stock Servers"](https:/ 2. The XRP nodes are accessed by dApps or development tools internally through [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). RPC API is not exposed to the Internet to protect nodes from unauthorized access. 3. The XRP nodes send various monitoring metrics for EC2 to Amazon CloudWatch. +## Well-Architected + +
    +Review the for pros and cons of this solution. + +### Well-Architected Checklist + +This is the Well-Architected checklist for XRP nodes implementation of the AWS Blockchain Node Runner app. This checklist takes into account questions from the [AWS Well-Architected Framework](https://aws.amazon.com/architecture/well-architected/) which are relevant to this workload. Please feel free to add more checks from the framework if required for your workload. + +| Pillar | Control | Question/Check | Remarks | +|:------------------------|:----------------------------------|:---------------------------------------------------------------------------------|:-----------------| +| Security | Network protection | Are there unnecessary open ports in security groups? | Please note that XRP sync ports remain open for outbound connections; Port 2459 and 51235 (TCP/UDP). | +| | | Traffic inspection | AWS WAF could be implemented for traffic inspection. Additional charges will apply. | +| | Compute protection | Reduce attack surface | This solution uses Amazon Linux 2 AMI. You may choose to run hardening scripts on it. | +| | | Enable people to perform actions at a distance | This solution uses AWS Systems Manager for terminal session, not ssh ports. | +| | Data protection at rest | Use encrypted Amazon Elastic Block Store (Amazon EBS) volumes | This solution uses encrypted Amazon EBS volumes. | +| | | Use encrypted Amazon Simple Storage Service (Amazon S3) buckets | This solution uses Amazon S3 managed keys (SSE-S3) encryption. | +| | Data protection in transit | Use TLS | The AWS Application Load balancer currently uses HTTP listener. Create HTTPS listener with self signed certificate if TLS is desired. | +| | Authorization and access control | Use instance profile with Amazon Elastic Compute Cloud (Amazon EC2) instances | This solution uses AWS Identity and Access Management (AWS IAM) role instead of IAM user. | +| | | Following principle of least privilege access | Privileges are scoped down. | +| | Application security | Security focused development practices | cdk-nag is being used with appropriate suppressions. | +| Cost optimization | Service selection | Use cost effective resources | AWS Graviton-based Amazon EC2 instances are being used, which are cost effective compared to Intel/AMD instances. | +| Reliability | Resiliency implementation | Withstand component failures | This solution uses AWS Application Load Balancer with RPC nodes for high availability. | +| | Resource monitoring | How are workload resources monitored? | Resources are being monitored using Amazon CloudWatch dashboards. Amazon CloudWatch custom metrics are being pushed via CloudWatch Agent. | +| Performance efficiency | Compute selection | How is compute solution selected? | Compute solution is selected based on best price-performance, i.e. AWS Graviton-based Amazon EC2 instances. | +| | Storage selection | How is storage solution selected? | Storage solution is selected based on best price-performance. | +| Operational excellence | Workload health | How is health of workload determined? | Health of workload is determined via AWS Application Load Balancer Target Group Health Checks, on port 8545. | +| Sustainability | Hardware & services | Select most efficient hardware for your workload | This solution uses AWS Graviton-based Amazon EC2 instances which offer the best performance per watt of energy use in Amazon EC2. | + +
    + ## Setup Instructions ### Open AWS CloudShell diff --git a/lib/xrp/sample-configs/.env-sample-mainnet b/lib/xrp/sample-configs/.env-sample-mainnet new file mode 100644 index 00000000..b54f54c9 --- /dev/null +++ b/lib/xrp/sample-configs/.env-sample-mainnet @@ -0,0 +1,12 @@ +AWS_ACCOUNT_ID="xxxxxxxxxxx" +AWS_REGION="xxxxxxxxxx" +XRP_INSTANCE_TYPE="i3.2xlarge" #The solution was originally tested with the r7a.12xlarge instance type. Other instance types may work, but have not been extensively tested. i3.2xlarge is recommended for use by XRP Ledger +XRP_CPU_TYPE="x86_64" # All options: "x86_64". ARM currently not supported +DATA_VOL_TYPE="gp3" # Other options: "io1" | "io2" | "gp3" | "instance-store" . IMPORTANT: Use "instance-store" option only with instance types that support that feature, like popular for node im4gn, d3, i3en, and i4i instance families +DATA_VOL_SIZE="2000" # Current required data size to keep both smapshot archive and unarchived version of it +DATA_VOL_IOPS="12000" # Max IOPS for EBS volumes (not applicable for "instance-store") +DATA_VOL_THROUGHPUT="700" +XRP_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN="60" +XRP_HA_NODES_HEARTBEAT_DELAY_MIN="5" +XRP_HA_NUMBER_OF_NODES="2" +HUB_NETWORK_ID="mainnet" \ No newline at end of file diff --git a/lib/xrp/sample-configs/.env-sample-testnet b/lib/xrp/sample-configs/.env-sample-testnet index 7a1d1c90..f4a98648 100644 --- a/lib/xrp/sample-configs/.env-sample-testnet +++ b/lib/xrp/sample-configs/.env-sample-testnet @@ -1,6 +1,6 @@ AWS_ACCOUNT_ID="xxxxxxxxxxx" AWS_REGION="xxxxxxxxxx" -XRP_INSTANCE_TYPE="r7a.12xlarge" +XRP_INSTANCE_TYPE="i3.2xlarge" #The solution was originally tested with the r7a.12xlarge instance type. Other instance types may work, but have not been extensively tested. i3.2xlarge is recommended for use by XRP Ledger XRP_CPU_TYPE="x86_64" # All options: "x86_64". ARM currently not supported DATA_VOL_TYPE="gp3" # Other options: "io1" | "io2" | "gp3" | "instance-store" . IMPORTANT: Use "instance-store" option only with instance types that support that feature, like popular for node im4gn, d3, i3en, and i4i instance families DATA_VOL_SIZE="2000" # Current required data size to keep both smapshot archive and unarchived version of it From cf5af4ba7a5725c20e065cd865c18fbd91694418 Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 14:20:22 -0500 Subject: [PATCH 19/27] updated WAF to reflect new instance tpye --- lib/xrp/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index 90e6c443..704ffdce 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -45,13 +45,13 @@ This is the Well-Architected checklist for XRP nodes implementation of the AWS B | | Authorization and access control | Use instance profile with Amazon Elastic Compute Cloud (Amazon EC2) instances | This solution uses AWS Identity and Access Management (AWS IAM) role instead of IAM user. | | | | Following principle of least privilege access | Privileges are scoped down. | | | Application security | Security focused development practices | cdk-nag is being used with appropriate suppressions. | -| Cost optimization | Service selection | Use cost effective resources | AWS Graviton-based Amazon EC2 instances are being used, which are cost effective compared to Intel/AMD instances. | +| Cost optimization | Service selection | Use cost effective resources | Cost efficient I3 instances are being used, which are ideal for high transaction and low latecy workloads. | | Reliability | Resiliency implementation | Withstand component failures | This solution uses AWS Application Load Balancer with RPC nodes for high availability. | | | Resource monitoring | How are workload resources monitored? | Resources are being monitored using Amazon CloudWatch dashboards. Amazon CloudWatch custom metrics are being pushed via CloudWatch Agent. | -| Performance efficiency | Compute selection | How is compute solution selected? | Compute solution is selected based on best price-performance, i.e. AWS Graviton-based Amazon EC2 instances. | +| Performance efficiency | Compute selection | How is compute solution selected? | Compute solution is selected based on best price-performance. | | | Storage selection | How is storage solution selected? | Storage solution is selected based on best price-performance. | | Operational excellence | Workload health | How is health of workload determined? | Health of workload is determined via AWS Application Load Balancer Target Group Health Checks, on port 8545. | -| Sustainability | Hardware & services | Select most efficient hardware for your workload | This solution uses AWS Graviton-based Amazon EC2 instances which offer the best performance per watt of energy use in Amazon EC2. | +| Sustainability | Hardware & services | Select most efficient hardware for your workload | This solution uses I3 instance class which is Storage Optimized instances for high transaction and low latency. | From 0562fd9cb077f450576f647acd82a0da0c77ae5f Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 14:23:17 -0500 Subject: [PATCH 20/27] updated Sustainability section in WAF --- lib/xrp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index 704ffdce..b0d8a84f 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -51,7 +51,7 @@ This is the Well-Architected checklist for XRP nodes implementation of the AWS B | Performance efficiency | Compute selection | How is compute solution selected? | Compute solution is selected based on best price-performance. | | | Storage selection | How is storage solution selected? | Storage solution is selected based on best price-performance. | | Operational excellence | Workload health | How is health of workload determined? | Health of workload is determined via AWS Application Load Balancer Target Group Health Checks, on port 8545. | -| Sustainability | Hardware & services | Select most efficient hardware for your workload | This solution uses I3 instance class which is Storage Optimized instances for high transaction and low latency. | +| Sustainability | Hardware & services | Select most efficient hardware for your workload | Amazon EC2 I3 instances support the Sustainability Pillar of the AWS Well-Architected Framework by offering high-performance, storage-optimized computing that enables more efficient resource utilization, potentially reducing overall energy consumption and hardware requirements for data-intensive workloads. | From ac5098d41e85d8a4fd1dc608da595d7145a95f6f Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 14:54:58 -0500 Subject: [PATCH 21/27] addressed pre-commit failure --- lib/xrp/README.md | 4 ++-- lib/xrp/sample-configs/.env-sample-mainnet | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index b0d8a84f..c81e8476 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -124,7 +124,7 @@ export INSTANCE_ID=$(cat single-node-deploy.json | jq -r '.["XRP-single-node"].n echo "NODE_INTERNAL_IP=$NODE_INTERNAL_IP" ``` -Copy output from the last `echo` command with `NODE_INTERNAL_IP=` and open [CloudShell tab with VPC environment](https://docs.aws.amazon.com/cloudshell/latest/userguide/creating-vpc-environment.html) to access internal IP address space. Paste `NODE_INTERNAL_IP=` into the new CloudShell tab. +Copy output from the last `echo` command with `NODE_INTERNAL_IP=` and open [CloudShell tab with VPC environment](https://docs.aws.amazon.com/cloudshell/latest/userguide/creating-vpc-environment.html) to access internal IP address space. Paste `NODE_INTERNAL_IP=` into the new CloudShell tab. Then query the RPC API to receive the latest block height: @@ -166,7 +166,7 @@ export XRP_RPC_ALB_URL=$(cat ha-nodes-deploy.json | jq -r '..|.alburl? | select( echo XRP_RPC_ALB_URL=$XRP_RPC_ALB_URL ``` -Copy output from the last `echo` command with `XRP_RPC_ALB_URL=` and open [CloudShell tab with VPC environment](https://docs.aws.amazon.com/cloudshell/latest/userguide/creating-vpc-environment.html) to access internal IP address space. Paste `XRP_RPC_ALB_URL=` into the VPC CloudShell tab. +Copy output from the last `echo` command with `XRP_RPC_ALB_URL=` and open [CloudShell tab with VPC environment](https://docs.aws.amazon.com/cloudshell/latest/userguide/creating-vpc-environment.html) to access internal IP address space. Paste `XRP_RPC_ALB_URL=` into the VPC CloudShell tab. Then query the load balancer to retrieve the current block height: diff --git a/lib/xrp/sample-configs/.env-sample-mainnet b/lib/xrp/sample-configs/.env-sample-mainnet index b54f54c9..cdf4d645 100644 --- a/lib/xrp/sample-configs/.env-sample-mainnet +++ b/lib/xrp/sample-configs/.env-sample-mainnet @@ -9,4 +9,4 @@ DATA_VOL_THROUGHPUT="700" XRP_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN="60" XRP_HA_NODES_HEARTBEAT_DELAY_MIN="5" XRP_HA_NUMBER_OF_NODES="2" -HUB_NETWORK_ID="mainnet" \ No newline at end of file +HUB_NETWORK_ID="mainnet" From f05ba36f2f26dea038a5551fc1853fb593798bc5 Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Tue, 25 Feb 2025 14:57:09 -0500 Subject: [PATCH 22/27] addressed pre-commit failure pt2 --- lib/xrp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/xrp/package.json b/lib/xrp/package.json index b777d2b9..eca6f9fb 100644 --- a/lib/xrp/package.json +++ b/lib/xrp/package.json @@ -8,4 +8,4 @@ "cdk": "npx cdk", "scan-cdk": "npx cdk synth" } -} \ No newline at end of file +} From 268ee52e3ca913c3c8f418648a723261c3275e86 Mon Sep 17 00:00:00 2001 From: Simon Goldberg Date: Thu, 27 Feb 2025 10:07:01 -0500 Subject: [PATCH 23/27] modified instance type and fixed cw dashboard --- lib/xrp/README.md | 4 ++-- lib/xrp/sample-configs/.env-sample-mainnet | 2 +- lib/xrp/sample-configs/.env-sample-testnet | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index c81e8476..14d786e9 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -45,13 +45,13 @@ This is the Well-Architected checklist for XRP nodes implementation of the AWS B | | Authorization and access control | Use instance profile with Amazon Elastic Compute Cloud (Amazon EC2) instances | This solution uses AWS Identity and Access Management (AWS IAM) role instead of IAM user. | | | | Following principle of least privilege access | Privileges are scoped down. | | | Application security | Security focused development practices | cdk-nag is being used with appropriate suppressions. | -| Cost optimization | Service selection | Use cost effective resources | Cost efficient I3 instances are being used, which are ideal for high transaction and low latecy workloads. | +| Cost optimization | Service selection | Use cost effective resources | Cost efficient R7a instances are being used, which are ideal for high transaction and low latecy workloads. | | Reliability | Resiliency implementation | Withstand component failures | This solution uses AWS Application Load Balancer with RPC nodes for high availability. | | | Resource monitoring | How are workload resources monitored? | Resources are being monitored using Amazon CloudWatch dashboards. Amazon CloudWatch custom metrics are being pushed via CloudWatch Agent. | | Performance efficiency | Compute selection | How is compute solution selected? | Compute solution is selected based on best price-performance. | | | Storage selection | How is storage solution selected? | Storage solution is selected based on best price-performance. | | Operational excellence | Workload health | How is health of workload determined? | Health of workload is determined via AWS Application Load Balancer Target Group Health Checks, on port 8545. | -| Sustainability | Hardware & services | Select most efficient hardware for your workload | Amazon EC2 I3 instances support the Sustainability Pillar of the AWS Well-Architected Framework by offering high-performance, storage-optimized computing that enables more efficient resource utilization, potentially reducing overall energy consumption and hardware requirements for data-intensive workloads. | +| Sustainability | Hardware & services | Select most efficient hardware for your workload | Amazon EC2 R7a instances support the Sustainability Pillar of the AWS Well-Architected Framework by offering memory optimization that enables more efficient resource utilization, potentially reducing overall energy consumption and hardware requirements for data-intensive workloads. | diff --git a/lib/xrp/sample-configs/.env-sample-mainnet b/lib/xrp/sample-configs/.env-sample-mainnet index cdf4d645..d55445ae 100644 --- a/lib/xrp/sample-configs/.env-sample-mainnet +++ b/lib/xrp/sample-configs/.env-sample-mainnet @@ -1,6 +1,6 @@ AWS_ACCOUNT_ID="xxxxxxxxxxx" AWS_REGION="xxxxxxxxxx" -XRP_INSTANCE_TYPE="i3.2xlarge" #The solution was originally tested with the r7a.12xlarge instance type. Other instance types may work, but have not been extensively tested. i3.2xlarge is recommended for use by XRP Ledger +XRP_INSTANCE_TYPE="r7a.2xlarge" #The solution was originally tested with the r7a.12xlarge instance type. Other instance types will work, but have not been extensively tested. XRP_CPU_TYPE="x86_64" # All options: "x86_64". ARM currently not supported DATA_VOL_TYPE="gp3" # Other options: "io1" | "io2" | "gp3" | "instance-store" . IMPORTANT: Use "instance-store" option only with instance types that support that feature, like popular for node im4gn, d3, i3en, and i4i instance families DATA_VOL_SIZE="2000" # Current required data size to keep both smapshot archive and unarchived version of it diff --git a/lib/xrp/sample-configs/.env-sample-testnet b/lib/xrp/sample-configs/.env-sample-testnet index f4a98648..855e9787 100644 --- a/lib/xrp/sample-configs/.env-sample-testnet +++ b/lib/xrp/sample-configs/.env-sample-testnet @@ -1,6 +1,6 @@ AWS_ACCOUNT_ID="xxxxxxxxxxx" AWS_REGION="xxxxxxxxxx" -XRP_INSTANCE_TYPE="i3.2xlarge" #The solution was originally tested with the r7a.12xlarge instance type. Other instance types may work, but have not been extensively tested. i3.2xlarge is recommended for use by XRP Ledger +XRP_INSTANCE_TYPE="r7a.2xlarge" #The solution was originally tested with the r7a.12xlarge instance type. Other instance types will work, but have not been extensively tested. XRP_CPU_TYPE="x86_64" # All options: "x86_64". ARM currently not supported DATA_VOL_TYPE="gp3" # Other options: "io1" | "io2" | "gp3" | "instance-store" . IMPORTANT: Use "instance-store" option only with instance types that support that feature, like popular for node im4gn, d3, i3en, and i4i instance families DATA_VOL_SIZE="2000" # Current required data size to keep both smapshot archive and unarchived version of it From 3610a01485a70eb55185e261eee376c06d5771cc Mon Sep 17 00:00:00 2001 From: racket2000 <56010597+racket2000@users.noreply.github.com> Date: Thu, 27 Feb 2025 11:50:24 -0500 Subject: [PATCH 24/27] Update README.md Signed-off-by: racket2000 <56010597+racket2000@users.noreply.github.com> --- lib/xrp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index 14d786e9..c5e688c8 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -116,7 +116,7 @@ npx cdk deploy XRP-single-node --json --outputs-file single-node-deploy.json - Navigate to [CloudWatch service](https://console.aws.amazon.com/cloudwatch/) (make sure you are in the region you have specified for `AWS_REGION`) - Open `Dashboards` and select dashboard that starts with `XRP-single-node` from the list of dashboards. -3. Once the initial synchronization is done, you should be able to access the RPC API of that node from within the same VPC. The RPC port is not exposed to the Internet. Run the following query against the private IP of the single RPC node you deployed: +3. Once the initial synchronization is done, you should be able to access the RPC API of that node from within the same VPC. The RPC port is not exposed to the Internet. Run the following command to retrieve the private IP of the single RPC node you deployed: ```bash export INSTANCE_ID=$(cat single-node-deploy.json | jq -r '.["XRP-single-node"].nodeinstanceid') From 67628afec8a746163d279cf87946496d8d386dc4 Mon Sep 17 00:00:00 2001 From: racket2000 <56010597+racket2000@users.noreply.github.com> Date: Thu, 27 Feb 2025 11:51:39 -0500 Subject: [PATCH 25/27] removed unnecessary clean up steps Signed-off-by: racket2000 <56010597+racket2000@users.noreply.github.com> --- lib/xrp/README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/xrp/README.md b/lib/xrp/README.md index c5e688c8..2867f4c7 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -190,10 +190,6 @@ You will get a response similar to this: Destroy HA Nodes, Single Nodes and Common stacks ```bash -# Setting the AWS account id and region in case local .env file is lost - export AWS_ACCOUNT_ID= - export AWS_REGION= - pwd # Make sure you are in aws-blockchain-node-runners/lib/xrp From 85b3c8b2eaf6d3f5dc6ef7153e285a72dcd34bee Mon Sep 17 00:00:00 2001 From: Nikolay Vlasov Date: Tue, 4 Mar 2025 14:24:30 +1100 Subject: [PATCH 26/27] XRP. Added HA setup tests and modified node dashboard --- lib/xrp/README.md | 16 +- .../assets/user-data/check_xrp_sequence.sh | 47 +++- lib/xrp/lib/constructs/node-cw-dashboard.ts | 113 ++++---- lib/xrp/test/.env-test | 2 +- lib/xrp/test/ha-nodes-stack.test.ts | 244 ++++++++++++++++++ lib/xrp/test/single-node-stack.test.ts | 2 +- 6 files changed, 362 insertions(+), 62 deletions(-) create mode 100644 lib/xrp/test/ha-nodes-stack.test.ts diff --git a/lib/xrp/README.md b/lib/xrp/README.md index 2867f4c7..a08fe558 100644 --- a/lib/xrp/README.md +++ b/lib/xrp/README.md @@ -2,7 +2,7 @@ | Contributed by | |:--------------------------------:| -| Pedro Aceves
    acevespa@amazon.com | +| [Pedro Aceves](https://github.com/acevesp)| XRP node deployment on AWS. All nodes are configure as ["Stock Servers"](https://xrpl.org/docs/infrastructure/configuration/server-modes/run-rippled-as-a-stock-server) @@ -27,7 +27,7 @@ XRP node deployment on AWS. All nodes are configure as ["Stock Servers"](https:/ ## Well-Architected
    -Review the for pros and cons of this solution. +Review pros and cons of this solution. ### Well-Architected Checklist @@ -50,7 +50,7 @@ This is the Well-Architected checklist for XRP nodes implementation of the AWS B | | Resource monitoring | How are workload resources monitored? | Resources are being monitored using Amazon CloudWatch dashboards. Amazon CloudWatch custom metrics are being pushed via CloudWatch Agent. | | Performance efficiency | Compute selection | How is compute solution selected? | Compute solution is selected based on best price-performance. | | | Storage selection | How is storage solution selected? | Storage solution is selected based on best price-performance. | -| Operational excellence | Workload health | How is health of workload determined? | Health of workload is determined via AWS Application Load Balancer Target Group Health Checks, on port 8545. | +| Operational excellence | Workload health | How is health of workload determined? | Health of workload is determined via AWS Application Load Balancer Target Group Health Checks, on port 6005. | | Sustainability | Hardware & services | Select most efficient hardware for your workload | Amazon EC2 R7a instances support the Sustainability Pillar of the AWS Well-Architected Framework by offering memory optimization that enables more efficient resource utilization, potentially reducing overall energy consumption and hardware requirements for data-intensive workloads. |
    @@ -59,7 +59,7 @@ This is the Well-Architected checklist for XRP nodes implementation of the AWS B ### Open AWS CloudShell -To begin, ensure you login to your AWS account with permissions to create and modify resources in IAM, EC2, EBS, VPC, S3, KMS, and Secrets Manager. +To begin, ensure you login to your AWS account with permissions to create and modify resources in IAM, EC2, EBS, VPC, S3, and KMS. From the AWS Management Console, open the [AWS CloudShell](https://docs.aws.amazon.com/cloudshell/latest/userguide/welcome.html), a web-based shell environment. If unfamiliar, review the [2-minute YouTube video](https://youtu.be/fz4rbjRaiQM) for an overview and check out [CloudShell with VPC environment](https://docs.aws.amazon.com/cloudshell/latest/userguide/creating-vpc-environment.html) that we'll use to test nodes API from internal IP address space. @@ -93,7 +93,7 @@ cd lib/xrp cp ./sample-configs/.env-sample-testnet .env nano .env ``` -> **NOTE:** *You can find more examples inside `sample-configs` * +> **NOTE:** *You can find more examples inside `sample-configs`* 4. Deploy common components such as IAM role: @@ -194,13 +194,13 @@ pwd # Make sure you are in aws-blockchain-node-runners/lib/xrp # Destroy HA Nodes -cdk destroy XRP-ha-nodes +npx cdk destroy XRP-ha-nodes # Destroy Single Node -cdk destroy XRP-single-node +npx cdk destroy XRP-single-node # Delete all common components like IAM role and Security Group -cdk destroy XRP-common +npx cdk destroy XRP-common ``` ### FAQ diff --git a/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh b/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh index aefadb48..706bd3be 100644 --- a/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh +++ b/lib/xrp/lib/assets/user-data/check_xrp_sequence.sh @@ -26,7 +26,8 @@ set -euo pipefail MAX_RETRIES=3 RETRY_DELAY=5 NAMESPACE="CWAgent" -METRIC_NAME="XRP_Sequence" +CURRENT_METRIC_NAME="XRP_Current_Sequence" +DELTA_METRIC_NAME="XRP_Delta_Sequence" LOCKFILE="/tmp/check_xrp_sequence.lock" LOCK_FD=200 @@ -115,6 +116,29 @@ get_current_sequence() { local retry_count=0 local seq + while [[ ${retry_count} -lt ${MAX_RETRIES} ]]; do + if seq=$(curl -s -f -H 'Content-Type: application/json' \ + -d '{"method":"ledger_current","params":[{}]}' \ + http://localhost:5005/ | \ + jq -e '.result.ledger_current_index // 0'); then + if [[ "${seq}" != "0" ]]; then + echo "${seq}" + return 0 + fi + fi + log_warning "Failed to get sequence, attempt $((retry_count + 1))/${MAX_RETRIES}" + retry_count=$((retry_count + 1)) + sleep ${RETRY_DELAY} + done + + log_error "Failed to get current sequence after ${MAX_RETRIES} attempts" + return 1 +} + +get_validated_sequence() { + local retry_count=0 + local seq + while [[ ${retry_count} -lt ${MAX_RETRIES} ]]; do if seq=$(curl -s -f -H 'Content-Type: application/json' \ -d '{"method":"server_info","params":[{}]}' \ @@ -137,12 +161,13 @@ get_current_sequence() { # Function to send metric to CloudWatch with retries send_to_cloudwatch() { local sequence=$1 + local metric_name=$2 local retry_count=0 while [[ ${retry_count} -lt ${MAX_RETRIES} ]]; do if aws cloudwatch put-metric-data \ --namespace "${NAMESPACE}" \ - --metric-name "${METRIC_NAME}" \ + --metric-name "${metric_name}" \ --value "${sequence}" \ --region "${REGION}" \ --dimensions "InstanceId=${INSTANCE_ID}" \ @@ -192,14 +217,26 @@ main() { fi # Get current sequence - if ! sequence=$(get_current_sequence); then + if ! current_sequence=$(get_current_sequence); then return 1 fi - log_info "Retrieved sequence: ${sequence}" + # Get current sequence + if ! validated_sequence=$(get_validated_sequence); then + return 1 + fi + + log_info "Retrieved current sequence: ${current_sequence}" + log_info "Retrieved validated sequence: ${validated_sequence}" + + # Send to CloudWatch + if ! send_to_cloudwatch "${current_sequence}" "${CURRENT_METRIC_NAME}"; then + return 1 + fi # Send to CloudWatch - if ! send_to_cloudwatch "${sequence}"; then + delta_sequence=$((current_sequence - validated_sequence)) + if ! send_to_cloudwatch "${delta_sequence}" "${DELTA_METRIC_NAME}"; then return 1 fi diff --git a/lib/xrp/lib/constructs/node-cw-dashboard.ts b/lib/xrp/lib/constructs/node-cw-dashboard.ts index 9121eed3..959a7698 100644 --- a/lib/xrp/lib/constructs/node-cw-dashboard.ts +++ b/lib/xrp/lib/constructs/node-cw-dashboard.ts @@ -1,7 +1,7 @@ export const SingleNodeCWDashboardJSON = { "widgets": [ { - "height": 6, + "height": 4, "width": 8, "y": 0, "x": 0, @@ -18,13 +18,13 @@ export const SingleNodeCWDashboardJSON = { }, "region": "${REGION}", "metrics": [ - [ "AWS/EC2", "CPUUtilization","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + [ "AWS/EC2", "CPUUtilization", "InstanceId", "${INSTANCE_ID}", { "label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] ], "title": "CPU utilization (%)" } }, { - "height": 6, + "height": 4, "width": 8, "y": 0, "x": 8, @@ -32,9 +32,9 @@ export const SingleNodeCWDashboardJSON = { "properties": { "metrics": [ [ { "expression": "m7/PERIOD(m7)", "label": "Read", "id": "e7" } ], - [ "CWAgent", "diskio_reads","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7", "visible": false, "stat": "Sum", "period": 60 } ], + [ "CWAgent", "diskio_reads", "InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7", "visible": false, "stat": "Sum", "period": 60 } ], [ { "expression": "m8/PERIOD(m8)", "label": "Write", "id": "e8" } ], - [ "CWAgent", "diskio_writes","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m8", "visible": false, "stat": "Sum", "period": 60 } ] + [ "CWAgent", "diskio_writes", "InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m8", "visible": false, "stat": "Sum", "period": 60 } ] ], "view": "timeSeries", "stacked": false, @@ -45,31 +45,32 @@ export const SingleNodeCWDashboardJSON = { } }, { - "height": 6, + "height": 4, "width": 8, "y": 0, "x": 16, "type": "metric", "properties": { + "metrics": [ + [ "CWAgent", "XRP_Current_Sequence", "InstanceId", "${INSTANCE_ID}", { "label": "${INSTANCE_ID}-${INSTANCE_NAME}", "region": "${REGION}" } ] + ], "sparkline": false, - "view": "singleValue", + "view": "timeSeries", "region": "${REGION}", "stacked": false, "singleValueFullPrecision": true, "liveData": true, "setPeriodToTimeRange": false, "trend": true, - "metrics": [ - [ "CWAgent", "XRP_Sequence","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] - ], - "title": "XRP Sequence" + "title": "XRP Current Sequence", + "period": 300 } }, { - "height": 6, + "height": 4, "width": 8, - "y": 6, - "x": 0, + "y": 12, + "x": 16, "type": "metric", "properties": { "view": "timeSeries", @@ -83,16 +84,16 @@ export const SingleNodeCWDashboardJSON = { }, "region": "${REGION}", "metrics": [ - [ "AWS/EC2", "NetworkIn","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + [ "AWS/EC2", "NetworkIn", "InstanceId", "${INSTANCE_ID}", { "label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] ], "title": "Network in (bytes)" } }, { - "height": 6, + "height": 4, "width": 8, - "y": 6, - "x": 8, + "y": 4, + "x": 0, "type": "metric", "properties": { "view": "timeSeries", @@ -101,16 +102,16 @@ export const SingleNodeCWDashboardJSON = { "stat": "Average", "period": 300, "metrics": [ - [ "CWAgent", "cpu_usage_iowait","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + [ "CWAgent", "cpu_usage_iowait", "InstanceId", "${INSTANCE_ID}", { "label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] ], "title": "CPU Usage IO wait (%)" } }, { - "height": 6, + "height": 4, "width": 8, - "y": 6, - "x": 16, + "y": 4, + "x": 8, "type": "metric", "properties": { "view": "timeSeries", @@ -125,20 +126,20 @@ export const SingleNodeCWDashboardJSON = { "region": "${REGION}", "metrics": [ [ { "expression": "IF(m7_2 !=0, (m7_1 / m7_2), 0)", "label": "Read", "id": "e7" } ], - [ "CWAgent", "diskio_read_time","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_1", "visible": false, "stat": "Sum", "period": 60 } ], - [ "CWAgent", "diskio_reads","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_2", "visible": false, "stat": "Sum", "period": 60 } ], + [ "CWAgent", "diskio_read_time", "InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_1", "visible": false, "stat": "Sum", "period": 60 } ], + [ "CWAgent", "diskio_reads", "InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_2", "visible": false, "stat": "Sum", "period": 60 } ], [ { "expression": "IF(m7_4 !=0, (m7_3 / m7_4), 0)", "label": "Write", "id": "e8" } ], - [ "CWAgent", "diskio_write_time","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_3", "visible": false, "stat": "Sum", "period": 60 } ], - [ "CWAgent", "diskio_writes","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_4", "visible": false, "stat": "Sum", "period": 60 } ] + [ "CWAgent", "diskio_write_time", "InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_3", "visible": false, "stat": "Sum", "period": 60 } ], + [ "CWAgent", "diskio_writes", "InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m7_4", "visible": false, "stat": "Sum", "period": 60 } ] ], "title": "nvme1n1 Volume Read/Write latency (ms/op)" } }, { - "height": 6, + "height": 4, "width": 8, - "y": 12, - "x": 0, + "y": 8, + "x": 16, "type": "metric", "properties": { "view": "timeSeries", @@ -152,16 +153,16 @@ export const SingleNodeCWDashboardJSON = { }, "region": "${REGION}", "metrics": [ - [ "AWS/EC2", "NetworkOut","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + [ "AWS/EC2", "NetworkOut", "InstanceId", "${INSTANCE_ID}", { "label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] ], "title": "Network out (bytes)" } }, { - "height": 6, + "height": 4, "width": 8, - "y": 12, - "x": 8, + "y": 8, + "x": 0, "type": "metric", "properties": { "view": "timeSeries", @@ -170,23 +171,23 @@ export const SingleNodeCWDashboardJSON = { "stat": "Average", "period": 300, "metrics": [ - [ "CWAgent", "mem_used_percent","InstanceId", "${INSTANCE_ID}", {"label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + [ "CWAgent", "mem_used_percent", "InstanceId", "${INSTANCE_ID}", { "label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] ], "title": "Mem Used (%)" } }, { - "height": 6, + "height": 4, "width": 8, - "y": 12, - "x": 16, + "y": 8, + "x": 8, "type": "metric", "properties": { "metrics": [ - [ { "expression": "m2/PERIOD(m2)", "label": "Read", "id": "e2", "period": 60, "region": "us-east-1" } ], - [ "CWAgent", "diskio_read_bytes","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m2", "stat": "Sum", "visible": false, "period": 60 } ], - [ { "expression": "m3/PERIOD(m3)", "label": "Write", "id": "e3", "period": 60, "region": "us-east-1" } ], - [ "CWAgent", "diskio_write_bytes","InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m3", "stat": "Sum", "visible": false, "period": 60 } ] + [ { "expression": "m2/PERIOD(m2)", "label": "Read", "id": "e2", "period": 60, "region": "${REGION}" } ], + [ "CWAgent", "diskio_read_bytes", "InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m2", "stat": "Sum", "visible": false, "period": 60 } ], + [ { "expression": "m3/PERIOD(m3)", "label": "Write", "id": "e3", "period": 60, "region": "${REGION}" } ], + [ "CWAgent", "diskio_write_bytes", "InstanceId", "${INSTANCE_ID}", "name", "nvme1n1", { "id": "m3", "stat": "Sum", "visible": false, "period": 60 } ] ], "view": "timeSeries", "stacked": false, @@ -197,22 +198,40 @@ export const SingleNodeCWDashboardJSON = { } }, { - "height": 6, + "height": 4, "width": 8, - "y": 18, - "x": 0, + "y": 12, + "x": 8, "type": "metric", "properties": { "metrics": [ - [ "CWAgent", "disk_used_percent","InstanceId", "${INSTANCE_ID}", "device", "nvme1n1", "path", "/var/lib/rippled", "fstype", "xfs", { "region": "${REGION}", "label": "/var/lib/rippled" } ] + [ "CWAgent", "disk_used_percent", "InstanceId", "${INSTANCE_ID}", "device", "nvme1n1", "path", "/var/lib/rippled", "fstype", "xfs", { "region": "${REGION}", "label": "/var/lib/rippled" } ] ], "sparkline": true, "view": "singleValue", "region": "${REGION}", "title": "nvme1n1 Disk Used (%)", "period": 60, - "stat": "Average" + "stat": "Maximum" + } + }, + { + "type": "metric", + "x": 16, + "y": 4, + "width": 8, + "height": 4, + "properties": { + "metrics": [ + [ "CWAgent", "XRP_Delta_Sequence", "InstanceId", "${INSTANCE_ID}", { "region": "${REGION}", "label": "XRP Current - Validated Sequence" } ] + ], + "view": "timeSeries", + "stacked": false, + "region": "${REGION}", + "period": 300, + "stat": "Maximum", + "title": "XRP Current - Validated Sequence" } } ] -} +} \ No newline at end of file diff --git a/lib/xrp/test/.env-test b/lib/xrp/test/.env-test index 7a1d1c90..15a3928c 100644 --- a/lib/xrp/test/.env-test +++ b/lib/xrp/test/.env-test @@ -1,6 +1,6 @@ AWS_ACCOUNT_ID="xxxxxxxxxxx" AWS_REGION="xxxxxxxxxx" -XRP_INSTANCE_TYPE="r7a.12xlarge" +XRP_INSTANCE_TYPE="r7a.2xlarge" XRP_CPU_TYPE="x86_64" # All options: "x86_64". ARM currently not supported DATA_VOL_TYPE="gp3" # Other options: "io1" | "io2" | "gp3" | "instance-store" . IMPORTANT: Use "instance-store" option only with instance types that support that feature, like popular for node im4gn, d3, i3en, and i4i instance families DATA_VOL_SIZE="2000" # Current required data size to keep both smapshot archive and unarchived version of it diff --git a/lib/xrp/test/ha-nodes-stack.test.ts b/lib/xrp/test/ha-nodes-stack.test.ts new file mode 100644 index 00000000..0703a790 --- /dev/null +++ b/lib/xrp/test/ha-nodes-stack.test.ts @@ -0,0 +1,244 @@ +import { Match, Template } from "aws-cdk-lib/assertions"; +import * as cdk from "aws-cdk-lib"; +import * as dotenv from 'dotenv'; +dotenv.config({ path: './test/.env-test' }); +import * as config from "../lib/config/XRPConfig"; +import { XRPCommonStack } from "../lib/common-stack"; +import { XRPHANodesStack } from "../lib/ha-nodes-stack"; + +describe("XRPHANodesStackProps", () => { + test("synthesizes the way we expect", () => { + const app = new cdk.App(); + + const xrpCommonStack = new XRPCommonStack(app, "xrp-common", { + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + stackName: `xrp-nodes-common`, + }); + + // Create the XRPHANodesStackProps. + const xRPHANodesStack = new XRPHANodesStack(app, "XRP-ha-nodes", { + stackName: "xrp-ha-nodes", + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + instanceType: config.baseNodeConfig.instanceType, + instanceCpuType: config.baseNodeConfig.instanceCpuType, + dataVolume: config.baseNodeConfig.dataVolume, + hubNetworkID: config.baseNodeConfig.hubNetworkID, + instanceRole: xrpCommonStack.instanceRole, + albHealthCheckGracePeriodMin: config.haNodeConfig.albHealthCheckGracePeriodMin, + heartBeatDelayMin: config.haNodeConfig.heartBeatDelayMin, + numberOfNodes: config.haNodeConfig.numberOfNodes, + }); + + // Prepare the stack for assertions. + const template = Template.fromStack(xRPHANodesStack); + + // Has EC2 instance security group. + template.hasResourceProperties("AWS::EC2::SecurityGroup", { + GroupDescription: Match.anyValue(), + VpcId: Match.anyValue(), + SecurityGroupEgress: [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + SecurityGroupIngress: [ + { + "CidrIp": "0.0.0.0/0", + "Description": "P2P protocols", + "FromPort": 51235, + "IpProtocol": "tcp", + "ToPort": 51235 + }, + { + "CidrIp": "0.0.0.0/0", + "Description": "P2P protocols", + "FromPort": 2459, + "IpProtocol": "tcp", + "ToPort": 2459 + }, + { + "CidrIp": "1.2.3.4/5", + "Description": "RPC port HTTP (user access needs to be restricted. Allowed access only from internal IPs)", + "FromPort": 6005, + "IpProtocol": "tcp", + "ToPort": 6005 + }, + { + "Description": "Allow access from ALB to Blockchain Node", + "FromPort": 0, + "IpProtocol": "tcp", + "SourceSecurityGroupId": Match.anyValue(), + "ToPort": 65535 + }, + ] + }) + + // Has security group from ALB to EC2. + template.hasResourceProperties("AWS::EC2::SecurityGroupIngress", { + Description: Match.anyValue(), + FromPort: 6005, + GroupId: Match.anyValue(), + IpProtocol: "tcp", + SourceSecurityGroupId: Match.anyValue(), + ToPort: 6005, + }) + + // Has launch template profile for EC2 instances. + template.hasResourceProperties("AWS::IAM::InstanceProfile", { + Roles: [Match.anyValue()] + }); + + // Has EC2 launch template. + template.hasResourceProperties("AWS::EC2::LaunchTemplate", { + LaunchTemplateData: { + BlockDeviceMappings: [ + { + "DeviceName": "/dev/xvda", + "Ebs": { + "DeleteOnTermination": true, + "Encrypted": true, + "Iops": 3000, + "Throughput": 125, + "VolumeSize": 46, + "VolumeType": "gp3" + } + }, + { + "DeviceName": "/dev/sdf", + "Ebs": { + "DeleteOnTermination": true, + "Encrypted": true, + "Iops": 12000, + "Throughput": 700, + "VolumeSize": 2000, + "VolumeType": "gp3" + } + } + ], + EbsOptimized: true, + IamInstanceProfile: Match.anyValue(), + ImageId: Match.anyValue(), + InstanceType:"r7a.2xlarge", + SecurityGroupIds: [Match.anyValue()], + UserData: Match.anyValue(), + TagSpecifications: Match.anyValue(), + } + }) + + // Has Auto Scaling Group. + template.hasResourceProperties("AWS::AutoScaling::AutoScalingGroup", { + AutoScalingGroupName: `xrp-ha-nodes`, + HealthCheckGracePeriod: config.haNodeConfig.albHealthCheckGracePeriodMin * 60, + HealthCheckType: "ELB", + DefaultInstanceWarmup: 60, + MinSize: "0", + MaxSize: "4", + DesiredCapacity: config.haNodeConfig.numberOfNodes.toString(), + VPCZoneIdentifier: Match.anyValue(), + TargetGroupARNs: Match.anyValue(), + }); + + // Has Auto Scaling Lifecycle Hook. + template.hasResourceProperties("AWS::AutoScaling::LifecycleHook", { + DefaultResult: "ABANDON", + HeartbeatTimeout: config.haNodeConfig.heartBeatDelayMin * 60, + LifecycleHookName: `xrp-ha-nodes`, + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + }); + + // Has Auto Scaling Security Group. + template.hasResourceProperties("AWS::EC2::SecurityGroup", { + GroupDescription: "Security Group for Load Balancer", + SecurityGroupEgress: [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + SecurityGroupIngress: [ + { + "CidrIp": "1.2.3.4/5", + "Description": "Blockchain Node RPC", + "FromPort": 6005, + "IpProtocol": "tcp", + "ToPort": 6005 + } + ], + VpcId: Match.anyValue(), + }); + + // Has ALB. + template.hasResourceProperties("AWS::ElasticLoadBalancingV2::LoadBalancer", { + LoadBalancerAttributes: [ + { + Key: "deletion_protection.enabled", + Value: "false" + }, + { + Key: "access_logs.s3.enabled", + Value: "true" + }, + { + Key: "access_logs.s3.bucket", + Value: Match.anyValue(), + }, + { + Key: "access_logs.s3.prefix", + Value: `xrp-ha-nodes` + } + ], + Scheme: "internal", + SecurityGroups: [ + Match.anyValue() + ], + "Subnets": [ + Match.anyValue(), + Match.anyValue() + ], + Type: "application", + }); + + // Has ALB listener. + template.hasResourceProperties("AWS::ElasticLoadBalancingV2::Listener", { + "DefaultActions": [ + { + "TargetGroupArn": Match.anyValue(), + Type: "forward" + } + ], + LoadBalancerArn: Match.anyValue(), + Port: 6005, + Protocol: "HTTP" + }) + + // Has ALB target group. + template.hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { + HealthCheckEnabled: true, + HealthCheckIntervalSeconds: 30, + HealthCheckPath: "/", + HealthCheckPort: "6005", + HealthyThresholdCount: 3, + Matcher: { + HttpCode: "200-299" + }, + Port: 6005, + Protocol: "HTTP", + TargetGroupAttributes: [ + { + Key: "deregistration_delay.timeout_seconds", + Value: "30" + }, + { + Key: "stickiness.enabled", + Value: "false" + } + ], + TargetType: "instance", + UnhealthyThresholdCount: 2, + VpcId: Match.anyValue(), + }) + }); +}); diff --git a/lib/xrp/test/single-node-stack.test.ts b/lib/xrp/test/single-node-stack.test.ts index 69ca6cdc..311e556e 100644 --- a/lib/xrp/test/single-node-stack.test.ts +++ b/lib/xrp/test/single-node-stack.test.ts @@ -83,7 +83,7 @@ describe("XRPSingleNodeStack", () => { ], IamInstanceProfile: Match.anyValue(), ImageId: Match.anyValue(), - InstanceType: "r7a.12xlarge", + InstanceType: "r7a.2xlarge", Monitoring: true, PropagateTagsToVolumeOnCreation: true, SecurityGroupIds: Match.anyValue(), From a7dfc6f146d2dee932a74b32a1efeb2c527e71c9 Mon Sep 17 00:00:00 2001 From: Nikolay Vlasov Date: Tue, 4 Mar 2025 14:58:42 +1100 Subject: [PATCH 27/27] XRP. Fixed problems with precommit --- lib/xrp/lib/constructs/node-cw-dashboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/xrp/lib/constructs/node-cw-dashboard.ts b/lib/xrp/lib/constructs/node-cw-dashboard.ts index 959a7698..ee1dad10 100644 --- a/lib/xrp/lib/constructs/node-cw-dashboard.ts +++ b/lib/xrp/lib/constructs/node-cw-dashboard.ts @@ -234,4 +234,4 @@ export const SingleNodeCWDashboardJSON = { } } ] -} \ No newline at end of file +}