diff --git a/lib/tron/.gitignore b/lib/tron/.gitignore new file mode 100644 index 00000000..f60797b6 --- /dev/null +++ b/lib/tron/.gitignore @@ -0,0 +1,8 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/lib/tron/README.md b/lib/tron/README.md new file mode 100644 index 00000000..ed37143a --- /dev/null +++ b/lib/tron/README.md @@ -0,0 +1,185 @@ +# Sample AWS Blockchain Node Runner app for TRON Nodes + +| Contributed by | +|:--------------:| +| [@snese](https://github.com/snese) | + +[TRON](https://tron.network/) is a high-throughput, EVM-compatible (via the TRON Virtual Machine, TVM) Layer-1 blockchain using Delegated Proof of Stake (DPoS). Nodes run the [java-tron](https://github.com/tronprotocol/java-tron) client. + +This blueprint deploys a single node or a Highly Available (HA) set of TRON RPC nodes on AWS. It is intended for development, testing, or Proof of Concept purposes. + +## Overview of Deployment Architectures + +### Single Node setup + +![Single Node Deployment](./doc/assets/Architecture-Single-TRON-Node-Runners.drawio.png) + +1. The AWS CDK deploys a single node, storing scripts and config in an S3 bucket copied to the EC2 instance at launch. +2. A single TRON RPC FullNode (or Lite FullNode) is deployed in the [Default VPC](https://docs.aws.amazon.com/vpc/latest/userguide/default-vpc.html) and synchronizes with the TRON network through an [Internet Gateway](https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Internet_Gateway.html). The P2P port (18888) is open to the Internet for peer discovery. +3. The node is accessed by dApps or development tools internally over HTTP (8090) and gRPC (50051). The RPC APIs are **not** exposed to the Internet. +4. The node sends EC2 and TRON client metrics to Amazon CloudWatch. + +### Highly Available setup + +![Highly Available Deployment](./doc/assets/Architecture-HA-TRON-Node-Runners.drawio.png) + +1. The CDK deploys nodes in an [Auto Scaling Group](https://docs.aws.amazon.com/autoscaling/ec2/userguide/auto-scaling-groups.html) behind an internal [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). +2. The ALB health check targets `GET /wallet/getnowblock` on port 8090 (java-tron returns HTTP 200 on this endpoint; `/` returns 404). +3. RPC APIs are reachable only from within the VPC through the ALB. +4. Nodes publish EC2 and TRON metrics to CloudWatch. + +## Node Types + +| Type | `TRON_NODE_CONFIGURATION` | Data size (mainnet) | Use case | +|------|---------------------------|---------------------|----------| +| Lite FullNode | `lite` | ~53 GB snapshot (state + latest 65536 blocks) | Follow chain, send/broadcast tx, query post-startup data. Fast start, low cost. | +| FullNode | `full` | ~2.9–3 TB snapshot (complete history) | Full historical queries, dApp/exchange backend. | + +The same `FullNode.jar` runs both; the difference is the snapshot the node starts from. Lite nodes set `openHistoryQueryWhenLiteFN = true` so history APIs work for blocks synced after startup. + +## Snapshot Acceleration + +Syncing from genesis is slow, so the blueprint bootstraps the database from a snapshot. Set `TRON_SNAPSHOT_TYPE`: + +| `TRON_SNAPSHOT_TYPE` | Source | Notes | +|----------------------|--------|-------| +| `none` | Sync from genesis | Slowest. | +| `public` | TRON's official snapshot host | Lite uses `aria2c` multi-connection download; Full streams `wget \| tar` (4 TB volume) or uses `aria2c` when the volume has room. Decompresses with `pigz`. | +| `s3` | Your own private S3 staging bucket (`s5cmd` + `zstd`) | Fastest and most repeatable. Requires deploying the snapshot node first to populate the bucket (`tron-snapshots--`, created by `tron-common`). | + +For a FullNode used repeatedly, `s3` is recommended: the snapshot node pays the slow public download once and re-stages the data as multithreaded `zstd`, so every other node restores in-region at transfer-bound speed. This mirrors the ethereum/tezos S3 sync-node and BSC/base zstd patterns in this repo. + +### [OPTIONAL] Deploy the snapshot node (only for `TRON_SNAPSHOT_TYPE=s3`) + +The snapshot node syncs from the public source and uploads its database to the private S3 bucket daily (cron), so RPC/single nodes can restore quickly. + +```bash +npx cdk deploy tron-snapshot-node --json --outputs-file snapshot-node-deploy.json +``` + +Once it has synced and uploaded at least once (check `/tmp/upload-snapshot.log` on the instance), deploy your RPC/single/HA nodes with `TRON_SNAPSHOT_TYPE=s3`. To remove it: `cdk destroy tron-snapshot-node`, then empty and delete the S3 bucket. + +## java-tron on AWS Graviton (ARM64) + +This blueprint defaults to AWS Graviton (ARM64) for price-performance. java-tron on ARM64 has specific requirements that the blueprint handles automatically: + +- **JDK**: ARM64 uses Amazon Corretto 17 (x86_64 uses Corretto 8). java-tron is not validated on newer JDKs. +- **Garbage collector**: TRON's documented `-XX:+UseConcMarkSweepGC` (CMS) was removed in JDK 14+. On ARM64/JDK17 the blueprint uses G1GC; on x86_64/JDK8 it uses CMS. +- **Client jar**: the generic `FullNode.jar` bundles an x86_64-only RocksDB native library and fails on ARM64 with `UnsatisfiedLinkError`. The blueprint downloads `FullNode-aarch64.jar` on ARM64 and `FullNode.jar` on x86_64. +- **Storage engine**: ARM64 supports RocksDB only (`db.engine = ROCKSDB`); the snapshot source must match (the blueprint uses the official RocksDB snapshot). + +## Well-Architected Checklist + +Well-Architected checklist for the TRON node implementation, based on the [AWS Well-Architected Framework](https://aws.amazon.com/architecture/well-architected/). + +| Pillar | Control | Question/Check | Remarks | +|:-------|:--------|:---------------|:--------| +| Security | Network protection | Are there unnecessary open ports in security groups? | Only TRON P2P port 18888 (TCP/UDP) is open to the public, as required by the P2P protocol. RPC ports 8090 (HTTP) and 50051 (gRPC) are restricted to the VPC CIDR. | +| | Traffic inspection | Traffic protection | WAF/Shield are not used. Consider [AWS WAF](https://docs.aws.amazon.com/waf/latest/developerguide/waf-chapter.html) and [AWS Shield](https://docs.aws.amazon.com/waf/latest/developerguide/shield-chapter.html) if exposing RPC publicly (additional cost). | +| | Compute protection | Reduce attack surface | Uses the Amazon Linux 2 AMI; apply hardening as needed. | +| | | Actions at a distance | Uses [AWS Systems Manager Session Manager](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager.html), not SSH ports. | +| | Data protection at rest | Encrypted EBS volumes | All EBS volumes are encrypted. | +| | Data protection in transit | Use TLS | The ALB uses an HTTP listener. Add an HTTPS listener with a certificate if TLS is required. | +| | Authorization & access control | Use instance profile | Uses an IAM role (not IAM user) via instance profile. | +| | | Least privilege | Runs the node as a non-root user (`bcuser`). | +| | Application security | Secure development | cdk-nag with documented suppressions. Snapshot and client-jar downloads use the official TRON snapshot host and tronprotocol GitHub releases; the snapshot host is plain HTTP (override via `TRON_SNAPSHOTS_URI`) and integrity caveats are noted inline in the scripts. | +| Cost optimization | Service selection | Cost effective resources | Graviton instances with the `aarch64` client jar; Lite uses `m7g.2xlarge`, Full uses `m7g.4xlarge`; gp3 EBS. | +| | Cost awareness | Estimate costs | Lite and Full differ significantly. Use the [AWS Pricing Calculator](https://calculator.aws/#/). | +| Reliability | Resiliency | Withstand component failures | The HA setup uses an ALB with an Auto Scaling Group across AZs. | +| | Data backup | How is data backed up? | TRON data is replicated by the network; nodes restore from snapshots, so no extra backup is used. | +| | Resource monitoring | How are resources monitored? | CloudWatch dashboards and custom metrics (block height, blocks behind) via the CloudWatch Agent. | +| Performance efficiency | Compute selection | How is compute selected? | AWS Graviton (ARM64) with the matching `aarch64` jar and Corretto 17. | +| | Storage selection | How is storage selected? | gp3 EBS with tuned IOPS/throughput; 4 TB for FullNode, 600 GB for Lite. | +| | Architecture selection | Best performance architecture | Based on TRON's recommended configuration plus testing on AWS. | +| Operational excellence | Workload health | How is health determined? | ALB Target Group health checks on `GET /wallet/getnowblock` (port 8090); CloudWatch sync metrics. | +| Sustainability | Hardware & services | Efficient hardware | AWS Graviton offers strong performance per watt in Amazon EC2. | + +## Recommended Infrastructure + +| Usage pattern | Ideal configuration | Primary AWS option | Config reference | +|---------------|---------------------|--------------------|------------------| +| Lite FullNode | 8 vCPU, 32 GB RAM, 600 GB gp3 | `m7g.2xlarge` | [.env-sample-lite](./sample-configs/.env-sample-lite) | +| FullNode | 16 vCPU, 64 GB RAM, 4 TB gp3 (10K IOPS, 700 MB/s) | `m7g.4xlarge` | [.env-sample-full](./sample-configs/.env-sample-full) | + +## Setup Instructions + +### Open AWS CloudShell + +Log in to your AWS account with permissions to create/modify IAM, EC2, EBS, VPC, S3, and KMS resources, then open [AWS CloudShell](https://docs.aws.amazon.com/cloudshell/latest/userguide/welcome.html). + +### 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 the CDK app + +```bash +cd lib/tron +# For a Lite FullNode: +cp ./sample-configs/.env-sample-lite .env +# For a FullNode: +# cp ./sample-configs/.env-sample-full .env +nano .env # set AWS_ACCOUNT_ID, AWS_REGION, and optionally TRON_SNAPSHOTS_URL +``` + +> If you have deleted the default VPC, create one with `aws ec2 create-default-vpc`. +> If your account/region is not bootstrapped: `cdk bootstrap aws://ACCOUNT-NUMBER/REGION`. + +### Deploy common components (IAM role + snapshot bucket) + +```bash +npx cdk deploy tron-common +``` + +### Option 1: Single RPC Node + +```bash +npx cdk deploy tron-single-node --json --outputs-file single-node-deploy.json +``` + +A Lite node initializes in roughly 20–30 minutes (53 GB snapshot); a FullNode downloads ~3 TB and takes several hours. Track progress in the CloudWatch dashboard `tron-single-node---`, or query the node from within the VPC: + +```bash +INSTANCE_ID=$(cat single-node-deploy.json | jq -r '..|.singleinstanceid? | select(. != null)') +NODE_INTERNAL_IP=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID --query 'Reservations[*].Instances[*].PrivateIpAddress' --output text) +curl -s -X POST http://$NODE_INTERNAL_IP:8090/wallet/getnowblock +``` + +The response includes `block_header.raw_data.number` (current block height). Compare it to [Tronscan](https://tronscan.org/) to confirm the node is syncing. + +### Option 2: Highly Available RPC Nodes + +```bash +npx cdk deploy tron-ha-nodes --json --outputs-file ha-nodes-deploy.json + +RPC_ALB_URL=$(cat ha-nodes-deploy.json | jq -r '..|.alburl? | select(. != null)') +curl -s -X POST http://$RPC_ALB_URL:8090/wallet/getnowblock +``` + +> The ALB is internal (not Internet-facing). Protect your RPC APIs before any public exposure. + +### Clearing up + +```bash +export AWS_ACCOUNT_ID= +export AWS_REGION= +cd lib/tron +cdk destroy tron-single-node +cdk destroy tron-ha-nodes +cdk destroy tron-common +``` + +## FAQ + +1. Check the node logs: `aws ssm start-session --target --region $AWS_REGION` then `sudo tail -f /home/bcuser/tron/logs/tron.log` +2. Check the user-data logs: `sudo cat /var/log/cloud-init-output.log` +3. Restart the service: `sudo systemctl restart tron && sudo systemctl status tron` +4. TRON RPC API reference: [HTTP API](https://developers.tron.network/reference/full-node-api-overview) and [gRPC](https://developers.tron.network/docs/trons-grpc-calls). + +## Upgrades + +When the client needs upgrading, use a blue/green pattern. This is not yet automated and contributions are welcome. diff --git a/lib/tron/app.ts b/lib/tron/app.ts new file mode 100644 index 00000000..95d10fb2 --- /dev/null +++ b/lib/tron/app.ts @@ -0,0 +1,46 @@ +import 'dotenv/config' +import "source-map-support/register"; +import * as cdk from "aws-cdk-lib"; +import * as config from "./lib/config/tronConfig"; +import { TronCommonStack } from "./lib/common-stack"; +import { TronSingleNodeStack } from "./lib/single-node-stack"; +import { TronHANodesStack } from "./lib/ha-nodes-stack"; +import { TronSnapshotNodeStack } from "./lib/snapshot-node-stack"; +import * as nag from "cdk-nag"; + +const app = new cdk.App(); +cdk.Tags.of(app).add("Project", "AWS_TRON"); + +new TronCommonStack(app, "tron-common", { + stackName: `tron-nodes-common`, + env: { account: config.baseConfig.accountId, region: config.baseConfig.region } +}); + +new TronSingleNodeStack(app, "tron-single-node", { + stackName: `tron-single-node-${config.baseNodeConfig.nodeConfiguration}-${config.baseNodeConfig.tronNetwork}`, + + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + ...config.baseNodeConfig +}); + +new TronSnapshotNodeStack(app, "tron-snapshot-node", { + stackName: `tron-snapshot-node-${config.baseNodeConfig.nodeConfiguration}-${config.baseNodeConfig.tronNetwork}`, + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + ...config.baseNodeConfig +}); + +new TronHANodesStack(app, "tron-ha-nodes", { + stackName: `tron-ha-nodes-${config.baseNodeConfig.nodeConfiguration}-${config.baseNodeConfig.tronNetwork}`, + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + ...config.baseNodeConfig, + ...config.haNodeConfig +}); + +// Security Check +cdk.Aspects.of(app).add( + new nag.AwsSolutionsChecks({ + verbose: false, + reports: true, + logIgnores: false + }) +); diff --git a/lib/tron/cdk.json b/lib/tron/cdk.json new file mode 100644 index 00000000..48091fb4 --- /dev/null +++ b/lib/tron/cdk.json @@ -0,0 +1,57 @@ +{ + "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-sutronriptions: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, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true + } +} diff --git a/lib/tron/doc/assets/Architecture-HA-TRON-Node-Runners.drawio b/lib/tron/doc/assets/Architecture-HA-TRON-Node-Runners.drawio new file mode 100644 index 00000000..9d27fabb --- /dev/null +++ b/lib/tron/doc/assets/Architecture-HA-TRON-Node-Runners.drawio @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/tron/doc/assets/Architecture-HA-TRON-Node-Runners.drawio.png b/lib/tron/doc/assets/Architecture-HA-TRON-Node-Runners.drawio.png new file mode 100644 index 00000000..47e475c0 Binary files /dev/null and b/lib/tron/doc/assets/Architecture-HA-TRON-Node-Runners.drawio.png differ diff --git a/lib/tron/doc/assets/Architecture-Single-TRON-Node-Runners.drawio b/lib/tron/doc/assets/Architecture-Single-TRON-Node-Runners.drawio new file mode 100644 index 00000000..5590f16d --- /dev/null +++ b/lib/tron/doc/assets/Architecture-Single-TRON-Node-Runners.drawio @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/tron/doc/assets/Architecture-Single-TRON-Node-Runners.drawio.png b/lib/tron/doc/assets/Architecture-Single-TRON-Node-Runners.drawio.png new file mode 100644 index 00000000..ccacb27c Binary files /dev/null and b/lib/tron/doc/assets/Architecture-Single-TRON-Node-Runners.drawio.png differ diff --git a/lib/tron/jest.config.js b/lib/tron/jest.config.js new file mode 100644 index 00000000..08263b89 --- /dev/null +++ b/lib/tron/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; diff --git a/lib/tron/lib/assets/cfn-hup/cfn-auto-reloader.conf b/lib/tron/lib/assets/cfn-hup/cfn-auto-reloader.conf new file mode 100644 index 00000000..3cd32a0a --- /dev/null +++ b/lib/tron/lib/assets/cfn-hup/cfn-auto-reloader.conf @@ -0,0 +1,4 @@ +[cfn-auto-reloader-hook] +triggers=post.update +path=Resources.WebServerHost.Metadata.AWS::CloudFormation::Init +action=/opt/aws/bin/cfn-init -v --stack __AWS_STACK_NAME__ --resource WebServerHost --region __AWS_REGION__ diff --git a/lib/tron/lib/assets/cfn-hup/cfn-hup.conf b/lib/tron/lib/assets/cfn-hup/cfn-hup.conf new file mode 100644 index 00000000..2163b37a --- /dev/null +++ b/lib/tron/lib/assets/cfn-hup/cfn-hup.conf @@ -0,0 +1,5 @@ +[main] +stack=__AWS_STACK_ID__ +region=__AWS_REGION__ +# The interval used to check for changes to the resource metadata in minutes. Default is 15 +interval=2 diff --git a/lib/tron/lib/assets/cfn-hup/cfn-hup.service b/lib/tron/lib/assets/cfn-hup/cfn-hup.service new file mode 100644 index 00000000..2660ea46 --- /dev/null +++ b/lib/tron/lib/assets/cfn-hup/cfn-hup.service @@ -0,0 +1,8 @@ +[Unit] +Description=cfn-hup daemon +[Service] +Type=simple +ExecStart=/usr/local/bin/cfn-hup +Restart=always +[Install] +WantedBy=multi-user.target diff --git a/lib/tron/lib/assets/cw-agent.json b/lib/tron/lib/assets/cw-agent.json new file mode 100644 index 00000000..28833017 --- /dev/null +++ b/lib/tron/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/tron/lib/assets/download-snapshot.sh b/lib/tron/lib/assets/download-snapshot.sh new file mode 100644 index 00000000..c2437df3 --- /dev/null +++ b/lib/tron/lib/assets/download-snapshot.sh @@ -0,0 +1,119 @@ +#!/bin/bash +set +e + +# Bootstrap the TRON database from a snapshot. Three modes via TRON_SNAPSHOT_TYPE: +# none - skip (sync from genesis) +# public - download from TRON's official public snapshot host +# * lite node: aria2c multi-connection (fast) then extract (needs ~2x data disk) +# * full node: streamed wget|tar (disk-safe for ~3TB on a 4TB volume) +# s3 - restore from your own private S3 staging bucket via s5cmd + zstd (streamed) +# (fast + parallel + no double-disk; populate it with the snapshot node first) +# +# Inputs (env): TRON_SNAPSHOT_TYPE, TRON_NETWORK, TRON_NODE_TYPE, TRON_DB_ENGINE, +# TRON_SNAPSHOTS_URL (public override), TRON_SNAPSHOT_S3_BUCKET (s3 mode) + +source /etc/environment 2>/dev/null + +DATA_DIR=/data +cd "$DATA_DIR" || exit 1 + +TYPE="${TRON_SNAPSHOT_TYPE:-public}" + +if [ "$TYPE" == "none" ]; then + echo "TRON_SNAPSHOT_TYPE=none. Skipping snapshot; node will sync from genesis." + exit 0 +fi + +if [ "$TRON_NODE_TYPE" == "lite" ]; then + SNAP_FILE="LiteFullNode_output-directory.tgz" +else + SNAP_FILE="FullNode_output-directory.tgz" +fi + +# --------------------------------------------------------------------------- +# S3 mode: restore from the private staging bucket (streamed, no double disk) +# --------------------------------------------------------------------------- +if [ "$TYPE" == "s3" ]; then + if [ -z "$TRON_SNAPSHOT_S3_BUCKET" ] || [ "$TRON_SNAPSHOT_S3_BUCKET" == "none" ]; then + echo "TRON_SNAPSHOT_TYPE=s3 but TRON_SNAPSHOT_S3_BUCKET is not set. Skipping; node will sync from genesis." + echo "Deploy the snapshot node first to populate the bucket." + exit 0 + fi + if [ "$TRON_NODE_TYPE" == "lite" ]; then + S3_FILE="LiteFullNode_output-directory.tar.zst" + else + S3_FILE="FullNode_output-directory.tar.zst" + fi + S3_KEY="${TRON_NETWORK}/${TRON_NODE_TYPE}/${S3_FILE}" + echo "Restoring snapshot from s3://${TRON_SNAPSHOT_S3_BUCKET}/${S3_KEY} via s5cmd + zstd (streamed)" + s5cmd cat "s3://${TRON_SNAPSHOT_S3_BUCKET}/${S3_KEY}" | zstd -dc -T0 | tar xf - -C "$DATA_DIR" + status=${PIPESTATUS[0]} + if [ "$status" -ne 0 ]; then + echo "WARNING: S3 restore failed (s5cmd status $status). Node will sync from genesis." + else + echo "S3 snapshot restore complete at $(date '+%Y-%m-%d %H:%M:%S')" + fi + chown -R bcuser:bcuser "$DATA_DIR" 2>/dev/null + exit 0 +fi + +# --------------------------------------------------------------------------- +# Public mode: resolve the official snapshot URL +# --------------------------------------------------------------------------- +SNAPSHOT_URL="$TRON_SNAPSHOTS_URL" +if [ -z "$SNAPSHOT_URL" ] || [ "$SNAPSHOT_URL" == "none" ]; then + if [ "$TRON_NETWORK" == "nile" ]; then + echo "WARNING: no default Nile RocksDB snapshot. Set TRON_SNAPSHOTS_URL or the node syncs from genesis." + exit 0 + fi + # Official mainnet RocksDB source (America). Auto-discover the latest dated backup dir. + # Integrity note: this is TRON's official public snapshot host but is plain HTTP from a bare IP, + # so it provides no transport integrity. Override with TRON_SNAPSHOTS_URL to use a trusted/HTTPS + # mirror. TRON does not currently publish a snapshot checksum; add verification here if one exists. + SNAP_HOST="http://35.197.17.205" + LATEST_DIR=$(curl -s --max-time 30 "$SNAP_HOST/" | grep -oE 'backup[0-9]{8}/' | sort -u | tail -1) + if [ -z "$LATEST_DIR" ]; then + echo "WARNING: could not auto-discover latest snapshot dir on $SNAP_HOST. Syncing from genesis." + exit 0 + fi + SNAPSHOT_URL="$SNAP_HOST/$LATEST_DIR$SNAP_FILE" +fi +echo "Public snapshot URL: $SNAPSHOT_URL" + +if [ "$TRON_NODE_TYPE" == "full" ]; then + # Full node ~3TB. aria2c parallel (~3x faster) needs ~2x disk (archive + extracted, ~6TB). + # Use it only if the data volume has room; otherwise stream to stay within a 4TB volume. + FREE_BYTES=$(df --output=avail -B1 "$DATA_DIR" | tail -1 | tr -d ' ') + NEED_BYTES=$(( 6 * 1024 * 1024 * 1024 * 1024 )) + if [ -n "$FREE_BYTES" ] && [ "$FREE_BYTES" -gt "$NEED_BYTES" ]; then + echo "Full node: volume has room ($(df -h "$DATA_DIR" | tail -1 | awk '{print $4}') free), using aria2c multi-connection at $(date '+%Y-%m-%d %H:%M:%S')" + aria2c -s16 -x16 -k100M --max-tries=0 --retry-wait=10 "$SNAPSHOT_URL" -d "$DATA_DIR" -o snapshot.tgz + if [ $? -eq 0 ]; then + tar --use-compress-program="pigz -d" -xf "$DATA_DIR/snapshot.tgz" -C "$DATA_DIR" && rm -f "$DATA_DIR/snapshot.tgz" + echo "Snapshot extracted at $(date '+%Y-%m-%d %H:%M:%S')" + else + echo "WARNING: aria2c download failed. Node will sync from genesis." + fi + else + echo "Full node: limited disk, streamed download + extract (disk-efficient) at $(date '+%Y-%m-%d %H:%M:%S')" + MAX_RETRIES=5; attempt=1 + while [ "$attempt" -le "$MAX_RETRIES" ]; do + wget -q -O - "$SNAPSHOT_URL" | pigz -dc | tar xf - -C "$DATA_DIR" + [ "${PIPESTATUS[0]}" -eq 0 ] && { echo "Snapshot extracted at $(date '+%Y-%m-%d %H:%M:%S')"; break; } + echo "Attempt $attempt failed, retrying in 30s..."; attempt=$((attempt+1)); sleep 30 + done + fi +else + # Lite node: small (~53GB). aria2c multi-connection for speed, then extract. + echo "Lite node: aria2c multi-connection download then extract at $(date '+%Y-%m-%d %H:%M:%S')" + aria2c -s16 -x16 -k100M --max-tries=0 --retry-wait=10 "$SNAPSHOT_URL" -d "$DATA_DIR" -o snapshot.tgz + if [ $? -eq 0 ]; then + tar --use-compress-program="pigz -d" -xf "$DATA_DIR/snapshot.tgz" -C "$DATA_DIR" && rm -f "$DATA_DIR/snapshot.tgz" + echo "Snapshot extracted at $(date '+%Y-%m-%d %H:%M:%S')" + else + echo "WARNING: aria2c download failed. Node will sync from genesis." + fi +fi + +chown -R bcuser:bcuser "$DATA_DIR" 2>/dev/null +echo "Snapshot step complete." diff --git a/lib/tron/lib/assets/node-cw-dashboard.ts b/lib/tron/lib/assets/node-cw-dashboard.ts new file mode 100644 index 00000000..2f09522c --- /dev/null +++ b/lib/tron/lib/assets/node-cw-dashboard.ts @@ -0,0 +1,235 @@ +export const SyncNodeCWDashboardJSON = { + "widgets": [ + { + "height": 5, + "width": 6, + "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": 5, + "width": 6, + "y": 5, + "x": 18, + "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": 5, + "width": 6, + "y": 0, + "x": 18, + "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": 5, + "width": 6, + "y": 10, + "x": 0, + "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": 5, + "width": 6, + "y": 5, + "x": 0, + "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": 5, + "width": 6, + "y": 0, + "x": 6, + "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": 4, + "width": 6, + "y": 0, + "x": 12, + "type": "metric", + "properties": { + "metrics": [ + [ "CWAgent", "tron_sync_block", "InstanceId", "${INSTANCE_ID}", { "label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + ], + "sparkline": true, + "view": "timeSeries", + "stacked": false, + "region": "${REGION}", + "stat": "Maximum", + "period": 60, + "title": "TRON Client Block Height" + } + }, + { + "height": 4, + "width": 6, + "y": 4, + "x": 12, + "type": "metric", + "properties": { + "sparkline": true, + "view": "timeSeries", + "stacked": false, + "region": "${REGION}", + "stat": "Maximum", + "period": 60, + "metrics": [ + [ "CWAgent", "tron_blocks_behind", "InstanceId", "${INSTANCE_ID}", { "label": "${INSTANCE_ID}-${INSTANCE_NAME}" } ] + ], + "title": "TRON Client Blocks Behind" + } + }, + { + "height": 5, + "width": 6, + "y": 5, + "x": 6, + "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": 5, + "width": 6, + "y": 10, + "x": 6, + "type": "metric", + "properties": { + "metrics": [ + [ { "expression": "(m2/1048576)/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/1048576)/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, + "region": "${REGION}", + "stat": "Average", + "period": 60, + "title": "nvme1n1 Volume Read/Write throughput (MiB/sec)" + } + }, + { + "height": 3, + "width": 6, + "y": 15, + "x": 6, + "type": "metric", + "properties": { + "metrics": [ + [ "CWAgent", "disk_used_percent", "path", "/data", "InstanceId", "${INSTANCE_ID}", "device", "nvme1n1", "fstype", "xfs", { "region": "${REGION}", "label": "/data" } ] + ], + "sparkline": true, + "view": "singleValue", + "region": "${REGION}", + "title": "nvme1n1 Disk Used (%)", + "period": 60, + "stat": "Average" + } + } + ] +} diff --git a/lib/tron/lib/assets/setup-instance-store-volumes.sh b/lib/tron/lib/assets/setup-instance-store-volumes.sh new file mode 100644 index 00000000..4d5f23f4 --- /dev/null +++ b/lib/tron/lib/assets/setup-instance-store-volumes.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +source /etc/environment + +if [[ "$DATA_VOLUME_TYPE" == "instance-store" ]]; then + echo "Data volume type is instance store" + export DATA_VOLUME_ID=/dev/$(lsblk -lnb | awk -v VOLUME_SIZE_BYTES="$DATA_VOLUME_SIZE" '{if ($4== VOLUME_SIZE_BYTES) {print $1}}') +fi + +if [ -n "$DATA_VOLUME_ID" ]; then + if [ $(df --output=target | grep -c "/data") -lt 1 ]; then + echo "Checking fstab for Data volume" + + sudo mkfs.xfs -f $DATA_VOLUME_ID + sleep 10 + DATA_VOLUME_UUID=$(lsblk -fn -o UUID $DATA_VOLUME_ID) + DATA_VOLUME_FSTAB_CONF="UUID=$DATA_VOLUME_UUID /data xfs defaults 0 2" + echo "DATA_VOLUME_ID="$DATA_VOLUME_ID + echo "DATA_VOLUME_UUID="$DATA_VOLUME_UUID + echo "DATA_VOLUME_FSTAB_CONF="$DATA_VOLUME_FSTAB_CONF + + # Check if data disc is already in fstab and replace the line if it is with the new disc UUID + if [ $(grep -c "data" /etc/fstab) -gt 0 ]; then + SED_REPLACEMENT_STRING="$(grep -n "/data" /etc/fstab | cut -d: -f1)s#.*#$DATA_VOLUME_FSTAB_CONF#" + sudo cp /etc/fstab /etc/fstab.bak + sudo sed -i "$SED_REPLACEMENT_STRING" /etc/fstab + else + echo $DATA_VOLUME_FSTAB_CONF | sudo tee -a /etc/fstab + fi + + sudo mount -a + + /opt/download-snapshot.sh + + chown bcuser:bcuser -R /data + else + echo "Data volume is mounted, nothing changed" + fi +fi diff --git a/lib/tron/lib/assets/tron-checker/syncchecker-tron.sh b/lib/tron/lib/assets/tron-checker/syncchecker-tron.sh new file mode 100644 index 00000000..0de1ca65 --- /dev/null +++ b/lib/tron/lib/assets/tron-checker/syncchecker-tron.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Publishes TRON sync metrics to CloudWatch: +# tron_sync_block - local node's current block height +# tron_blocks_behind - difference vs a public reference node (0 when caught up) +# Metric names must match those used in node-cw-dashboard.ts. + +source /etc/environment 2>/dev/null + +# Local node height via java-tron HTTP API +LOCAL_HEIGHT=$(curl -s -X POST http://localhost:8090/wallet/getnowblock | jq -r '.block_header.raw_data.number // empty') +if [[ -z "$LOCAL_HEIGHT" ]]; then + LOCAL_HEIGHT=0 +fi + +# Public reference height (network-aware) +if [[ "$TRON_NETWORK" == "nile" ]]; then + REF_ENDPOINT="https://nile.trongrid.io/wallet/getnowblock" +else + REF_ENDPOINT="https://api.trongrid.io/wallet/getnowblock" +fi +REF_HEIGHT=$(curl -s -X POST "$REF_ENDPOINT" | jq -r '.block_header.raw_data.number // empty') +if [[ -z "$REF_HEIGHT" ]]; then + REF_HEIGHT=$LOCAL_HEIGHT +fi + +BLOCKS_BEHIND=$((REF_HEIGHT - LOCAL_HEIGHT)) +if [[ "$BLOCKS_BEHIND" -lt "0" ]]; then + BLOCKS_BEHIND=0 +fi + +# Send data to CloudWatch +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) +REGION=$(curl -H "X-aws-ec2-metadata-token: $TOKEN" -s http://169.254.169.254/latest/dynamic/instance-identity/document | jq .region -r) +TIMESTAMP=$(date +"%Y-%m-%dT%H:%M:%S%:z") + +aws cloudwatch put-metric-data --metric-name tron_sync_block --namespace CWAgent --value "$LOCAL_HEIGHT" --timestamp "$TIMESTAMP" --dimensions InstanceId="$INSTANCE_ID" --region "$REGION" +aws cloudwatch put-metric-data --metric-name tron_blocks_behind --namespace CWAgent --value "$BLOCKS_BEHIND" --timestamp "$TIMESTAMP" --dimensions InstanceId="$INSTANCE_ID" --region "$REGION" diff --git a/lib/tron/lib/assets/upload-snapshot.sh b/lib/tron/lib/assets/upload-snapshot.sh new file mode 100644 index 00000000..db40f46b --- /dev/null +++ b/lib/tron/lib/assets/upload-snapshot.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set +e + +# Run on the snapshot node (via daily cron). Produces a consistent TRON DB snapshot +# and uploads it to the private S3 staging bucket so RPC/single nodes can restore fast. +# +# Streaming tar | s5cmd pipe avoids creating a multi-TB local archive. + +source /etc/environment 2>/dev/null + +if [ -z "$TRON_SNAPSHOT_S3_BUCKET" ] || [ "$TRON_SNAPSHOT_S3_BUCKET" == "none" ]; then + echo "TRON_SNAPSHOT_S3_BUCKET not set; nothing to upload." + exit 0 +fi + +if [ "$TRON_NODE_TYPE" == "lite" ]; then + SNAP_FILE="LiteFullNode_output-directory.tar.zst" +else + SNAP_FILE="FullNode_output-directory.tar.zst" +fi +S3_KEY="${TRON_NETWORK}/${TRON_NODE_TYPE}/${SNAP_FILE}" + +echo "$(date '+%F %T') Stopping tron for a consistent snapshot" +systemctl stop tron +sleep 10 + +echo "$(date '+%F %T') Uploading /data/output-directory to s3://${TRON_SNAPSHOT_S3_BUCKET}/${S3_KEY}" +# zstd -T0 = multithreaded compression. We re-store the public gzip snapshot as zstd because +# zstd decompresses far faster (and multithreaded), making s3-mode restore transfer-bound, not gzip-bound. +tar -cf - -C /data output-directory | zstd -T0 | s5cmd pipe "s3://${TRON_SNAPSHOT_S3_BUCKET}/${S3_KEY}" +# Check every stage of the pipe (tar | zstd | s5cmd); a failure in tar or zstd must not be reported as success. +pipe=("${PIPESTATUS[@]}") +status=0 +for s in "${pipe[@]}"; do [ "$s" -ne 0 ] && status="$s"; done + +echo "$(date '+%F %T') Restarting tron" +systemctl start tron + +if [ "$status" -eq 0 ]; then + echo "$(date '+%F %T') Snapshot upload complete." +else + echo "$(date '+%F %T') WARNING: snapshot upload failed (s5cmd status $status)." +fi diff --git a/lib/tron/lib/assets/user-data/node.sh b/lib/tron/lib/assets/user-data/node.sh new file mode 100644 index 00000000..7c842a5b --- /dev/null +++ b/lib/tron/lib/assets/user-data/node.sh @@ -0,0 +1,265 @@ +#!/bin/bash +set +e + +echo "AWS_REGION=${_AWS_REGION_}" >> /etc/environment +echo "ASSETS_S3_PATH=${_ASSETS_S3_PATH_}" >> /etc/environment +echo "TRON_SNAPSHOTS_URL=${_TRON_SNAPSHOTS_URL_}" >> /etc/environment +echo "STACK_NAME=${_STACK_NAME_}" >> /etc/environment +echo "STACK_ID=${_STACK_ID_}" >> /etc/environment +echo "RESOURCE_ID=${_NODE_CF_LOGICAL_ID_}" >> /etc/environment +echo "DATA_VOLUME_TYPE=${_DATA_VOLUME_TYPE_}" >> /etc/environment +echo "DATA_VOLUME_SIZE=${_DATA_VOLUME_SIZE_}" >> /etc/environment +echo "TRON_NODE_TYPE=${_TRON_NODE_TYPE_}" >> /etc/environment +echo "TRON_NETWORK=${_TRON_NETWORK_}" >> /etc/environment +echo "TRON_DB_ENGINE=${_TRON_DB_ENGINE_}" >> /etc/environment +echo "TRON_DOWNLOAD_SNAPSHOT=${_TRON_DOWNLOAD_SNAPSHOT_}" >> /etc/environment +echo "TRON_SNAPSHOT_TYPE=${_TRON_SNAPSHOT_TYPE_}" >> /etc/environment +echo "TRON_SNAPSHOT_S3_BUCKET=${_TRON_SNAPSHOT_S3_BUCKET_}" >> /etc/environment +echo "TRON_SNAPSHOT_NODE=${_TRON_SNAPSHOT_NODE_}" >> /etc/environment +echo "LIFECYCLE_HOOK_NAME=${_LIFECYCLE_HOOK_NAME_}" >> /etc/environment +echo "AUTOSCALING_GROUP_NAME=${_AUTOSCALING_GROUP_NAME_}" >> /etc/environment +source /etc/environment + +arch=$(uname -m) +echo "Architecture detected: $arch" + +if [ "$arch" == "x86_64" ]; then + SSM_AGENT_BINARY_URI=https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm + AWS_CLI_BINARY_URI=https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip +else + SSM_AGENT_BINARY_URI=https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_arm64/amazon-ssm-agent.rpm + AWS_CLI_BINARY_URI=https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip +fi + +echo "Updating and installing required system packages" +yum update -y +amazon-linux-extras install epel -y +yum groupinstall "Development Tools" -y +yum -y install amazon-cloudwatch-agent collectd jq gcc ncurses-devel telnet aws-cfn-bootstrap wget tar gzip pv aria2 pigz zstd + +# Install the correct Amazon Corretto JDK for the CPU architecture. +# java-tron requires JDK 17 on ARM64 (Graviton) and JDK 8 on x86_64. +echo "Installing Amazon Corretto JDK" +rpm --import https://yum.corretto.aws/corretto.key +curl -sL -o /etc/yum.repos.d/corretto.repo https://yum.corretto.aws/corretto.repo +if [ "$arch" == "x86_64" ]; then + yum install -y java-1.8.0-amazon-corretto-devel +else + yum install -y java-17-amazon-corretto-devel +fi +java -version + +cd /opt + +echo 'Installing AWS CLI v2' +curl "$AWS_CLI_BINARY_URI" -o "awscliv2.zip" +unzip -q awscliv2.zip +./aws/install +rm -f /usr/bin/aws +ln /usr/local/bin/aws /usr/bin/aws + +echo "Installing s5cmd for fast S3 transfers" +if [ "$arch" == "x86_64" ]; then + S5CMD_URI=https://github.com/peak/s5cmd/releases/download/v2.2.2/s5cmd_2.2.2_Linux-64bit.tar.gz +else + S5CMD_URI=https://github.com/peak/s5cmd/releases/download/v2.2.2/s5cmd_2.2.2_Linux-arm64.tar.gz +fi +cd /opt +wget -q "$S5CMD_URI" -O s5cmd.tar.gz +tar -xf s5cmd.tar.gz s5cmd +chmod +x s5cmd +mv s5cmd /usr/bin/ +s5cmd version || echo "s5cmd install check failed (non-fatal)" + +echo "Downloading assets zip file" +aws s3 cp "$ASSETS_S3_PATH" ./assets.zip --region "$AWS_REGION" +unzip -q assets.zip + +aws configure set default.s3.max_concurrent_requests 50 +aws configure set default.s3.multipart_chunksize 256MB + +echo 'Installing SSM Agent' +yum install -y "$SSM_AGENT_BINARY_URI" + +echo 'Adding bcuser user and group' +sudo groupadd -g 1002 bcuser +sudo useradd -u 1002 -g 1002 -m -s /bin/bash bcuser +# bcuser runs the tron service and the snapshot scripts; it does NOT need sudo (least privilege). + +echo "Installing java-tron FullNode.jar" +mkdir -p /home/bcuser/tron +cd /home/bcuser/tron + +# Download the latest released FullNode jar for the correct CPU architecture. +# java-tron publishes per-arch jars: FullNode-aarch64.jar (ARM64) and FullNode.jar (x86_64). +# The generic FullNode.jar bundles an x86_64-only RocksDB native lib and will NOT run on ARM64. +if [ "$arch" == "x86_64" ]; then + JAR_ASSET="FullNode.jar" +else + JAR_ASSET="FullNode-aarch64.jar" +fi +echo "Selecting java-tron jar asset: $JAR_ASSET" +FULLNODE_JAR_URL=$(curl -s https://api.github.com/repos/tronprotocol/java-tron/releases/latest | jq -r --arg n "$JAR_ASSET" '.assets[] | select(.name==$n) | .browser_download_url') +if [ -z "$FULLNODE_JAR_URL" ] || [ "$FULLNODE_JAR_URL" == "null" ]; then + echo "Could not resolve $JAR_ASSET asset URL, falling back to latest/download redirect" + FULLNODE_JAR_URL="https://github.com/tronprotocol/java-tron/releases/latest/download/$JAR_ASSET" +fi +echo "Downloading $JAR_ASSET from: $FULLNODE_JAR_URL" +# Integrity: fetched over HTTPS from the official tronprotocol GitHub releases. java-tron does not +# currently publish per-asset checksums in its releases; add SHA256 verification here if/when it does. +wget -q -O FullNode.jar "$FULLNODE_JAR_URL" || { echo "ERROR: failed to download FullNode.jar from $FULLNODE_JAR_URL"; exit 1; } + +# Fetch the network configuration file +if [ "$TRON_NETWORK" == "nile" ]; then + echo "Fetching Nile testnet config" + wget -q -O config.conf https://raw.githubusercontent.com/tron-nile-testnet/nile-testnet/master/framework/src/main/resources/config-nile.conf || { echo "ERROR: failed to download Nile config"; exit 1; } +else + echo "Fetching mainnet config" + wget -q -O config.conf https://raw.githubusercontent.com/tronprotocol/java-tron/master/framework/src/main/resources/config.conf || { echo "ERROR: failed to download mainnet config"; exit 1; } +fi + +# Set the storage engine. ARM64 only supports ROCKSDB. +if [ "$TRON_DB_ENGINE" == "rocksdb" ] || [ "$arch" != "x86_64" ]; then + echo "Setting db.engine = ROCKSDB" + sed -i 's/db\.engine[[:space:]]*=[[:space:]]*"LEVELDB"/db.engine = "ROCKSDB"/' config.conf +fi + +# For Lite FullNode, enable history query APIs for data synced after startup. +if [ "$TRON_NODE_TYPE" == "lite" ]; then + echo "Enabling openHistoryQueryWhenLiteFN for Lite FullNode" + sed -i 's/openHistoryQueryWhenLiteFN[[:space:]]*=[[:space:]]*false/openHistoryQueryWhenLiteFN = true/' config.conf +fi + +sudo chown bcuser:bcuser -R /home/bcuser/ + +echo "Creating systemd service for java-tron" +# Compute -Xmx as ~80% of physical memory (in GB), floor of 4g +MEM_TOTAL_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}') +XMX_GB=$(( MEM_TOTAL_KB * 80 / 100 / 1024 / 1024 )) +if [ "$XMX_GB" -lt 4 ]; then XMX_GB=4; fi +echo "Setting -Xmx${!XMX_GB}g" + +# Select GC by JDK version. TRON docs recommend CMS (-XX:+UseConcMarkSweepGC) for JDK 8 (x86_64), +# but CMS was removed in JDK 14+, so on ARM64 (which requires JDK 17) we use G1GC instead. +if [ "$arch" == "x86_64" ]; then + GC_OPTS="-XX:+UseConcMarkSweepGC" +else + GC_OPTS="-XX:+UseG1GC" +fi +echo "Using GC options: $GC_OPTS" + +sudo bash -c "cat > /home/bcuser/tron/start.sh < /etc/systemd/system/tron.service </dev/null; echo "*/1 * * * * /opt/syncchecker.sh >/tmp/syncchecker.log 2>&1") | crontab - +crontab -l + +if [[ "$LIFECYCLE_HOOK_NAME" == "none" ]]; then + echo "Single node. Signaling completion to CloudFormation" + /opt/aws/bin/cfn-signal --stack "$STACK_NAME" --resource "$RESOURCE_ID" --region "$AWS_REGION" + echo "Single node. Wait for one minute for the volume to be available" + sleep 60 +fi + +echo "Preparing data volume" +mkdir -p /data + +if [[ "$DATA_VOLUME_TYPE" == "instance-store" ]]; then + echo "Data volume type is instance store" + cd /opt + chmod +x /opt/setup-instance-store-volumes.sh + (crontab -l 2>/dev/null; echo "@reboot /opt/setup-instance-store-volumes.sh >/tmp/setup-instance-store-volumes.log 2>&1") | crontab - + /opt/setup-instance-store-volumes.sh +else + echo "Data volume type is EBS" + # Identify the data volume by size, excluding any already-mounted device (e.g. the root volume) + # so we never mkfs the root disk if sizes happen to collide (prevents catastrophic data loss). + DATA_VOLUME_ID=/dev/$(lsblk -lnb -o NAME,SIZE,MOUNTPOINT | awk -v VOLUME_SIZE_BYTES="$DATA_VOLUME_SIZE" '($2==VOLUME_SIZE_BYTES && $3==""){print $1; exit}') + if [ "$DATA_VOLUME_ID" == "/dev/" ]; then + echo "ERROR: could not identify an unmounted data volume of size $DATA_VOLUME_SIZE; skipping format to avoid data loss." + else + mkfs -t xfs "$DATA_VOLUME_ID" + sleep 10 + DATA_VOLUME_UUID=$(lsblk -fn -o UUID "$DATA_VOLUME_ID") + DATA_VOLUME_FSTAB_CONF="UUID=$DATA_VOLUME_UUID /data xfs defaults 0 2" + echo "DATA_VOLUME_ID=$DATA_VOLUME_ID" + echo "$DATA_VOLUME_FSTAB_CONF" | tee -a /etc/fstab + mount -a + fi +fi + +lsblk -d +chown bcuser:bcuser -R /data + +# Download snapshot to speed up sync (streamed download + extract). +if [[ "$TRON_DOWNLOAD_SNAPSHOT" == "true" ]]; then + echo "Downloading TRON snapshot" + chmod +x /opt/download-snapshot.sh + su - bcuser -c "TRON_NODE_TYPE=$TRON_NODE_TYPE TRON_NETWORK=$TRON_NETWORK TRON_DB_ENGINE=$TRON_DB_ENGINE TRON_SNAPSHOTS_URL=$TRON_SNAPSHOTS_URL TRON_SNAPSHOT_TYPE=$TRON_SNAPSHOT_TYPE TRON_SNAPSHOT_S3_BUCKET=$TRON_SNAPSHOT_S3_BUCKET /opt/download-snapshot.sh" +fi + +mkdir -p /data/output-directory +chown bcuser:bcuser -R /data + +echo 'Configuring CloudWatch Agent' +cp /opt/cw-agent.json /opt/aws/amazon-cloudwatch-agent/etc/custom-amazon-cloudwatch-agent.json +echo "Starting CloudWatch Agent" +/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 status amazon-cloudwatch-agent + +echo "Starting TRON node service" +sudo systemctl daemon-reload +sudo systemctl enable --now tron + +# Snapshot node: upload DB to S3 daily so RPC/single nodes can restore fast (TRON_SNAPSHOT_TYPE=s3). +if [[ "$TRON_SNAPSHOT_NODE" == "true" ]]; then + echo "Configuring daily snapshot upload cron (snapshot node)" + chmod +x /opt/upload-snapshot.sh + (crontab -l 2>/dev/null; echo "0 0 * * * /opt/upload-snapshot.sh >/tmp/upload-snapshot.log 2>&1") | crontab - +fi + +if [[ "$LIFECYCLE_HOOK_NAME" != "none" ]]; then + 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 "$AUTOSCALING_GROUP_NAME" --region "$AWS_REGION" +fi + +echo "All Done!!" +set -e diff --git a/lib/tron/lib/common-stack.ts b/lib/tron/lib/common-stack.ts new file mode 100644 index 00000000..3da080a8 --- /dev/null +++ b/lib/tron/lib/common-stack.ts @@ -0,0 +1,83 @@ +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"; +import { SnapshotsS3BucketConstruct } from "../../constructs/snapshots-bucket"; + +export interface TronCommonStackProps extends cdk.StackProps { +} + +export class TronCommonStack extends cdk.Stack { + AWS_STACK_NAME = cdk.Stack.of(this).stackName; + AWS_ACCOUNT_ID = cdk.Stack.of(this).account; + + constructor(scope: cdkConstructs.Construct, id: string, props: TronCommonStackProps) { + super(scope, id, props); + + const region = cdk.Stack.of(this).region; + + const instanceRole = new iam.Role(this, "node-role", { + assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"), + iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchAgentServerPolicy") + ] + }); + + instanceRole.addToPolicy(new iam.PolicyStatement({ + resources: [`arn:aws:cloudformation:${region}:${this.AWS_ACCOUNT_ID}:stack/tron-*/*`], + actions: ["cloudformation:SignalResource"] + })); + + instanceRole.addToPolicy(new iam.PolicyStatement({ + resources: [`arn:aws:autoscaling:${region}:${this.AWS_ACCOUNT_ID}:autoScalingGroup:*:autoScalingGroupName/tron-*`], + actions: ["autoscaling:CompleteLifecycleAction"] + })); + + // in lifecycle + instanceRole.addToPolicy(new iam.PolicyStatement({ + resources: [`arn:aws:autoscaling:${region}:${this.AWS_ACCOUNT_ID}:autoScalingGroup:*:autoScalingGroupName/tron-*`], + actions: ["autoscaling:RecordLifecycleActionHeartbeat"] + })); + + // Private S3 bucket for snapshot staging (used when TRON_SNAPSHOT_TYPE=s3). + // The snapshot node uploads here; RPC/single nodes restore from here via s5cmd. + const snapshotsBucket = new SnapshotsS3BucketConstruct(this, "snapshots-bucket", { + bucketName: `tron-snapshots-${this.AWS_ACCOUNT_ID}-${region}` + }); + instanceRole.addToPolicy(new iam.PolicyStatement({ + resources: [snapshotsBucket.bucketArn, snapshotsBucket.arnForObjects("*")], + actions: ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + })); + + new cdk.CfnOutput(this, "Instance Role ARN", { + value: instanceRole.roleArn, + exportName: "TronNodeInstanceRoleArn" + }); + + new cdk.CfnOutput(this, "Snapshot Bucket Name", { + value: snapshotsBucket.bucketName, + exportName: "TronNodeSnapshotBucketName" + }); + + // 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" + }, + { + id: "AwsSolutions-S1", + reason: "Server access logs are not required for the transient snapshot staging bucket" + } + ], + true + ); + } +} diff --git a/lib/tron/lib/config/tronConfig.interface.ts b/lib/tron/lib/config/tronConfig.interface.ts new file mode 100644 index 00000000..5c2408aa --- /dev/null +++ b/lib/tron/lib/config/tronConfig.interface.ts @@ -0,0 +1,42 @@ +import * as configTypes from "../../../constructs/config.interface"; + +export type TronNetwork = "mainnet" | "nile"; +// "full" - FullNode: stores complete history (~2.9TB), all HTTP/gRPC APIs +// "lite" - Lite FullNode: state snapshot + latest 65536 blocks (~3% of full), faster start +export type TronNodeConfiguration = "full" | "lite"; + +// Snapshot bootstrap source: +// "none" - sync from genesis (slow) +// "public" - download from TRON official snapshot host (accelerated with aria2c multi-connection) +// "s3" - restore from your own private S3 staging bucket populated by the snapshot node (s5cmd) +export type TronSnapshotType = "none" | "public" | "s3"; + +// java-tron storage engine. On ARM64 (Graviton) only ROCKSDB is supported; +// LEVELDB is deprecated for arm per java-tron config.conf. +export type TronDbEngine = "rocksdb" | "leveldb"; + +export type TronNodeRole = "single-node" | "rpc-node" | "snapshot-node"; + +export interface TronDataVolumeConfig extends configTypes.DataVolumeConfig { + +} + +export interface TronBaseConfig extends configTypes.BaseConfig { + +} + +export interface TronBaseNodeConfig extends configTypes.BaseNodeConfig { + tronNetwork: TronNetwork; + nodeConfiguration: TronNodeConfiguration; + dbEngine: TronDbEngine; + snapshotType: TronSnapshotType; + snapshotsUrl: string; + dataVolume: TronDataVolumeConfig; + downloadSnapshot: boolean; +} + +export interface TronHAConfig { + albHealthCheckGracePeriodMin: number; + heartBeatDelayMin: number; + numberOfNodes: number; +} diff --git a/lib/tron/lib/config/tronConfig.ts b/lib/tron/lib/config/tronConfig.ts new file mode 100644 index 00000000..e8b9d185 --- /dev/null +++ b/lib/tron/lib/config/tronConfig.ts @@ -0,0 +1,51 @@ +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as configTypes from "./tronConfig.interface"; +import * as constants from "../../../constructs/constants"; + +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.TronBaseConfig = { + accountId: process.env.AWS_ACCOUNT_ID || "xxxxxxxxxxx", + region: process.env.AWS_REGION || "us-east-1" +}; + +export const baseNodeConfig: configTypes.TronBaseNodeConfig = { + // Default to AWS Graviton (ARM64). java-tron on ARM64 requires JDK 17 (Amazon Corretto 17) + // and ROCKSDB storage engine. Override TRON_CPU_TYPE=x86_64 for Intel/AMD. + instanceType: new ec2.InstanceType(process.env.TRON_INSTANCE_TYPE ? process.env.TRON_INSTANCE_TYPE : "m7g.4xlarge"), + instanceCpuType: process.env.TRON_CPU_TYPE?.toLowerCase() == "x86_64" ? ec2.AmazonLinuxCpuType.X86_64 : ec2.AmazonLinuxCpuType.ARM_64, + tronNetwork: (process.env.TRON_NETWORK || "mainnet") as configTypes.TronNetwork, + nodeConfiguration: (process.env.TRON_NODE_CONFIGURATION || "lite") as configTypes.TronNodeConfiguration, + // ARM64 only supports ROCKSDB; keep rocksdb as the default. + dbEngine: (process.env.TRON_DB_ENGINE?.toLowerCase() || "rocksdb") as configTypes.TronDbEngine, + // Snapshot bootstrap source: none | public | s3 (default public) + snapshotType: (process.env.TRON_SNAPSHOT_TYPE?.toLowerCase() || "public") as configTypes.TronSnapshotType, + snapshotsUrl: process.env.TRON_SNAPSHOTS_URL || constants.NoneValue, + downloadSnapshot: (process.env.TRON_SNAPSHOT_TYPE?.toLowerCase() || "public") !== "none", + dataVolume: { + // Lite FullNode needs ~200GB+headroom; FullNode needs ~4TB. Set per node type via .env. + sizeGiB: process.env.TRON_DATA_VOL_SIZE ? parseInt(process.env.TRON_DATA_VOL_SIZE) : 600, + type: parseDataVolumeType(process.env.TRON_DATA_VOL_TYPE?.toLowerCase() ? process.env.TRON_DATA_VOL_TYPE?.toLowerCase() : "gp3"), + iops: process.env.TRON_DATA_VOL_IOPS ? parseInt(process.env.TRON_DATA_VOL_IOPS) : 10000, + throughput: process.env.TRON_DATA_VOL_THROUGHPUT ? parseInt(process.env.TRON_DATA_VOL_THROUGHPUT) : 700 + } +}; + +export const haNodeConfig: configTypes.TronHAConfig = { + albHealthCheckGracePeriodMin: process.env.TRON_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN ? parseInt(process.env.TRON_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN) : 10, + heartBeatDelayMin: process.env.TRON_HA_NODES_HEARTBEAT_DELAY_MIN ? parseInt(process.env.TRON_HA_NODES_HEARTBEAT_DELAY_MIN) : 40, + numberOfNodes: process.env.TRON_HA_NUMBER_OF_NODES ? parseInt(process.env.TRON_HA_NUMBER_OF_NODES) : 2 +}; diff --git a/lib/tron/lib/constructs/tron-node-security-group.ts b/lib/tron/lib/constructs/tron-node-security-group.ts new file mode 100644 index 00000000..a93988cf --- /dev/null +++ b/lib/tron/lib/constructs/tron-node-security-group.ts @@ -0,0 +1,49 @@ +import * as cdk from "aws-cdk-lib"; +import * as cdkConstructs from "constructs"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import * as nag from "cdk-nag"; + +export interface TronNodeSecurityGroupConstructsProps { + vpc: cdk.aws_ec2.IVpc; +} + +export class TronNodeSecurityGroupConstructs extends cdkConstructs.Construct { + public securityGroup: cdk.aws_ec2.SecurityGroup; + + constructor(scope: cdkConstructs.Construct, id: string, props: TronNodeSecurityGroupConstructsProps) { + super(scope, id); + + const { + vpc + } = props; + + const sg = new ec2.SecurityGroup(this, `rpc-node-security-group`, { + vpc, + description: "Security Group for TRON (java-tron) nodes", + allowAllOutbound: true + }); + + // Public ports - required for P2P participation in the TRON network. + // java-tron node.listen.port = 18888 (uses both TCP for peer connections and UDP for node discovery). + sg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(18888), "TRON P2P (TCP)"); + sg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.udp(18888), "TRON P2P node discovery (UDP)"); + + // Private ports - RPC APIs are only reachable from within the VPC, never the Internet. + sg.addIngressRule(ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Port.tcp(8090), "TRON HTTP FullNode API"); + sg.addIngressRule(ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Port.tcp(50051), "TRON gRPC API"); + + this.securityGroup = sg; + + // cdk-nag suppressions + nag.NagSuppressions.addResourceSuppressions( + this, + [ + { + id: "AwsSolutions-EC23", + reason: "TRON P2P port 18888 (TCP/UDP) must be open to the public Internet for the node to discover peers and sync with the network" + } + ], + true + ); + } +} diff --git a/lib/tron/lib/ha-nodes-stack.ts b/lib/tron/lib/ha-nodes-stack.ts new file mode 100644 index 00000000..1bf2523c --- /dev/null +++ b/lib/tron/lib/ha-nodes-stack.ts @@ -0,0 +1,135 @@ +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 { AmazonLinuxGeneration, AmazonLinuxImage } from "aws-cdk-lib/aws-ec2"; +import * as s3Assets from "aws-cdk-lib/aws-s3-assets"; +import * as configTypes from "./config/tronConfig.interface"; +import { TronNodeSecurityGroupConstructs } from "./constructs/tron-node-security-group"; +import * as fs from "fs"; +import * as path from "path"; +import * as constants from "../../constructs/constants"; +import { HANodesConstruct } from "../../constructs/ha-rpc-nodes-with-alb"; +import * as nag from "cdk-nag"; + +export interface TronHANodesStackProps extends cdk.StackProps, configTypes.TronBaseNodeConfig, configTypes.TronHAConfig { +} + +export class TronHANodesStack extends cdk.Stack { + constructor(scope: cdkConstructs.Construct, id: string, props: TronHANodesStackProps) { + super(scope, id, props); + + const REGION = cdk.Stack.of(this).region; + const STACK_NAME = cdk.Stack.of(this).stackName; + const lifecycleHookName = STACK_NAME; + const autoScalingGroupName = STACK_NAME; + + const { + instanceType, + instanceCpuType, + tronNetwork, + nodeConfiguration, + dbEngine, + snapshotType, + snapshotsUrl, + dataVolume, + downloadSnapshot, + 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 TRON-specific construct + const instanceSG = new TronNodeSecurityGroupConstructs(this, "security-group", { vpc: vpc }); + + // getting the IAM Role ARM from the common stack + const importedInstanceRoleArn = cdk.Fn.importValue("TronNodeInstanceRoleArn"); + const importedSnapshotBucketName = cdk.Fn.importValue("TronNodeSnapshotBucketName"); + + const instanceRole = iam.Role.fromRoleArn(this, "iam-role", importedInstanceRoleArn); + + // making our scripts and configs from the local "assets" directory available for instance to download + const asset = new s3Assets.Asset(this, "assets", { + path: path.join(__dirname, "assets") + }); + + asset.bucket.grantRead(instanceRole); + + // parsing user data script and injecting necessary variables + const nodeScript = fs.readFileSync(path.join(__dirname, "assets", "user-data", "node.sh")).toString(); + const dataVolumeSizeBytes = dataVolume.sizeGiB * constants.GibibytesToBytesConversionCoefficient; + + const modifiedInitNodeScript = cdk.Fn.sub(nodeScript, { + _AWS_REGION_: REGION, + _ASSETS_S3_PATH_: `s3://${asset.s3BucketName}/${asset.s3ObjectKey}`, + _STACK_NAME_: STACK_NAME, + _TRON_SNAPSHOTS_URL_: snapshotsUrl, + _STACK_ID_: constants.NoneValue, + _NODE_CF_LOGICAL_ID_: constants.NoneValue, + _TRON_NODE_TYPE_: nodeConfiguration, + _TRON_DB_ENGINE_: dbEngine, + _TRON_SNAPSHOT_TYPE_: snapshotType, + _TRON_SNAPSHOT_S3_BUCKET_: importedSnapshotBucketName, + _TRON_SNAPSHOT_NODE_: "false", + _DATA_VOLUME_TYPE_: dataVolume.type, + _DATA_VOLUME_SIZE_: dataVolumeSizeBytes.toString(), + _TRON_DOWNLOAD_SNAPSHOT_: downloadSnapshot.toString(), + + _TRON_NETWORK_: tronNetwork, + _LIFECYCLE_HOOK_NAME_: lifecycleHookName, + _AUTOSCALING_GROUP_NAME_: autoScalingGroupName + }); + + const rpcNodes = new HANodesConstruct(this, "rpc-nodes", { + instanceType, + dataVolumes: [dataVolume], + machineImage: new ec2.AmazonLinuxImage({ + generation: AmazonLinuxGeneration.AMAZON_LINUX_2, + kernel:ec2.AmazonLinuxKernel.KERNEL5_X, + cpuType: instanceCpuType + }), + role: instanceRole, + vpc, + securityGroup: instanceSG.securityGroup, + userData: modifiedInitNodeScript, + numberOfNodes, + rpcPortForALB: 8090, + healthCheckPath: "/wallet/getnowblock", + albHealthCheckGracePeriodMin, + heartBeatDelayMin, + lifecycleHookName: lifecycleHookName, + autoScalingGroupName: autoScalingGroupName + }); + + + + new cdk.CfnOutput(this, "alb-url", { value: rpcNodes.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/tron/lib/single-node-stack.ts b/lib/tron/lib/single-node-stack.ts new file mode 100644 index 00000000..44cbd99b --- /dev/null +++ b/lib/tron/lib/single-node-stack.ts @@ -0,0 +1,141 @@ +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 nodeCwDashboard from "./assets/node-cw-dashboard" +import * as cw from 'aws-cdk-lib/aws-cloudwatch'; +import * as constants from "../../constructs/constants"; +import { SingleNodeConstruct } from "../../constructs/single-node" +import * as configTypes from "./config/tronConfig.interface"; +import { TronNodeSecurityGroupConstructs } from "./constructs/tron-node-security-group" +import * as nag from "cdk-nag"; + +export interface TronSingleNodeStackProps extends cdk.StackProps, configTypes.TronBaseNodeConfig { +} + +export class TronSingleNodeStack extends cdk.Stack { + constructor(scope: cdkConstructs.Construct, id: string, props: TronSingleNodeStackProps) { + 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, + tronNetwork, + nodeConfiguration, + dbEngine, + snapshotType, + snapshotsUrl, + dataVolume, + downloadSnapshot, + } = props; + + // Using default VPC + const vpc = ec2.Vpc.fromLookup(this, "vpc", { isDefault: true }); + + // Setting up the security group for the node from Ethereum-specific construct + const instanceSG = new TronNodeSecurityGroupConstructs (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 snapshot bucket name and IAM role ARN from the common stack + const importedInstanceRoleArn = cdk.Fn.importValue("TronNodeInstanceRoleArn"); + const importedSnapshotBucketName = cdk.Fn.importValue("TronNodeSnapshotBucketName"); + + const 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 + const node = new SingleNodeConstruct(this, "single-node", { + instanceName: STACK_NAME, + instanceType, + dataVolumes: [dataVolume], + machineImage: new ec2.AmazonLinuxImage({ + generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2, + kernel:ec2.AmazonLinuxKernel.KERNEL5_X, + cpuType: instanceCpuType, + }), + vpc, + availabilityZone: chosenAvailabilityZone, + role: instanceRole, + securityGroup: instanceSG.securityGroup, + vpcSubnets: { + subnetType: ec2.SubnetType.PUBLIC, + }, + }); + + // Parsing user data script and injecting necessary variables + const userData = fs.readFileSync(path.join(__dirname, "assets", "user-data", "node.sh")).toString(); + + const dataVolumeSizeBytes = dataVolume.sizeGiB * constants.GibibytesToBytesConversionCoefficient; + + const modifiedUserData = cdk.Fn.sub(userData, { + _AWS_REGION_: REGION, + _ASSETS_S3_PATH_: `s3://${asset.s3BucketName}/${asset.s3ObjectKey}`, + _STACK_NAME_: STACK_NAME, + _TRON_SNAPSHOTS_URL_: snapshotsUrl, + _STACK_ID_: STACK_ID, + _NODE_CF_LOGICAL_ID_: node.nodeCFLogicalId, + _TRON_NODE_TYPE_: nodeConfiguration, + _TRON_DB_ENGINE_: dbEngine, + _TRON_SNAPSHOT_TYPE_: snapshotType, + _TRON_SNAPSHOT_S3_BUCKET_: importedSnapshotBucketName, + _TRON_SNAPSHOT_NODE_: "false", + _DATA_VOLUME_TYPE_: dataVolume.type, + _DATA_VOLUME_SIZE_: dataVolumeSizeBytes.toString(), + _TRON_DOWNLOAD_SNAPSHOT_: downloadSnapshot.toString(), + + _TRON_NETWORK_: tronNetwork, + _LIFECYCLE_HOOK_NAME_: constants.NoneValue, + _AUTOSCALING_GROUP_NAME_: constants.NoneValue, + }); + + // Adding modified userdata script to the instance prepared fro us by Single Node constract + node.instance.addUserData(modifiedUserData); + + // Adding CloudWatch dashboard to the node + const dashboardString = cdk.Fn.sub(JSON.stringify(nodeCwDashboard.SyncNodeCWDashboardJSON), { + INSTANCE_ID:node.instanceId, + INSTANCE_NAME: STACK_NAME, + REGION: REGION, + }) + + new cw.CfnDashboard(this, 'single-cw-dashboard', { + dashboardName: `${STACK_NAME}-${node.instanceId}`, + dashboardBody: dashboardString, + }); + + new cdk.CfnOutput(this, "single-instance-id", { + value: node.instanceId, + }); + + // Adding suppressions to the stack + nag.NagSuppressions.addResourceSuppressions( + this, + [ + { + id: "AwsSolutions-IAM5", + reason: "Need read and write access to the S3 bucket", + }, + ], + true + ); + } +} diff --git a/lib/tron/lib/snapshot-node-stack.ts b/lib/tron/lib/snapshot-node-stack.ts new file mode 100644 index 00000000..f58a33cd --- /dev/null +++ b/lib/tron/lib/snapshot-node-stack.ts @@ -0,0 +1,124 @@ +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 nodeCwDashboard from "./assets/node-cw-dashboard"; +import * as cw from "aws-cdk-lib/aws-cloudwatch"; +import * as constants from "../../constructs/constants"; +import { SingleNodeConstruct } from "../../constructs/single-node"; +import * as configTypes from "./config/tronConfig.interface"; +import { TronNodeSecurityGroupConstructs } from "./constructs/tron-node-security-group"; +import * as nag from "cdk-nag"; + +export interface TronSnapshotNodeStackProps extends cdk.StackProps, configTypes.TronBaseNodeConfig { +} + +// Snapshot node: syncs from the public source, then uploads its DB to the private S3 +// staging bucket on a daily cron so RPC/single nodes can restore quickly (TRON_SNAPSHOT_TYPE=s3). +export class TronSnapshotNodeStack extends cdk.Stack { + constructor(scope: cdkConstructs.Construct, id: string, props: TronSnapshotNodeStackProps) { + super(scope, id, props); + + 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]; + + const { + instanceType, + instanceCpuType, + tronNetwork, + nodeConfiguration, + dbEngine, + snapshotsUrl, + dataVolume, + } = props; + + const vpc = ec2.Vpc.fromLookup(this, "vpc", { isDefault: true }); + + const instanceSG = new TronNodeSecurityGroupConstructs(this, "security-group", { vpc: vpc }); + + const asset = new s3Assets.Asset(this, "assets", { + path: path.join(__dirname, "assets"), + }); + + const importedInstanceRoleArn = cdk.Fn.importValue("TronNodeInstanceRoleArn"); + const importedSnapshotBucketName = cdk.Fn.importValue("TronNodeSnapshotBucketName"); + const instanceRole = iam.Role.fromRoleArn(this, "iam-role", importedInstanceRoleArn); + asset.bucket.grantRead(instanceRole); + + const node = new SingleNodeConstruct(this, "snapshot-node", { + instanceName: STACK_NAME, + instanceType, + dataVolumes: [dataVolume], + machineImage: new ec2.AmazonLinuxImage({ + generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2, + kernel: ec2.AmazonLinuxKernel.KERNEL5_X, + cpuType: instanceCpuType, + }), + vpc, + availabilityZone: chosenAvailabilityZone, + role: instanceRole, + securityGroup: instanceSG.securityGroup, + vpcSubnets: { + subnetType: ec2.SubnetType.PUBLIC, + }, + }); + + const userData = fs.readFileSync(path.join(__dirname, "assets", "user-data", "node.sh")).toString(); + const dataVolumeSizeBytes = dataVolume.sizeGiB * constants.GibibytesToBytesConversionCoefficient; + + const modifiedUserData = cdk.Fn.sub(userData, { + _AWS_REGION_: REGION, + _ASSETS_S3_PATH_: `s3://${asset.s3BucketName}/${asset.s3ObjectKey}`, + _STACK_NAME_: STACK_NAME, + _TRON_SNAPSHOTS_URL_: snapshotsUrl, + _STACK_ID_: STACK_ID, + _NODE_CF_LOGICAL_ID_: node.nodeCFLogicalId, + _TRON_NODE_TYPE_: nodeConfiguration, + _TRON_DB_ENGINE_: dbEngine, + // Snapshot node always bootstraps from the public source, then maintains the S3 copy. + _TRON_SNAPSHOT_TYPE_: "public", + _TRON_SNAPSHOT_S3_BUCKET_: importedSnapshotBucketName, + _TRON_SNAPSHOT_NODE_: "true", + _DATA_VOLUME_TYPE_: dataVolume.type, + _DATA_VOLUME_SIZE_: dataVolumeSizeBytes.toString(), + _TRON_DOWNLOAD_SNAPSHOT_: "true", + _TRON_NETWORK_: tronNetwork, + _LIFECYCLE_HOOK_NAME_: constants.NoneValue, + _AUTOSCALING_GROUP_NAME_: constants.NoneValue, + }); + + node.instance.addUserData(modifiedUserData); + + const dashboardString = cdk.Fn.sub(JSON.stringify(nodeCwDashboard.SyncNodeCWDashboardJSON), { + INSTANCE_ID: node.instanceId, + INSTANCE_NAME: STACK_NAME, + REGION: REGION, + }); + + new cw.CfnDashboard(this, "snapshot-cw-dashboard", { + dashboardName: `${STACK_NAME}-${node.instanceId}`, + dashboardBody: dashboardString, + }); + + new cdk.CfnOutput(this, "snapshot-instance-id", { + value: node.instanceId, + }); + + nag.NagSuppressions.addResourceSuppressions( + this, + [ + { + id: "AwsSolutions-IAM5", + reason: "Need read and write access to the S3 bucket", + }, + ], + true + ); + } +} diff --git a/lib/tron/package.json b/lib/tron/package.json new file mode 100644 index 00000000..d07c2027 --- /dev/null +++ b/lib/tron/package.json @@ -0,0 +1,21 @@ +{ + "name": "aws-blockchain-node-runners-tron", + "version": "0.1.0", + "bin": { + "tron": "bin/tron.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "cdk", + "cdk_deploy_common": "cdk deploy tron-common", + "cdk_deploy_single_node": "cdk deploy tron-single-node", + "cdk_deploy_snapshot_node": "cdk deploy tron-snapshot-node", + "cdk_deploy_ha_nodes": "cdk deploy tron-ha-nodes", + "cdk_destroy_common": "cdk destroy tron-common", + "cdk_destroy_single_node": "cdk destroy tron-single-node", + "cdk_destroy_snapshot_node": "cdk destroy tron-snapshot-node", + "cdk_destroy_ha_nodes": "cdk destroy tron-ha-nodes" + } +} diff --git a/lib/tron/sample-configs/.env-sample-full b/lib/tron/sample-configs/.env-sample-full new file mode 100644 index 00000000..2aec9c04 --- /dev/null +++ b/lib/tron/sample-configs/.env-sample-full @@ -0,0 +1,31 @@ +## Set the AWS account ID and region for deployment +AWS_ACCOUNT_ID="" +AWS_REGION="us-east-1" + +## Compute: AWS Graviton (ARM64). java-tron on ARM64 uses Amazon Corretto 17 + RocksDB. +## FullNode recommended: 16 vCPU / 32GB+ RAM. m7g.4xlarge = 16 vCPU / 64GB. +TRON_INSTANCE_TYPE="m7g.4xlarge" +TRON_CPU_TYPE="ARM_64" + +## Node: full FullNode on mainnet. Stores complete history (~2.9TB), all HTTP/gRPC APIs. +TRON_NETWORK="mainnet" +TRON_NODE_CONFIGURATION="full" +TRON_DB_ENGINE="rocksdb" + +## Snapshot: streamed download to speed up sync (FullNode snapshot is ~2.9TB). +## IMPORTANT: verify the current FullNode RocksDB snapshot URL on +## https://developers.tron.network/docs/main-net-database-snapshots +## and set it here. Leave as "none" to use the script's documented default. +TRON_SNAPSHOT_TYPE="public" # none | public | s3 +TRON_SNAPSHOTS_URL="none" + +## Storage: FullNode needs ~2.9TB+; 4TB gp3 with high IOPS/throughput for sync performance. +TRON_DATA_VOL_TYPE="gp3" +TRON_DATA_VOL_SIZE="4000" +TRON_DATA_VOL_IOPS="10000" +TRON_DATA_VOL_THROUGHPUT="700" + +## HA options (only used by tron-ha-nodes) +TRON_HA_NUMBER_OF_NODES="2" +TRON_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN="10" +TRON_HA_NODES_HEARTBEAT_DELAY_MIN="40" diff --git a/lib/tron/sample-configs/.env-sample-lite b/lib/tron/sample-configs/.env-sample-lite new file mode 100644 index 00000000..62ed7525 --- /dev/null +++ b/lib/tron/sample-configs/.env-sample-lite @@ -0,0 +1,30 @@ +## Set the AWS account ID and region for deployment +AWS_ACCOUNT_ID="" +AWS_REGION="us-east-1" + +## Compute: AWS Graviton (ARM64). java-tron on ARM64 uses Amazon Corretto 17 + RocksDB. +TRON_INSTANCE_TYPE="m7g.2xlarge" +TRON_CPU_TYPE="ARM_64" + +## Node: Lite FullNode on mainnet. Lite stores state + latest 65536 blocks (~3% of full). +TRON_NETWORK="mainnet" +TRON_NODE_CONFIGURATION="lite" +TRON_DB_ENGINE="rocksdb" + +## Snapshot: streamed download to speed up sync. +## IMPORTANT: verify the current Lite RocksDB snapshot URL on +## https://developers.tron.network/docs/main-net-database-snapshots +## and set it here. Leave as "none" to use the script's documented default. +TRON_SNAPSHOT_TYPE="public" # none | public | s3 +TRON_SNAPSHOTS_URL="none" + +## Storage: Lite needs ~200GB; 600GB gp3 leaves headroom for growth. +TRON_DATA_VOL_TYPE="gp3" +TRON_DATA_VOL_SIZE="600" +TRON_DATA_VOL_IOPS="6000" +TRON_DATA_VOL_THROUGHPUT="400" + +## HA options (only used by tron-ha-nodes) +TRON_HA_NUMBER_OF_NODES="2" +TRON_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN="10" +TRON_HA_NODES_HEARTBEAT_DELAY_MIN="40" diff --git a/lib/tron/test/.env-test b/lib/tron/test/.env-test new file mode 100644 index 00000000..2c99d536 --- /dev/null +++ b/lib/tron/test/.env-test @@ -0,0 +1,31 @@ +############################################################# +# Example configuration for TRON nodes runner app on AWS # +############################################################# + +## Set the AWS account id and region for your environment ## +AWS_ACCOUNT_ID="xxxxxxxxxxx" +AWS_REGION="us-east-1" + +## Common configuration parameters ## +TRON_NETWORK="mainnet" # All options: "mainnet", "nile" +TRON_NODE_CONFIGURATION="full" # All options: "full", "lite" +TRON_DB_ENGINE="rocksdb" # All options: "rocksdb", "leveldb". ARM64 only supports "rocksdb" + +## Optional TRON snapshot download URL. Leave as "none" to use script defaults. +TRON_SNAPSHOT_TYPE="public" # none | public | s3 +TRON_SNAPSHOTS_URL="none" + +## Instance configuration +TRON_INSTANCE_TYPE="m7g.4xlarge" +TRON_CPU_TYPE="ARM_64" # All options: "x86_64", "ARM_64". Must match the instance type family + +## Data volume configuration +TRON_DATA_VOL_TYPE="gp3" # Options: "io1" | "io2" | "gp3" | "instance-store" +TRON_DATA_VOL_SIZE="4000" +TRON_DATA_VOL_IOPS="10000" +TRON_DATA_VOL_THROUGHPUT="700" + +## HA nodes configuration ## +TRON_HA_NUMBER_OF_NODES="2" +TRON_HA_ALB_HEALTHCHECK_GRACE_PERIOD_MIN="300" +TRON_HA_NODES_HEARTBEAT_DELAY_MIN="120" diff --git a/lib/tron/test/common-stack.test.ts b/lib/tron/test/common-stack.test.ts new file mode 100644 index 00000000..45dbda9b --- /dev/null +++ b/lib/tron/test/common-stack.test.ts @@ -0,0 +1,63 @@ +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/tronConfig"; +import { TronCommonStack } from "../lib/common-stack"; + +describe("TRONCommonStack", () => { + test("synthesizes the way we expect", () => { + const app = new cdk.App(); + + // Create the TronCommonStack. + const tronCommonStack = new TronCommonStack(app, "tron-common", { + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + stackName: `tron-nodes-common`, + }); + + // Prepare the stack for assertions. + const template = Template.fromStack(tronCommonStack); + + // 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/AmazonSSMManagedInstanceCore" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/CloudWatchAgentServerPolicy" + ] + ] + } + ] + }) + + }); +}); diff --git a/lib/tron/test/ha-nodes-stack.test.ts b/lib/tron/test/ha-nodes-stack.test.ts new file mode 100644 index 00000000..157368b8 --- /dev/null +++ b/lib/tron/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/tronConfig"; +import { TronHANodesStack } from "../lib/ha-nodes-stack"; + +describe("TronHANodesStack", () => { + test("synthesizes the way we expect", () => { + const app = new cdk.App(); + + // Create the TronHANodesStack. + const tronHANodesStack = new TronHANodesStack(app, "tron-sync-node", { + stackName: `tron-ha-nodes-${config.baseNodeConfig.nodeConfiguration}`, + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + ...config.baseNodeConfig, + ...config.haNodeConfig + }); + + // Prepare the stack for assertions. + const template = Template.fromStack(tronHANodesStack); + + // 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": "TRON P2P (TCP)", + "FromPort": 18888, + "IpProtocol": "tcp", + "ToPort": 18888 + }, + { + "CidrIp": "0.0.0.0/0", + "Description": "TRON P2P node discovery (UDP)", + "FromPort": 18888, + "IpProtocol": "udp", + "ToPort": 18888 + }, + { + "CidrIp": "1.2.3.4/5", + "Description": "TRON HTTP FullNode API", + "FromPort": 8090, + "IpProtocol": "tcp", + "ToPort": 8090 + }, + { + "CidrIp": "1.2.3.4/5", + "Description": "TRON gRPC API", + "FromPort": 50051, + "IpProtocol": "tcp", + "ToPort": 50051 + }, + { + "Description": "Allow access from ALB to Blockchain Node", + "FromPort": 0, + "IpProtocol": "tcp", + "SourceSecurityGroupId": { + "Fn::GetAtt": [ + Match.anyValue(), + "GroupId" + ] + }, + "ToPort": 65535 + }, + ] + }) + + // Has security group from ALB to EC2. + template.hasResourceProperties("AWS::EC2::SecurityGroupIngress", { + Description: "Load balancer to target", + FromPort: 8090, + GroupId: Match.anyValue(), + IpProtocol: "tcp", + SourceSecurityGroupId: Match.anyValue(), + ToPort: 8090, + }) + + // 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": 10000, + "Throughput": 700, + "VolumeSize": 4000, + "VolumeType": "gp3" + } + } + ], + EbsOptimized: true, + IamInstanceProfile: Match.anyValue(), + ImageId: Match.anyValue(), + InstanceType:"m7g.4xlarge", + SecurityGroupIds: [Match.anyValue()], + UserData: Match.anyValue(), + TagSpecifications: Match.anyValue(), + } + }) + + // Has Auto Scaling Group. + template.hasResourceProperties("AWS::AutoScaling::AutoScalingGroup", { + AutoScalingGroupName: `tron-ha-nodes-${config.baseNodeConfig.nodeConfiguration}`, + 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: `tron-ha-nodes-${config.baseNodeConfig.nodeConfiguration}`, + LifecycleTransition: "autoscaling:EC2_INSTANCE_LAUNCHING", + }); + + // Has Auto Scaling Security Group. + template.hasResourceProperties("AWS::EC2::SecurityGroup", { + GroupDescription: Match.anyValue(), + 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": 8090, + "IpProtocol": "tcp", + "ToPort": 8090 + } + ], + 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: `tron-ha-nodes-${config.baseNodeConfig.nodeConfiguration}` + } + ], + 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: 8090, + Protocol: "HTTP" + }) + + // Has ALB target group. + template.hasResourceProperties("AWS::ElasticLoadBalancingV2::TargetGroup", { + HealthCheckEnabled: true, + HealthCheckIntervalSeconds: 30, + HealthCheckPath: "/wallet/getnowblock", + HealthCheckPort: "8090", + HealthyThresholdCount: 3, + Matcher: { + HttpCode: "200-299" + }, + Port: 8090, + 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/tron/test/single-node-stack.test.ts b/lib/tron/test/single-node-stack.test.ts new file mode 100644 index 00000000..b6810f34 --- /dev/null +++ b/lib/tron/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'; +dotenv.config({ path: './test/.env-test' }); +import * as config from "../lib/config/tronConfig"; +import { TronSingleNodeStack } from "../lib/single-node-stack"; + +describe("TRONSingleNodeStack", () => { + test("synthesizes the way we expect", () => { + const app = new cdk.App(); + + // Create the EthSingleNodeStack. + const tronSingleNodeStack = new TronSingleNodeStack(app, "tron-single-node", { + stackName: `tron-single-node`, + + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + ...config.baseNodeConfig + }); + + // Prepare the stack for assertions. + const template = Template.fromStack(tronSingleNodeStack); + + // 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": "TRON P2P (TCP)", + "FromPort": 18888, + "IpProtocol": "tcp", + "ToPort": 18888 + }, + { + "CidrIp": "0.0.0.0/0", + "Description": "TRON P2P node discovery (UDP)", + "FromPort": 18888, + "IpProtocol": "udp", + "ToPort": 18888 + }, + { + "CidrIp": "1.2.3.4/5", + "Description": "TRON HTTP FullNode API", + "FromPort": 8090, + "IpProtocol": "tcp", + "ToPort": 8090 + }, + { + "CidrIp": "1.2.3.4/5", + "Description": "TRON gRPC API", + "FromPort": 50051, + "IpProtocol": "tcp", + "ToPort": 50051 + } + ] + }) + + // 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: "m7g.4xlarge", + 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: 10000, + MultiAttachEnabled: false, + Size: 4000, + 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": ["", ["tron-single-node-",{ "Ref": Match.anyValue() }]] + } + }) + + }); +}); diff --git a/lib/tron/test/snapshot-node-stack.test.ts b/lib/tron/test/snapshot-node-stack.test.ts new file mode 100644 index 00000000..685c8064 --- /dev/null +++ b/lib/tron/test/snapshot-node-stack.test.ts @@ -0,0 +1,38 @@ +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/tronConfig"; +import { TronSnapshotNodeStack } from "../lib/snapshot-node-stack"; + +describe("TronSnapshotNodeStack", () => { + test("synthesizes the way we expect", () => { + const app = new cdk.App(); + + const tronSnapshotNodeStack = new TronSnapshotNodeStack(app, "tron-snapshot-node", { + stackName: `tron-snapshot-node`, + env: { account: config.baseConfig.accountId, region: config.baseConfig.region }, + ...config.baseNodeConfig + }); + + const template = Template.fromStack(tronSnapshotNodeStack); + + // Has an EC2 instance for the snapshot node + template.hasResourceProperties("AWS::EC2::Instance", { + InstanceType: "m7g.4xlarge", + Monitoring: true, + }); + + // Has the data volume + template.hasResourceProperties("AWS::EC2::Volume", { + Encrypted: true, + Size: 4000, + VolumeType: "gp3" + }); + + // Has a CloudWatch dashboard + template.hasResourceProperties("AWS::CloudWatch::Dashboard", { + DashboardBody: Match.anyValue(), + }); + }); +}); diff --git a/lib/tron/tsconfig.json b/lib/tron/tsconfig.json new file mode 100644 index 00000000..8e1979f3 --- /dev/null +++ b/lib/tron/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" + ] +} diff --git a/scripts/check-external-urls.sh b/scripts/check-external-urls.sh index 645cdde3..e998be3e 100755 --- a/scripts/check-external-urls.sh +++ b/scripts/check-external-urls.sh @@ -8,6 +8,7 @@ set -euo pipefail TRUSTED_DOMAINS=( "s3.amazonaws.com" "awscli.amazonaws.com" + "yum.corretto.aws" "download.docker.com" "sh.rustup.rs" "raw.githubusercontent.com" @@ -26,6 +27,7 @@ TRUSTED_DOMAINS=( TRUSTED_REPOS=( "github.com/docker/compose" "github.com/peak/s5cmd" + "github.com/tronprotocol/java-tron" "github.com/MystenLabs/sui" "github.com/anza-xyz/agave" "github.com/bnb-chain/bsc" @@ -36,6 +38,7 @@ TRUSTED_REPOS=( # Trusted org prefixes (domain + org, any repo under it allowed) TRUSTED_ORGS=( "github.com/NethermindEth" + "github.com/tron-nile-testnet" "github.com/stacks-network" )