diff --git a/pkg/ethereum/ethereum_node.go b/pkg/ethereum/ethereum_node.go index 2149409..9872716 100644 --- a/pkg/ethereum/ethereum_node.go +++ b/pkg/ethereum/ethereum_node.go @@ -10,6 +10,10 @@ import ( // NewEthereumNodeComponent creates a new Ethereum node component that combines an execution client and a consensus client func NewEthereumNodeComponent(ctx *pulumi.Context, args *EthereumNodeArgs, opts ...pulumi.ResourceOption) (*EthereumNodeComponent, error) { + if err := args.Validate(); err != nil { + return nil, fmt.Errorf("invalid ethereum node args: %w", err) + } + component := &EthereumNodeComponent{ Name: args.Name, Namespace: args.Namespace, diff --git a/pkg/ethereum/validation.go b/pkg/ethereum/validation.go new file mode 100644 index 0000000..9ef89f3 --- /dev/null +++ b/pkg/ethereum/validation.go @@ -0,0 +1,36 @@ +package ethereum + +import ( + "fmt" +) + +// Validate validates the EthereumNodeArgs struct +func (args *EthereumNodeArgs) Validate() error { + if args.Name == "" { + return fmt.Errorf("name is required") + } + + if args.Namespace == "" { + return fmt.Errorf("namespace is required") + } + + if args.ExecutionClient == nil { + return fmt.Errorf("executionClient is required") + } + + if args.ConsensusClient == nil { + return fmt.Errorf("consensusClient is required") + } + + // Validate execution client + if err := args.ExecutionClient.Validate(); err != nil { + return fmt.Errorf("execution client validation failed: %w", err) + } + + // Validate consensus client + if err := args.ConsensusClient.Validate(); err != nil { + return fmt.Errorf("consensus client validation failed: %w", err) + } + + return nil +} diff --git a/pkg/ethereum/validation_test.go b/pkg/ethereum/validation_test.go new file mode 100644 index 0000000..b250ca1 --- /dev/null +++ b/pkg/ethereum/validation_test.go @@ -0,0 +1,122 @@ +package ethereum + +import ( + "testing" + + "github.com/init4tech/signet-infra-components/pkg/ethereum/consensus" + "github.com/init4tech/signet-infra-components/pkg/ethereum/execution" + "github.com/stretchr/testify/assert" +) + +func TestEthereumNodeArgsValidate(t *testing.T) { + // Test with valid args + validArgs := EthereumNodeArgs{ + Name: "test-node", + Namespace: "default", + ExecutionClient: &execution.ExecutionClientArgs{ + Name: "test-execution", + Namespace: "default", + StorageSize: "100Gi", + StorageClass: "standard", + Image: "test-execution-image", + ImagePullPolicy: "Always", + JWTSecret: "test-jwt-secret", + P2PPort: 30303, + RPCPort: 8545, + WSPort: 8546, + MetricsPort: 9090, + AuthRPCPort: 8551, + DiscoveryPort: 30303, + }, + ConsensusClient: &consensus.ConsensusClientArgs{ + Name: "test-consensus", + Namespace: "default", + StorageSize: "100Gi", + StorageClass: "standard", + Image: "test-consensus-image", + ImagePullPolicy: "Always", + JWTSecret: "test-jwt-secret", + P2PPort: 30303, + BeaconAPIPort: 5052, + MetricsPort: 9090, + ExecutionClientEndpoint: "http://execution:8551", + }, + } + + err := validArgs.Validate() + assert.NoError(t, err) + + // Test with missing name + invalidArgs1 := EthereumNodeArgs{ + Namespace: "default", + ExecutionClient: validArgs.ExecutionClient, + ConsensusClient: validArgs.ConsensusClient, + } + + err = invalidArgs1.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "name is required") + + // Test with missing namespace + invalidArgs2 := EthereumNodeArgs{ + Name: "test-node", + ExecutionClient: validArgs.ExecutionClient, + ConsensusClient: validArgs.ConsensusClient, + } + + err = invalidArgs2.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "namespace is required") + + // Test with missing execution client + invalidArgs3 := EthereumNodeArgs{ + Name: "test-node", + Namespace: "default", + ConsensusClient: validArgs.ConsensusClient, + } + + err = invalidArgs3.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "executionClient is required") + + // Test with missing consensus client + invalidArgs4 := EthereumNodeArgs{ + Name: "test-node", + Namespace: "default", + ExecutionClient: validArgs.ExecutionClient, + } + + err = invalidArgs4.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "consensusClient is required") + + // Test with invalid execution client + invalidExecutionClient := &execution.ExecutionClientArgs{ + // Missing required fields + } + invalidArgs5 := EthereumNodeArgs{ + Name: "test-node", + Namespace: "default", + ExecutionClient: invalidExecutionClient, + ConsensusClient: validArgs.ConsensusClient, + } + + err = invalidArgs5.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "execution client validation failed") + + // Test with invalid consensus client + invalidConsensusClient := &consensus.ConsensusClientArgs{ + // Missing required fields + } + invalidArgs6 := EthereumNodeArgs{ + Name: "test-node", + Namespace: "default", + ExecutionClient: validArgs.ExecutionClient, + ConsensusClient: invalidConsensusClient, + } + + err = invalidArgs6.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "consensus client validation failed") +} diff --git a/pkg/pylon/constants.go b/pkg/pylon/constants.go new file mode 100644 index 0000000..42f4729 --- /dev/null +++ b/pkg/pylon/constants.go @@ -0,0 +1,25 @@ +package pylon + +// Storage constants +const ( + ExecutionClientStorageSize = "150Gi" + ConsensusClientStorageSize = "100Gi" + StorageClassAWSGP3 = "aws-gp3" +) + +// Port constants +const ( + ExecutionP2PPort = 30303 + ExecutionRPCPort = 8545 + ExecutionWSPort = 8546 + ExecutionMetricsPort = 9001 + ExecutionAuthRPCPort = 8551 + ConsensusBeaconAPIPort = 4000 + ConsensusMetricsPort = 5054 +) + +// Image constants +const ( + ConsensusClientImage = "sigp/lighthouse:latest" + ImagePullPolicyAlways = "Always" +) diff --git a/pkg/pylon/helpers.go b/pkg/pylon/helpers.go new file mode 100644 index 0000000..68068df --- /dev/null +++ b/pkg/pylon/helpers.go @@ -0,0 +1 @@ +package pylon diff --git a/pkg/pylon/pylon.go b/pkg/pylon/pylon.go new file mode 100644 index 0000000..cdadf3e --- /dev/null +++ b/pkg/pylon/pylon.go @@ -0,0 +1,93 @@ +package pylon + +import ( + "fmt" + + "github.com/init4tech/signet-infra-components/pkg/ethereum" + "github.com/init4tech/signet-infra-components/pkg/ethereum/consensus" + "github.com/init4tech/signet-infra-components/pkg/ethereum/execution" + "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/s3" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +func NewPylonComponent(ctx *pulumi.Context, args *PylonComponentArgs, opts ...pulumi.ResourceOption) (*PylonComponent, error) { + if err := args.Validate(); err != nil { + return nil, fmt.Errorf("invalid pylon component args: %w", err) + } + + component := &PylonComponent{} + + err := ctx.RegisterComponentResource("signet:index:Pylon", args.Name, component, opts...) + if err != nil { + return nil, err + } + + // Convert public args to internal args + internalArgs := args.toInternal() + + stack := ctx.Stack() + + // Get the existing Route53 hosted zone for signet.sh + dbProjectName := internalArgs.DbProjectName + dbStackName := fmt.Sprintf("%s/%s", dbProjectName, stack) + + // TODO: this should be a stack reference to the pylon db stack- should this be an arg? + // Need to think about how i want to handle the separation of the pylon db and pylon components + thePylonDbStack, err := pulumi.NewStackReference(ctx, dbStackName, nil) + if err != nil { + return nil, err + } + + // Get the database cluster endpoint (unused for now but needed for future implementation) + _ = thePylonDbStack.GetStringOutput(pulumi.String("dbClusterEndpoint")) + + // Create the S3 bucket for blob storage (unused for now but needed for future implementation) + _, err = s3.NewBucketV2(ctx, "pylon-blob-bucket", &s3.BucketV2Args{ + Bucket: internalArgs.PylonBlobBucketName, + }) + if err != nil { + return nil, err + } + + // Convert environment to internal type for use with ethereum components + internalEnv := args.Env.toInternal() + + // Create Ethereum node component + ethereumNodeArgs := ðereum.EthereumNodeArgs{ + Name: args.Name, + Namespace: args.Namespace, + ExecutionClient: &execution.ExecutionClientArgs{ + Name: args.Name, + Namespace: args.Namespace, + StorageSize: ExecutionClientStorageSize, + StorageClass: StorageClassAWSGP3, + Image: args.PylonImage, + JWTSecret: args.ExecutionJwt, + P2PPort: ExecutionP2PPort, + RPCPort: ExecutionRPCPort, + WSPort: ExecutionWSPort, + MetricsPort: ExecutionMetricsPort, + AuthRPCPort: ExecutionAuthRPCPort, + DiscoveryPort: ExecutionP2PPort, + ExecutionClientEnv: internalEnv, + }, + ConsensusClient: &consensus.ConsensusClientArgs{ + Name: args.Name, + Namespace: args.Namespace, + StorageSize: ConsensusClientStorageSize, + StorageClass: StorageClassAWSGP3, + Image: ConsensusClientImage, + ImagePullPolicy: ImagePullPolicyAlways, + BeaconAPIPort: ConsensusBeaconAPIPort, + MetricsPort: ConsensusMetricsPort, + }, + } + + ethereumNode, err := ethereum.NewEthereumNodeComponent(ctx, ethereumNodeArgs, pulumi.Parent(component)) + if err != nil { + return nil, err + } + component.EthereumNode = ethereumNode + + return component, nil +} diff --git a/pkg/pylon/types.go b/pkg/pylon/types.go new file mode 100644 index 0000000..c7a932c --- /dev/null +++ b/pkg/pylon/types.go @@ -0,0 +1,118 @@ +package pylon + +import ( + "strconv" + + "github.com/init4tech/signet-infra-components/pkg/ethereum" + "github.com/init4tech/signet-infra-components/pkg/utils" + v1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +// Public-facing structs with base Go types +type PylonComponentArgs struct { + Namespace string + Name string + DbProjectName string + ExecutionJwt string + PylonImage string + PylonBlobBucketName string + Env PylonEnv +} + +// Internal structs with Pulumi types for use within the component +type pylonComponentArgsInternal struct { + Namespace pulumi.StringInput + Name pulumi.StringInput + DbProjectName pulumi.StringInput + ExecutionJwt pulumi.StringInput + PylonImage pulumi.StringInput + PylonBlobBucketName pulumi.StringInput + Env pylonEnvInternal +} + +// Public-facing environment struct with base Go types +type PylonEnv struct { + PylonStartBlock int `pulumi:"pylonStartBlock" validate:"required"` + PylonS3Url string `pulumi:"pylonS3Url" validate:"required"` + PylonS3Region string `pulumi:"pylonS3Region" validate:"required"` + PylonSenderAddress string `pulumi:"pylonSenderAddress" validate:"required"` + PylonNetworkSlotDuration int `pulumi:"pylonNetworkSlotDuration" validate:"required"` + PylonNetworkSlotOffset int `pulumi:"pylonNetworkSlotOffset" validate:"required"` + PylonRequestsPerSecond int `pulumi:"pylonRequestsPerSecond" validate:"required"` + PylonRustLog string `pulumi:"pylonRustLog"` + PylonPort int `pulumi:"pylonPort" validate:"required"` + AwsAccessKeyId string `pulumi:"awsAccessKeyId" validate:"required"` + AwsSecretAccessKey string `pulumi:"awsSecretAccessKey" validate:"required"` + AwsRegion string `pulumi:"awsRegion" validate:"required"` + PylonDbUrl string `pulumi:"pylonDbUrl" validate:"required"` + PylonConsensusClientUrl string `pulumi:"pylonConsensusClientUrl" validate:"required"` + PylonBlobscanBaseUrl string `pulumi:"pylonBlobscanBaseUrl" validate:"required"` + PylonNetworkStartTimestamp int `pulumi:"pylonNetworkStartTimestamp" validate:"required"` +} + +// Internal environment struct with Pulumi types +type pylonEnvInternal struct { + PylonStartBlock pulumi.StringInput `pulumi:"pylonStartBlock" validate:"required"` + PylonS3Url pulumi.StringInput `pulumi:"pylonS3Url" validate:"required"` + PylonS3Region pulumi.StringInput `pulumi:"pylonS3Region" validate:"required"` + PylonSenderAddress pulumi.StringInput `pulumi:"pylonSenderAddress" validate:"required"` + PylonNetworkSlotDuration pulumi.StringInput `pulumi:"pylonNetworkSlotDuration" validate:"required"` + PylonNetworkSlotOffset pulumi.StringInput `pulumi:"pylonNetworkSlotOffset" validate:"required"` + PylonRequestsPerSecond pulumi.StringInput `pulumi:"pylonRequestsPerSecond" validate:"required"` + PylonRustLog pulumi.StringInput `pulumi:"pylonRustLog"` + PylonPort pulumi.StringInput `pulumi:"pylonPort" validate:"required"` + AwsAccessKeyId pulumi.StringInput `pulumi:"awsAccessKeyId" validate:"required"` + AwsSecretAccessKey pulumi.StringInput `pulumi:"awsSecretAccessKey" validate:"required"` + AwsRegion pulumi.StringInput `pulumi:"awsRegion" validate:"required"` + PylonDbUrl pulumi.StringInput `pulumi:"pylonDbUrl" validate:"required"` + PylonConsensusClientUrl pulumi.StringInput `pulumi:"pylonConsensusClientUrl" validate:"required"` + PylonBlobscanBaseUrl pulumi.StringInput `pulumi:"pylonBlobscanBaseUrl" validate:"required"` + PylonNetworkStartTimestamp pulumi.StringInput `pulumi:"pylonNetworkStartTimestamp" validate:"required"` +} + +// Conversion function to convert public args to internal args +func (args PylonComponentArgs) toInternal() pylonComponentArgsInternal { + return pylonComponentArgsInternal{ + Namespace: pulumi.String(args.Namespace), + Name: pulumi.String(args.Name), + DbProjectName: pulumi.String(args.DbProjectName), + ExecutionJwt: pulumi.String(args.ExecutionJwt), + PylonImage: pulumi.String(args.PylonImage), + PylonBlobBucketName: pulumi.String(args.PylonBlobBucketName), + Env: args.Env.toInternal(), + } +} + +// Conversion function to convert public env to internal env +func (e PylonEnv) toInternal() pylonEnvInternal { + return pylonEnvInternal{ + PylonStartBlock: pulumi.String(strconv.Itoa(e.PylonStartBlock)), + PylonS3Url: pulumi.String(e.PylonS3Url), + PylonS3Region: pulumi.String(e.PylonS3Region), + PylonSenderAddress: pulumi.String(e.PylonSenderAddress), + PylonNetworkSlotDuration: pulumi.String(strconv.Itoa(e.PylonNetworkSlotDuration)), + PylonNetworkSlotOffset: pulumi.String(strconv.Itoa(e.PylonNetworkSlotOffset)), + PylonRequestsPerSecond: pulumi.String(strconv.Itoa(e.PylonRequestsPerSecond)), + PylonRustLog: pulumi.String(e.PylonRustLog), + PylonPort: pulumi.String(strconv.Itoa(e.PylonPort)), + AwsAccessKeyId: pulumi.String(e.AwsAccessKeyId), + AwsSecretAccessKey: pulumi.String(e.AwsSecretAccessKey), + AwsRegion: pulumi.String(e.AwsRegion), + PylonDbUrl: pulumi.String(e.PylonDbUrl), + PylonConsensusClientUrl: pulumi.String(e.PylonConsensusClientUrl), + PylonBlobscanBaseUrl: pulumi.String(e.PylonBlobscanBaseUrl), + PylonNetworkStartTimestamp: pulumi.String(strconv.Itoa(e.PylonNetworkStartTimestamp)), + } +} + +// GetEnvMap implements the utils.EnvProvider interface for internal env +func (e pylonEnvInternal) GetEnvMap() pulumi.StringMap { + return utils.CreateEnvMap(e) +} + +type PylonComponent struct { + pulumi.ResourceState + EthereumNode *ethereum.EthereumNodeComponent + PylonEnvConfigMap *v1.ConfigMap +} diff --git a/pkg/pylon/validation.go b/pkg/pylon/validation.go new file mode 100644 index 0000000..42414a9 --- /dev/null +++ b/pkg/pylon/validation.go @@ -0,0 +1,136 @@ +package pylon + +import ( + "fmt" + + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +// Validate validates the PylonComponentArgs struct +func (args *PylonComponentArgs) Validate() error { + if args.Namespace == "" { + return fmt.Errorf("namespace is required") + } + + if args.Name == "" { + return fmt.Errorf("name is required") + } + + if args.DbProjectName == "" { + return fmt.Errorf("dbProjectName is required") + } + + if args.ExecutionJwt == "" { + return fmt.Errorf("executionJwt is required") + } + + if args.PylonImage == "" { + return fmt.Errorf("pylonImage is required") + } + + if args.PylonBlobBucketName == "" { + return fmt.Errorf("pylonBlobBucketName is required") + } + + if err := validateEnv(args.Env); err != nil { + return err + } + + return nil +} + +func ValidatePylon(ctx *pulumi.Context, args *PylonComponentArgs) error { + if args.Namespace == "" { + return fmt.Errorf("namespace is required") + } + + if args.Name == "" { + return fmt.Errorf("name is required") + } + + if args.DbProjectName == "" { + return fmt.Errorf("dbProjectName is required") + } + + if args.ExecutionJwt == "" { + return fmt.Errorf("executionJwt is required") + } + + if args.PylonImage == "" { + return fmt.Errorf("pylonImage is required") + } + + if args.PylonBlobBucketName == "" { + return fmt.Errorf("pylonBlobBucketName is required") + } + + if err := validateEnv(args.Env); err != nil { + return err + } + + return nil +} + +func validateEnv(env PylonEnv) error { + if env.PylonStartBlock == 0 { + return fmt.Errorf("pylonStartBlock is required") + } + + if env.PylonSenderAddress == "" { + return fmt.Errorf("pylonSenderAddress is required") + } + + if env.PylonS3Url == "" { + return fmt.Errorf("pylonS3Url is required") + } + + if env.PylonS3Region == "" { + return fmt.Errorf("pylonS3Region is required") + } + + if env.PylonConsensusClientUrl == "" { + return fmt.Errorf("pylonConsensusClientUrl is required") + } + + if env.PylonBlobscanBaseUrl == "" { + return fmt.Errorf("pylonBlobscanBaseUrl is required") + } + + if env.PylonNetworkStartTimestamp == 0 { + return fmt.Errorf("pylonNetworkStartTimestamp is required") + } + + if env.PylonNetworkSlotDuration == 0 { + return fmt.Errorf("pylonNetworkSlotDuration is required") + } + + if env.PylonNetworkSlotOffset < 0 { + return fmt.Errorf("pylonNetworkSlotOffset must be a non-negative integer") + } + + if env.PylonRequestsPerSecond == 0 { + return fmt.Errorf("pylonRequestsPerSecond is required") + } + + if env.PylonPort == 0 { + return fmt.Errorf("pylonPort is required") + } + + if env.AwsAccessKeyId == "" { + return fmt.Errorf("awsAccessKeyId is required") + } + + if env.AwsSecretAccessKey == "" { + return fmt.Errorf("awsSecretAccessKey is required") + } + + if env.AwsRegion == "" { + return fmt.Errorf("awsRegion is required") + } + + if env.PylonDbUrl == "" { + return fmt.Errorf("pylonDbUrl is required") + } + + return nil +} diff --git a/pkg/pylon/validation_test.go b/pkg/pylon/validation_test.go new file mode 100644 index 0000000..2f73e81 --- /dev/null +++ b/pkg/pylon/validation_test.go @@ -0,0 +1,123 @@ +package pylon + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPylonComponentArgsValidate(t *testing.T) { + // Test with valid args + validArgs := PylonComponentArgs{ + Name: "test-pylon", + Namespace: "default", + DbProjectName: "test-db-project", + ExecutionJwt: "test-jwt", + PylonImage: "test-image:latest", + PylonBlobBucketName: "test-bucket", + Env: PylonEnv{ + PylonStartBlock: 1000, + PylonS3Url: "https://s3.example.com", + PylonS3Region: "us-west-2", + PylonSenderAddress: "0x1234567890123456789012345678901234567890", + PylonNetworkSlotDuration: 12, + PylonNetworkSlotOffset: 0, + PylonRequestsPerSecond: 100, + PylonPort: 8080, + AwsAccessKeyId: "test-access-key", + AwsSecretAccessKey: "test-secret-key", + AwsRegion: "us-west-2", + PylonDbUrl: "postgresql://test:test@localhost:5432/test", + PylonConsensusClientUrl: "http://consensus:5052", + PylonBlobscanBaseUrl: "http://blobscan:3000", + PylonNetworkStartTimestamp: 1234567890, + }, + } + + err := validArgs.Validate() + assert.NoError(t, err) + + // Test with missing name + invalidArgs1 := PylonComponentArgs{ + Namespace: "default", + DbProjectName: "test-db-project", + ExecutionJwt: "test-jwt", + PylonImage: "test-image:latest", + PylonBlobBucketName: "test-bucket", + Env: validArgs.Env, + } + + err = invalidArgs1.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "name is required") + + // Test with missing namespace + invalidArgs2 := PylonComponentArgs{ + Name: "test-pylon", + DbProjectName: "test-db-project", + ExecutionJwt: "test-jwt", + PylonImage: "test-image:latest", + PylonBlobBucketName: "test-bucket", + Env: validArgs.Env, + } + + err = invalidArgs2.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "namespace is required") + + // Test with missing dbProjectName + invalidArgs3 := PylonComponentArgs{ + Name: "test-pylon", + Namespace: "default", + ExecutionJwt: "test-jwt", + PylonImage: "test-image:latest", + PylonBlobBucketName: "test-bucket", + Env: validArgs.Env, + } + + err = invalidArgs3.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "dbProjectName is required") + + // Test with missing executionJwt + invalidArgs4 := PylonComponentArgs{ + Name: "test-pylon", + Namespace: "default", + DbProjectName: "test-db-project", + PylonImage: "test-image:latest", + PylonBlobBucketName: "test-bucket", + Env: validArgs.Env, + } + + err = invalidArgs4.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "executionJwt is required") + + // Test with missing pylonImage + invalidArgs5 := PylonComponentArgs{ + Name: "test-pylon", + Namespace: "default", + DbProjectName: "test-db-project", + ExecutionJwt: "test-jwt", + PylonBlobBucketName: "test-bucket", + Env: validArgs.Env, + } + + err = invalidArgs5.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "pylonImage is required") + + // Test with missing pylonBlobBucketName + invalidArgs6 := PylonComponentArgs{ + Name: "test-pylon", + Namespace: "default", + DbProjectName: "test-db-project", + ExecutionJwt: "test-jwt", + PylonImage: "test-image:latest", + Env: validArgs.Env, + } + + err = invalidArgs6.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "pylonBlobBucketName is required") +}