diff --git a/cmd/init.go b/cmd/init.go index e5f02df2..6d00a362 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -82,6 +82,15 @@ func initCommon(args []string) error { if err := validateIPFSMode(initOptions.IPFSMode); err != nil { return err } + if err := validateConsensus(initOptions.Consensus); err != nil { + return err + } + if err := validatePrivateTransactionManagerSelection(initOptions.PrivateTransactionManager, initOptions.BlockchainNodeProvider); err != nil { + return err + } + if err := validatePrivateTransactionManagerBlockchainConnectorCombination(initOptions.PrivateTransactionManager, initOptions.BlockchainConnector); err != nil { + return err + } fmt.Println("initializing new FireFly stack...") @@ -200,6 +209,57 @@ func validateBlockchainProvider(providerString, nodeString string) error { return nil } +func validateConsensus(consensusString string) error { + v, err := fftypes.FFEnumParseString(context.Background(), types.Consensus, consensusString) + if err != nil { + return nil + } + + if v != types.ConsensusClique { + return errors.New("currently only Clique consensus is supported") + } + + return nil +} + +func validatePrivateTransactionManagerSelection(privateTransactionManagerInput string, nodeString string) error { + privateTransactionManager, err := fftypes.FFEnumParseString(context.Background(), types.PrivateTransactionManager, privateTransactionManagerInput) + if err != nil { + return err + } + + if !privateTransactionManager.Equals(types.PrivateTransactionManagerNone) { + v, err := fftypes.FFEnumParseString(context.Background(), types.BlockchainNodeProvider, nodeString) + if err != nil { + return nil + } + + if v != types.BlockchainNodeProviderQuorum { + return errors.New("private transaction manager can only be enabled if blockchain node provider is Quorum") + } + } + return nil +} + +func validatePrivateTransactionManagerBlockchainConnectorCombination(privateTransactionManagerInput string, blockchainConnectorInput string) error { + privateTransactionManager, err := fftypes.FFEnumParseString(context.Background(), types.PrivateTransactionManager, privateTransactionManagerInput) + if err != nil { + return err + } + + blockchainConnector, err := fftypes.FFEnumParseString(context.Background(), types.BlockchainConnector, blockchainConnectorInput) + if err != nil { + return nil + } + + if !privateTransactionManager.Equals(types.PrivateTransactionManagerNone) { + if !blockchainConnector.Equals(types.BlockchainConnectorEthconnect) { + return errors.New("currently only Ethconnect blockchain connector is supported with a private transaction manager") + } + } + return nil +} + func validateTokensProvider(input []string, blockchainNodeProviderInput string) error { tokenProviders := make([]fftypes.FFEnum, len(input)) for i, t := range input { @@ -246,10 +306,13 @@ func randomHexString(length int) (string, error) { func init() { initCmd.PersistentFlags().IntVarP(&initOptions.FireFlyBasePort, "firefly-base-port", "p", 5000, "Mapped port base of FireFly core API (1 added for each member)") initCmd.PersistentFlags().IntVarP(&initOptions.ServicesBasePort, "services-base-port", "s", 5100, "Mapped port base of services (100 added for each member)") + initCmd.PersistentFlags().IntVar(&initOptions.PtmBasePort, "ptm-base-port", 4100, "Mapped port base of private transaction manager (10 added for each member)") initCmd.PersistentFlags().StringVarP(&initOptions.DatabaseProvider, "database", "d", "sqlite3", fmt.Sprintf("Database type to use. Options are: %v", fftypes.FFEnumValues(types.DatabaseSelection))) initCmd.Flags().StringVarP(&initOptions.BlockchainConnector, "blockchain-connector", "c", "evmconnect", fmt.Sprintf("Blockchain connector to use. Options are: %v", fftypes.FFEnumValues(types.BlockchainConnector))) initCmd.Flags().StringVarP(&initOptions.BlockchainProvider, "blockchain-provider", "b", "ethereum", fmt.Sprintf("Blockchain to use. Options are: %v", fftypes.FFEnumValues(types.BlockchainProvider))) initCmd.Flags().StringVarP(&initOptions.BlockchainNodeProvider, "blockchain-node", "n", "geth", fmt.Sprintf("Blockchain node type to use. Options are: %v", fftypes.FFEnumValues(types.BlockchainNodeProvider))) + initCmd.PersistentFlags().StringVar(&initOptions.PrivateTransactionManager, "private-transaction-manager", "none", fmt.Sprintf("Private Transaction Manager to use. Options are: %v", fftypes.FFEnumValues(types.PrivateTransactionManager))) + initCmd.PersistentFlags().StringVar(&initOptions.Consensus, "consensus", "clique", fmt.Sprintf("Consensus algorithm to use. Options are %v", fftypes.FFEnumValues(types.Consensus))) initCmd.PersistentFlags().StringArrayVarP(&initOptions.TokenProviders, "token-providers", "t", []string{"erc20_erc721"}, fmt.Sprintf("Token providers to use. Options are: %v", fftypes.FFEnumValues(types.TokenProvider))) initCmd.PersistentFlags().IntVarP(&initOptions.ExternalProcesses, "external", "e", 0, "Manage a number of FireFly core processes outside of the docker-compose stack - useful for development and debugging") initCmd.PersistentFlags().StringVarP(&initOptions.FireFlyVersion, "release", "r", "latest", fmt.Sprintf("Select the FireFly release version to use. Options are: %v", fftypes.FFEnumValues(types.ReleaseChannelSelection))) @@ -271,5 +334,6 @@ func init() { initCmd.PersistentFlags().StringArrayVar(&initOptions.OrgNames, "org-name", []string{}, "Organization name") initCmd.PersistentFlags().StringArrayVar(&initOptions.NodeNames, "node-name", []string{}, "Node name") initCmd.PersistentFlags().BoolVar(&initOptions.RemoteNodeDeploy, "remote-node-deploy", false, "Enable or disable deployment of FireFly contracts on remote nodes") + initCmd.PersistentFlags().StringToStringVar(&initOptions.EnvironmentVars, "environment-vars", map[string]string{}, "Common environment variables to set on all containers in FireFly stack") rootCmd.AddCommand(initCmd) } diff --git a/internal/blockchain/ethereum/besu/besu_provider.go b/internal/blockchain/ethereum/besu/besu_provider.go index 261594e7..158e075d 100644 --- a/internal/blockchain/ethereum/besu/besu_provider.go +++ b/internal/blockchain/ethereum/besu/besu_provider.go @@ -172,7 +172,8 @@ func (p *BesuProvider) GetDockerServiceDefinitions() []*docker.ServiceDefinition Volumes: []string{ "besu:/data", }, - Logging: docker.StandardLogOptions, + Logging: docker.StandardLogOptions, + Environment: p.stack.EnvironmentVars, }, VolumeNames: []string{"besu"}, diff --git a/internal/blockchain/ethereum/connector/ethconnect/config.go b/internal/blockchain/ethereum/connector/ethconnect/config.go index f15f3444..03df319c 100644 --- a/internal/blockchain/ethereum/connector/ethconnect/config.go +++ b/internal/blockchain/ethereum/connector/ethconnect/config.go @@ -19,6 +19,7 @@ package ethconnect import ( "fmt" "os" + "path/filepath" "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum/connector" "github.com/hyperledger/firefly-cli/pkg/types" @@ -58,6 +59,10 @@ type HTTP struct { func (e *Config) WriteConfig(filename string, extraConnectorConfigPath string) error { configYamlBytes, _ := yaml.Marshal(e) + basedir := filepath.Dir(filename) + if err := os.MkdirAll(basedir, 0755); err != nil { + return err + } if err := os.WriteFile(filename, configYamlBytes, 0755); err != nil { return err } diff --git a/internal/blockchain/ethereum/connector/ethconnect/docker.go b/internal/blockchain/ethereum/connector/ethconnect/docker.go index f04448d9..55858098 100644 --- a/internal/blockchain/ethereum/connector/ethconnect/docker.go +++ b/internal/blockchain/ethereum/connector/ethconnect/docker.go @@ -42,7 +42,8 @@ func (e *Ethconnect) GetServiceDefinitions(s *types.Stack, dependentServices map fmt.Sprintf("ethconnect_config_%s:/ethconnect/config", member.ID), fmt.Sprintf("ethconnect_data_%s:/ethconnect/data", member.ID), }, - Logging: docker.StandardLogOptions, + Logging: docker.StandardLogOptions, + Environment: s.EnvironmentVars, }, VolumeNames: []string{ fmt.Sprintf("ethconnect_config_%v", member.ID), diff --git a/internal/blockchain/ethereum/connector/ethconnect/docker_test.go b/internal/blockchain/ethereum/connector/ethconnect/docker_test.go index 044bbb6c..fec8444b 100644 --- a/internal/blockchain/ethereum/connector/ethconnect/docker_test.go +++ b/internal/blockchain/ethereum/connector/ethconnect/docker_test.go @@ -75,6 +75,19 @@ func TestGetServiceDefinition(t *testing.T) { }, ServiceName: "ethconnect_firefly_4", }, + { + Name: "test_env_vars_5", + Members: &types.Stack{ + Members: []*types.Organization{{ID: "firefly_5", ExposedConnectorPort: 7892}}, + VersionManifest: &types.VersionManifest{Ethconnect: &getManifest.ManifestEntry}, + EnvironmentVars: map[string]interface{}{"HTTP_PROXY": ""}, + }, + DependentServices: map[string]string{ + "service1": "running", + "service2": "stopped", + }, + ServiceName: "ethconnect_firefly_5", + }, } for _, tc := range testServices { t.Run(tc.Name, func(t *testing.T) { diff --git a/internal/blockchain/ethereum/connector/evmconnect/config.go b/internal/blockchain/ethereum/connector/evmconnect/config.go index 3b5beab0..877a9c23 100644 --- a/internal/blockchain/ethereum/connector/evmconnect/config.go +++ b/internal/blockchain/ethereum/connector/evmconnect/config.go @@ -19,6 +19,7 @@ package evmconnect import ( "fmt" "os" + "path/filepath" "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum/connector" "github.com/hyperledger/firefly-cli/pkg/types" @@ -75,6 +76,10 @@ type GasOracleConfig struct { func (e *Config) WriteConfig(filename string, extraEvmconnectConfigPath string) error { configYamlBytes, _ := yaml.Marshal(e) + basedir := filepath.Dir(filename) + if err := os.MkdirAll(basedir, 0755); err != nil { + return err + } if err := os.WriteFile(filename, configYamlBytes, 0755); err != nil { return err } diff --git a/internal/blockchain/ethereum/connector/evmconnect/docker.go b/internal/blockchain/ethereum/connector/evmconnect/docker.go index 07ad626c..99675f30 100644 --- a/internal/blockchain/ethereum/connector/evmconnect/docker.go +++ b/internal/blockchain/ethereum/connector/evmconnect/docker.go @@ -43,7 +43,8 @@ func (e *Evmconnect) GetServiceDefinitions(s *types.Stack, dependentServices map fmt.Sprintf("%s/config/evmconnect_%s.yaml:/evmconnect/config.yaml", s.RuntimeDir, member.ID), fmt.Sprintf("%s:/evmconnect/data", dataVolumeName), }, - Logging: docker.StandardLogOptions, + Logging: docker.StandardLogOptions, + Environment: s.EnvironmentVars, }, VolumeNames: []string{ dataVolumeName, diff --git a/internal/blockchain/ethereum/connector/evmconnect/docker_test.go b/internal/blockchain/ethereum/connector/evmconnect/docker_test.go index 52bb2199..c872d4d4 100644 --- a/internal/blockchain/ethereum/connector/evmconnect/docker_test.go +++ b/internal/blockchain/ethereum/connector/evmconnect/docker_test.go @@ -75,6 +75,19 @@ func TestGetServiceDefinition(t *testing.T) { }, ServiceName: "evmconnect_firefly_4", }, + { + Name: "test_env_vars_5", + Members: &types.Stack{ + Members: []*types.Organization{{ID: "firefly_5", ExposedConnectorPort: 7892}}, + VersionManifest: &types.VersionManifest{Evmconnect: &getManifest.ManifestEntry}, + EnvironmentVars: map[string]interface{}{"HTTP_PROXY": ""}, + }, + DependentServices: map[string]string{ + "service1": "running", + "service2": "stopped", + }, + ServiceName: "evmconnect_firefly_5", + }, } for _, tc := range testServices { t.Run(tc.Name, func(t *testing.T) { @@ -90,7 +103,12 @@ func TestGetServiceDefinition(t *testing.T) { if serviceDefinitions[0].ServiceName != tc.ServiceName { t.Errorf("Expected ServiceName %q, got %q", tc.ServiceName, serviceDefinitions[0].ServiceName) } - + if len(tc.Members.EnvironmentVars) != len(serviceDefinitions[0].Service.Environment) { + t.Errorf("Expected EnvironmentVars %q, got %q", tc.Members.EnvironmentVars, serviceDefinitions[0].Service.Environment) + } + for k := range tc.Members.EnvironmentVars { + assert.Equal(t, tc.Members.EnvironmentVars[k], serviceDefinitions[0].Service.Environment[k]) + } }) } diff --git a/internal/blockchain/ethereum/ethereum.go b/internal/blockchain/ethereum/ethereum.go index 80d4383e..4e8de22c 100644 --- a/internal/blockchain/ethereum/ethereum.go +++ b/internal/blockchain/ethereum/ethereum.go @@ -32,8 +32,9 @@ import ( ) type Account struct { - Address string `json:"address"` - PrivateKey string `json:"privateKey"` + Address string `json:"address"` + PrivateKey string `json:"privateKey"` + PtmPublicKey string `json:"ptmPublicKey"` // Public key used for Tessera } func GenerateAddressAndPrivateKey() (address string, privateKey string) { diff --git a/internal/blockchain/ethereum/ethsigner/ethsigner.go b/internal/blockchain/ethereum/ethsigner/ethsigner.go index 07098a73..643166f2 100644 --- a/internal/blockchain/ethereum/ethsigner/ethsigner.go +++ b/internal/blockchain/ethereum/ethsigner/ethsigner.go @@ -160,7 +160,8 @@ func (p *EthSignerProvider) GetDockerServiceDefinition(rpcURL string) *docker.Se Interval: "15s", // 6000 requests in a day Retries: 60, }, - Ports: []string{fmt.Sprintf("%d:8545", p.stack.ExposedBlockchainPort)}, + Ports: []string{fmt.Sprintf("%d:8545", p.stack.ExposedBlockchainPort)}, + Environment: p.stack.EnvironmentVars, }, VolumeNames: []string{ "ethsigner", diff --git a/internal/blockchain/ethereum/geth/geth_provider.go b/internal/blockchain/ethereum/geth/geth_provider.go index 9acead70..9b5a8af5 100644 --- a/internal/blockchain/ethereum/geth/geth_provider.go +++ b/internal/blockchain/ethereum/geth/geth_provider.go @@ -190,6 +190,7 @@ func (p *GethProvider) GetDockerServiceDefinitions() []*docker.ServiceDefinition Volumes: []string{"geth:/data"}, Logging: docker.StandardLogOptions, Ports: []string{fmt.Sprintf("%d:8545", p.stack.ExposedBlockchainPort)}, + Environment: p.stack.EnvironmentVars, }, VolumeNames: []string{"geth"}, } diff --git a/internal/blockchain/ethereum/quorum/client.go b/internal/blockchain/ethereum/quorum/client.go new file mode 100644 index 00000000..27d9dc69 --- /dev/null +++ b/internal/blockchain/ethereum/quorum/client.go @@ -0,0 +1,93 @@ +// Copyright © 2024 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package quorum + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type QuorumClient struct { + rpcURL string +} + +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params []interface{} `json:"params"` +} + +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Error *JSONRPCError `json:"error,omitempty"` + Result interface{} `json:"result,omitempty"` +} + +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func NewQuorumClient(rpcURL string) *QuorumClient { + return &QuorumClient{ + rpcURL: rpcURL, + } +} + +func (g *QuorumClient) UnlockAccount(address string, password string) error { + requestBody, err := json.Marshal(&JSONRPCRequest{ + JSONRPC: "2.0", + ID: 0, + Method: "personal_unlockAccount", + Params: []interface{}{address, password, 0}, + }) + if err != nil { + return err + } + req, err := http.NewRequest("POST", g.rpcURL, bytes.NewBuffer(requestBody)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != 200 { + return fmt.Errorf("%s [%d] %s", req.URL, resp.StatusCode, responseBody) + } + var rpcResponse *JSONRPCResponse + err = json.Unmarshal(responseBody, &rpcResponse) + if err != nil { + return err + } + if rpcResponse.Error != nil { + return fmt.Errorf(rpcResponse.Error.Message) + } + return nil +} diff --git a/internal/blockchain/ethereum/quorum/client_test.go b/internal/blockchain/ethereum/quorum/client_test.go new file mode 100644 index 00000000..11e42ce8 --- /dev/null +++ b/internal/blockchain/ethereum/quorum/client_test.go @@ -0,0 +1,81 @@ +package quorum + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/hyperledger/firefly-cli/internal/utils" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +func TestUnlockAccount(t *testing.T) { + tests := []struct { + Name string + RPCUrl string + Address string + Password string + StatusCode int + ApiResponse *JSONRPCResponse + }{ + { + Name: "TestUnlockAccount-1", + RPCUrl: "http://127.0.0.1:8545", + Address: "user-1", + Password: "POST", + StatusCode: 200, + ApiResponse: &JSONRPCResponse{ + JSONRPC: "2.0", + ID: 0, + Error: nil, + Result: "mock result", + }, + }, + { + Name: "TestUnlockAccountError-2", + RPCUrl: "http://127.0.0.1:8545", + Address: "user-1", + Password: "POST", + StatusCode: 200, + ApiResponse: &JSONRPCResponse{ + JSONRPC: "2.0", + ID: 0, + Error: &JSONRPCError{500, "invalid account"}, + Result: "mock result", + }, + }, + { + Name: "TestUnlockAccountHTTPError-3", + RPCUrl: "http://localhost:8545", + Address: "user-1", + Password: "POST", + StatusCode: 500, + ApiResponse: &JSONRPCResponse{ + JSONRPC: "2.0", + ID: 0, + Error: nil, + Result: "mock result", + }, + }, + } + for _, tc := range tests { + t.Run(tc.Name, func(t *testing.T) { + apiResponse, _ := json.Marshal(tc.ApiResponse) + // mockResponse + httpmock.RegisterResponder("POST", tc.RPCUrl, + httpmock.NewStringResponder(tc.StatusCode, string(apiResponse))) + client := NewQuorumClient(tc.RPCUrl) + utils.StartMockServer(t) + err := client.UnlockAccount(tc.Address, tc.Password) + utils.StopMockServer(t) + + // expect errors when returned status code != 200 or ApiResponse comes back with non nil error + if tc.StatusCode != 200 || tc.ApiResponse.Error != nil { + assert.NotNil(t, err, "expects error to be returned when either quorum returns an application error or non 200 http response") + } else { + assert.NoError(t, err, fmt.Sprintf("unable to unlock account: %v", err)) + } + }) + } +} diff --git a/internal/blockchain/ethereum/quorum/genesis.go b/internal/blockchain/ethereum/quorum/genesis.go new file mode 100644 index 00000000..e111a735 --- /dev/null +++ b/internal/blockchain/ethereum/quorum/genesis.go @@ -0,0 +1,119 @@ +// Copyright © 2024 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package quorum + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type Genesis struct { + Config *GenesisConfig `json:"config"` + Nonce string `json:"nonce"` + Timestamp string `json:"timestamp"` + ExtraData string `json:"extraData"` + GasLimit string `json:"gasLimit"` + Difficulty string `json:"difficulty"` + MixHash string `json:"mixHash"` + Coinbase string `json:"coinbase"` + Alloc map[string]*Alloc `json:"alloc"` + Number string `json:"number"` + GasUsed string `json:"gasUsed"` + ParentHash string `json:"parentHash"` +} + +type GenesisConfig struct { + ChainID int64 `json:"chainId"` + HomesteadBlock int `json:"homesteadBlock"` + Eip150Block int `json:"eip150Block"` + Eip150Hash string `json:"eip150Hash"` + Eip155Block int `json:"eip155Block"` + Eip158Block int `json:"eip158Block"` + ByzantiumBlock int `json:"byzantiumBlock"` + ConstantinopleBlock int `json:"constantinopleBlock"` + PetersburgBlock int `json:"petersburgBlock"` + IstanbulBlock int `json:"istanbulBlock"` + Clique *CliqueConfig `json:"clique"` +} + +type CliqueConfig struct { + Period int `json:"period"` + Epoch int `json:"epoch"` +} + +type Alloc struct { + Balance string `json:"balance"` +} + +func CreateGenesis(addresses []string, blockPeriod int, chainID int64) *Genesis { + if blockPeriod == -1 { + blockPeriod = 5 + } + extraData := "0x0000000000000000000000000000000000000000000000000000000000000000" + alloc := make(map[string]*Alloc) + for _, address := range addresses { + alloc[address] = &Alloc{ + Balance: "0x200000000000000000000000000000000000000000000000000000000000000", + } + extraData += address + } + extraData = strings.ReplaceAll(fmt.Sprintf("%-236s", extraData), " ", "0") + + return &Genesis{ + Config: &GenesisConfig{ + ChainID: chainID, + HomesteadBlock: 0, + Eip150Block: 0, + Eip150Hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + Eip155Block: 0, + Eip158Block: 0, + ByzantiumBlock: 0, + ConstantinopleBlock: 0, + IstanbulBlock: 0, + Clique: &CliqueConfig{ + Period: blockPeriod, + Epoch: 30000, + }, + }, + Nonce: "0x0", + Timestamp: "0x0", + ExtraData: extraData, + GasLimit: "0xE0000000", + Difficulty: "0x1", + MixHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + Coinbase: "0x0000000000000000000000000000000000000000", + Alloc: alloc, + Number: "0x0", + GasUsed: "0x0", + ParentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + } +} + +func (g *Genesis) WriteGenesisJSON(filename string) error { + genesisJSONBytes, _ := json.MarshalIndent(g, "", " ") + basedir := filepath.Dir(filename) + if err := os.MkdirAll(basedir, 0755); err != nil { + return err + } + if err := os.WriteFile(filename, genesisJSONBytes, 0755); err != nil { + return err + } + return nil +} diff --git a/internal/blockchain/ethereum/quorum/genesis_test.go b/internal/blockchain/ethereum/quorum/genesis_test.go new file mode 100644 index 00000000..59753b8e --- /dev/null +++ b/internal/blockchain/ethereum/quorum/genesis_test.go @@ -0,0 +1,169 @@ +package quorum + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreateGenesis(t *testing.T) { + testCases := []struct { + Name string + addresses []string + blockPeriod int + chainID int64 + }{ + { + Name: "testcase1", + addresses: []string{"0xAddress20", "0xAddress27"}, + blockPeriod: 28, + chainID: int64(21), + }, + { + Name: "testcase2", + addresses: []string{"0xAddress36", "0xAddress45"}, + blockPeriod: 26, + chainID: int64(98), + }, + { + Name: "testcase3", + addresses: []string{"0xAddress19", "0xAddress38", "0xAddress64", "0xAddress74"}, + blockPeriod: 40, + chainID: int64(93), + }, + { + Name: "testcase4", + addresses: []string{"0xAddress96", "0xAddress25", "0xAddress49", "0xAddress24", "0xAddress37", "0xAddress12"}, + blockPeriod: 12, + chainID: int64(5000), + }, + { + Name: "testcase5", + addresses: []string{"0xAddress62536", "0xAddress3261", "0xAddress82721"}, + blockPeriod: 14, + chainID: int64(900000), + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + genesis := CreateGenesis(tc.addresses, tc.blockPeriod, tc.chainID) + extraData := "0x0000000000000000000000000000000000000000000000000000000000000000" + alloc := make(map[string]*Alloc) + for _, address := range tc.addresses { + alloc[address] = &Alloc{ + "0x200000000000000000000000000000000000000000000000000000000000000", + } + extraData += address + } + extraData = strings.ReplaceAll(fmt.Sprintf("%-236s", extraData), " ", "0") + expectedGenesis := &Genesis{ + Config: &GenesisConfig{ + ChainID: tc.chainID, + HomesteadBlock: 0, + Eip150Hash: "0x0000000000000000000000000000000000000000000000000000000000000000", + Eip155Block: 0, + Eip158Block: 0, + ByzantiumBlock: 0, + ConstantinopleBlock: 0, + Clique: &CliqueConfig{ + Period: tc.blockPeriod, + Epoch: 30000, + }, + }, + Nonce: "0x0", + Timestamp: "0x0", + ExtraData: extraData, + GasLimit: "0xE0000000", + Difficulty: "0x1", + MixHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + Coinbase: "0x0000000000000000000000000000000000000000", + Alloc: alloc, + Number: "0x0", + GasUsed: "0x0", + ParentHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + } + // Assert that the generated Genesis is equal to the expected Genesis + assert.Equal(t, expectedGenesis, genesis, "Generated Genesis does not match the expected Genesis") + + }) + } +} + +func TestWriteGenesisJSON(t *testing.T) { + filepath := t.TempDir() + + testCases := []struct { + Name string + SampleGenesis Genesis + filename string + }{ + { + Name: "TestCase1", + SampleGenesis: Genesis{ + Config: &GenesisConfig{ + ChainID: int64(456), + Eip155Block: 0, + Eip158Block: 0, + ByzantiumBlock: 0, + ConstantinopleBlock: 0, + IstanbulBlock: 0, + Clique: &CliqueConfig{ + Period: 20, + Epoch: 2000, + }, + }, + }, + filename: filepath + "/genesis1_output.json", + }, + { + Name: "TestCase2", + SampleGenesis: Genesis{ + Config: &GenesisConfig{ + ChainID: int64(338), + ConstantinopleBlock: 0, + Eip155Block: 0, + Eip158Block: 0, + ByzantiumBlock: 0, + IstanbulBlock: 0, + Clique: &CliqueConfig{ + Period: 40, + Epoch: 4000, + }, + }, + }, + filename: filepath + "/genesis2_output.json", + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + err := tc.SampleGenesis.WriteGenesisJSON(tc.filename) + if err != nil { + t.Log("unable to write Genesis JSON", err) + } + // Assert that there is no error + assert.NoError(t, err) + + writtenJSONBytes, err := os.ReadFile(tc.filename) + if err != nil { + t.Log("Unable to write JSON Bytes", err) + } + assert.NoError(t, err) + var writtenGenesis Genesis + + err = json.Unmarshal(writtenJSONBytes, &writtenGenesis) + if err != nil { + t.Log("unable to unmarshal JSON", err) + } + assert.NoError(t, err) + + // Assert that the written Genesis matches the original Genesis + assert.Equal(t, tc.SampleGenesis, writtenGenesis) + }) + + } + +} diff --git a/internal/blockchain/ethereum/quorum/private_transaction_manager.go b/internal/blockchain/ethereum/quorum/private_transaction_manager.go new file mode 100644 index 00000000..5e40bdb2 --- /dev/null +++ b/internal/blockchain/ethereum/quorum/private_transaction_manager.go @@ -0,0 +1,169 @@ +// Copyright © 2024 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package quorum + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/hyperledger/firefly-cli/internal/docker" +) + +var DockerEntrypoint = "docker-entrypoint.sh" +var TmQ2tPort = "9101" +var TmTpPort = "9080" +var TmP2pPort = "9000" +var QuorumPort = "8545" + +type PrivateKeyData struct { + Bytes string `json:"bytes"` +} + +type PrivateKey struct { + Type string `json:"type"` + Data PrivateKeyData `json:"data"` +} + +func CreateTesseraKeys(ctx context.Context, image, outputDirectory, prefix, name string) (privateKey, pubKey, path string, err error) { + // generates both .pub and .key files used by Tessera + var filename string + if prefix != "" { + filename = fmt.Sprintf("%v_%s", prefix, name) + } else { + filename = name + } + if err := os.MkdirAll(outputDirectory, 0755); err != nil { + return "", "", "", err + } + fmt.Println("generating tessera keys") + err = docker.RunDockerCommand(ctx, outputDirectory, "run", "--rm", "-v", fmt.Sprintf("%s:/keystore", outputDirectory), image, "-keygen", "-filename", fmt.Sprintf("/keystore/%s", filename)) + if err != nil { + return "", "", "", err + } + path = fmt.Sprintf("%s/%s", outputDirectory, filename) + pubKeyBytes, err := os.ReadFile(fmt.Sprintf("%v.%s", path, "pub")) + if err != nil { + return "", "", "", err + } + privateKeyBytes, err := os.ReadFile(fmt.Sprintf("%v.%s", path, "key")) + if err != nil { + return "", "", "", err + } + var privateKeyData PrivateKey + err = json.Unmarshal(privateKeyBytes, &privateKeyData) + if err != nil { + return "", "", "", err + } + return privateKeyData.Data.Bytes, string(pubKeyBytes), path, nil +} + +func CreateTesseraEntrypoint(ctx context.Context, outputDirectory, stackName string, memberCount int) error { + // only tessera v09 onwards is supported + var sb strings.Builder + for i := 0; i < memberCount; i++ { + sb.WriteString(fmt.Sprintf("{\"url\":\"http://%s_member%dtessera:%s\"},", stackName, i, TmP2pPort)) // construct peer list + } + peerList := strings.TrimSuffix(sb.String(), ",") + content := fmt.Sprintf(`export JAVA_OPTS="-Xms128M -Xmx128M" +DDIR=/data +mkdir -p ${DDIR} +cat < ${DDIR}/tessera-config-09.json + { + "useWhiteList": false, + "jdbc": { + "username": "sa", + "password": "", + "url": "jdbc:h2:./${DDIR}/db;TRACE_LEVEL_SYSTEM_OUT=0", + "autoCreateTables": true + }, + "serverConfigs":[ + { + "app":"ThirdParty", + "enabled": true, + "serverAddress": "http://$(hostname -i):%s", + "communicationType" : "REST" + }, + { + "app":"Q2T", + "enabled": true, + "serverAddress": "http://$(hostname -i):%s", + "sslConfig": { + "tls": "OFF" + }, + "communicationType" : "REST" + }, + { + "app":"P2P", + "enabled": true, + "serverAddress": "http://$(hostname -i):%s", + "sslConfig": { + "tls": "OFF" + }, + "communicationType" : "REST" + } + ], + "peer": [ + %s + ], + "keys": { + "passwords": [], + "keyData": [ + { + "privateKeyPath": "${DDIR}/keystore/tm.key", + "publicKeyPath": "${DDIR}/keystore/tm.pub" + } + ] + }, + "alwaysSendTo": [], + "bootstrapNode": false, + "features": { + "enableRemoteKeyValidation": false, + "enablePrivacyEnhancements": true + } + } +EOF +/tessera/bin/tessera -configfile ${DDIR}/tessera-config-09.json +`, TmTpPort, TmQ2tPort, TmP2pPort, peerList) + filename := filepath.Join(outputDirectory, DockerEntrypoint) + if err := os.MkdirAll(outputDirectory, 0755); err != nil { + return err + } + file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer file.Close() + _, err = file.WriteString(content) + if err != nil { + return err + } + return nil +} + +func CopyTesseraEntrypointToVolume(ctx context.Context, tesseraEntrypointDirectory, volumeName string) error { + if err := docker.MkdirInVolume(ctx, volumeName, ""); err != nil { + return err + } + if err := docker.CopyFileToVolume(ctx, volumeName, filepath.Join(tesseraEntrypointDirectory, DockerEntrypoint), ""); err != nil { + return err + } + return nil +} diff --git a/internal/blockchain/ethereum/quorum/private_transaction_manager_test.go b/internal/blockchain/ethereum/quorum/private_transaction_manager_test.go new file mode 100644 index 00000000..938f7604 --- /dev/null +++ b/internal/blockchain/ethereum/quorum/private_transaction_manager_test.go @@ -0,0 +1,112 @@ +package quorum + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hyperledger/firefly-cli/internal/log" + "github.com/hyperledger/firefly-cli/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestCreateTesseraKeys(t *testing.T) { + ctx := log.WithVerbosity(log.WithLogger(context.Background(), &log.StdoutLogger{}), false) + testCases := []struct { + Name string + Stack *types.Stack + TesseraImage string + KeysPrefix string + KeysName string + }{ + { + Name: "testcase1", + Stack: &types.Stack{ + Name: "Org-1_quorum", + InitDir: t.TempDir(), + }, + TesseraImage: "quorumengineering/tessera:24.4", + KeysPrefix: "", + KeysName: "tm", + }, + { + Name: "testcase2", + Stack: &types.Stack{ + Name: "Org-1_quorum", + InitDir: t.TempDir(), + }, + TesseraImage: "quorumengineering/tessera:24.4", + KeysPrefix: "xyz", + KeysName: "tm", + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + privateKey, publicKey, tesseraKeysPath, err := CreateTesseraKeys(ctx, tc.TesseraImage, filepath.Join(tc.Stack.InitDir, "tessera", "tessera_0", "keystore"), tc.KeysPrefix, tc.KeysName) + if err != nil { + t.Log("unable to create tessera keys", err) + } + //validate properties of tessera keys + assert.NotEmpty(t, privateKey) + assert.NotEmpty(t, publicKey) + assert.NotEmpty(t, tesseraKeysPath) + + expectedOutputName := tc.KeysName + if tc.KeysPrefix != "" { + expectedOutputName = fmt.Sprintf("%s_%s", tc.KeysPrefix, expectedOutputName) + } + assert.Equal(t, tesseraKeysPath, filepath.Join(tc.Stack.InitDir, "tessera", "tessera_0", "keystore", expectedOutputName), "invalid output path") + + assert.Nil(t, err) + }) + } +} + +func TestCreateTesseraEntrypoint(t *testing.T) { + ctx := log.WithVerbosity(log.WithLogger(context.Background(), &log.StdoutLogger{}), false) + testCases := []struct { + Name string + Stack *types.Stack + StackName string + MemberCount int + }{ + { + Name: "testcase1", + Stack: &types.Stack{ + Name: "Org-1_quorum", + InitDir: t.TempDir(), + }, + StackName: "org1", + MemberCount: 4, + }, + { + Name: "testcase2", + Stack: &types.Stack{ + Name: "Org-2_quorum", + InitDir: t.TempDir(), + }, + StackName: "org2", + MemberCount: 0, + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + err := CreateTesseraEntrypoint(ctx, tc.Stack.InitDir, tc.StackName, tc.MemberCount) + if err != nil { + t.Log("unable to create tessera docker entrypoint", err) + } + path := filepath.Join(tc.Stack.InitDir, "docker-entrypoint.sh") + _, err = os.Stat(path) + assert.NoError(t, err, "docker entrypoint file not created") + + b, err := os.ReadFile(path) + assert.NoError(t, err, "unable to read docker entrypoint file") + for i := 0; i < tc.MemberCount; i++ { + strings.Contains(string(b), fmt.Sprintf("member%dtessera", i)) + } + }) + } +} diff --git a/internal/blockchain/ethereum/quorum/quorum.go b/internal/blockchain/ethereum/quorum/quorum.go new file mode 100644 index 00000000..06bd2fd8 --- /dev/null +++ b/internal/blockchain/ethereum/quorum/quorum.go @@ -0,0 +1,119 @@ +// Copyright © 2024 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package quorum + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/hyperledger/firefly-cli/internal/docker" + "github.com/hyperledger/firefly-cli/pkg/types" + "github.com/hyperledger/firefly-common/pkg/fftypes" +) + +func CreateQuorumEntrypoint(ctx context.Context, outputDirectory, consensus, stackName string, memberIndex, chainID, blockPeriodInSeconds int, privateTransactionManager fftypes.FFEnum) error { + discoveryCmd := "BOOTNODE_CMD=\"\"" + connectTimeout := 15 + if memberIndex != 0 { + discoveryCmd = fmt.Sprintf(`bootnode=$(curl http://quorum_0:%s -s --connect-timeout %[2]d --max-time %[2]d --retry 5 --retry-connrefused --retry-delay 0 --retry-max-time 60 --fail --header "Content-Type: application/json" --data '{"jsonrpc":"2.0", "method": "admin_nodeInfo", "params": [], "id": 1}' | grep -o "enode://[a-z0-9@.:]*") +BOOTNODE_CMD="--bootnodes $bootnode" +BOOTNODE_CMD=${BOOTNODE_CMD/127.0.0.1/quorum_0}`, QuorumPort, connectTimeout) + } + + tesseraCmd := "" + if !privateTransactionManager.Equals(types.PrivateTransactionManagerNone) { + tesseraCmd = fmt.Sprintf(`TESSERA_URL=http://%[5]s_member%[1]dtessera +TESSERA_TP_PORT=%[2]s +TESSERA_Q2T_PORT=%[3]s +TESSERA_UPCHECK_URL=$TESSERA_URL:$TESSERA_TP_PORT/upcheck +ADDITIONAL_ARGS="${ADDITIONAL_ARGS:-} --ptm.timeout 5 --ptm.url ${TESSERA_URL}:${TESSERA_Q2T_PORT} --ptm.http.writebuffersize 4096 --ptm.http.readbuffersize 4096 --ptm.tls.mode off" + +echo -n "Checking tessera is up ... " +curl --connect-timeout %[4]d --max-time %[4]d --retry 5 --retry-connrefused --retry-delay 0 --retry-max-time 60 --silent --fail "${TESSERA_UPCHECK_URL}" +echo "" +`, memberIndex, TmTpPort, TmQ2tPort, connectTimeout, stackName) + } + + blockPeriod := blockPeriodInSeconds + if blockPeriodInSeconds == -1 { + blockPeriod = 5 + } + blockPeriodInMs := blockPeriod * 60 + + content := fmt.Sprintf(`#!/bin/sh + +set -o errexit +set -o nounset +set -o pipefail +set -o xtrace + +GOQUORUM_CONS_ALGO=%[1]s +if [ "ibft" == "$GOQUORUM_CONS_ALGO" ]; +then + echo "Using istanbul for consensus algorithm..." + export CONSENSUS_ARGS="--istanbul.blockperiod %[6]d --mine --miner.threads 1 --miner.gasprice 0 --emitcheckpoints" + export QUORUM_API="istanbul" +elif [ "qbft" == "$GOQUORUM_CONS_ALGO" ]; +then + echo "Using qbft for consensus algorithm..." + export CONSENSUS_ARGS="--mine --miner.threads 1 --miner.gasprice 0 --emitcheckpoints" + export QUORUM_API="istanbul" +elif [ "raft" == "$GOQUORUM_CONS_ALGO" ]; +then + echo "Using raft for consensus algorithm..." + export CONSENSUS_ARGS="--raft --raftblocktime %[7]d --raftport 53000" + export QUORUM_API="raft" +elif [ "clique" == "$GOQUORUM_CONS_ALGO" ]; +then + echo "Using clique for consensus algorithm..." + export CONSENSUS_ARGS="" + export QUORUM_API="clique" +fi + +ADDITIONAL_ARGS=${ADDITIONAL_ARGS:-} +%[2]s + +# discovery +%[3]s +echo "bootnode discovery command :: $BOOTNODE_CMD" +IP_ADDR=$(cat /etc/hosts | tail -n 1 | awk '{print $1}') + +exec geth --datadir /data --nat extip:$IP_ADDR --syncmode 'full' --revertreason --port 30311 --http --http.addr "0.0.0.0" --http.corsdomain="*" -http.port %[4]s --http.vhosts "*" --http.api admin,personal,eth,net,web3,txpool,miner,debug,$QUORUM_API --networkid %[5]d --miner.gasprice 0 --password /data/password --mine --allow-insecure-unlock --verbosity 4 $CONSENSUS_ARGS $BOOTNODE_CMD $ADDITIONAL_ARGS`, consensus, tesseraCmd, discoveryCmd, QuorumPort, chainID, blockPeriod, blockPeriodInMs) + filename := filepath.Join(outputDirectory, DockerEntrypoint) + if err := os.MkdirAll(outputDirectory, 0755); err != nil { + return err + } + file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer file.Close() + _, err = file.WriteString(content) + if err != nil { + return err + } + return nil +} + +func CopyQuorumEntrypointToVolume(ctx context.Context, quorumEntrypointDirectory, volumeName string) error { + if err := docker.CopyFileToVolume(ctx, volumeName, filepath.Join(quorumEntrypointDirectory, DockerEntrypoint), ""); err != nil { + return err + } + return nil +} diff --git a/internal/blockchain/ethereum/quorum/quorum_provider.go b/internal/blockchain/ethereum/quorum/quorum_provider.go new file mode 100644 index 00000000..0e3052db --- /dev/null +++ b/internal/blockchain/ethereum/quorum/quorum_provider.go @@ -0,0 +1,418 @@ +// Copyright © 2024 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package quorum + +import ( + "context" + "encoding/hex" + "fmt" + "os" + "path" + "path/filepath" + "strconv" + "time" + + "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum" + "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum/connector" + "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum/connector/ethconnect" + "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum/connector/evmconnect" + "github.com/hyperledger/firefly-cli/internal/docker" + "github.com/hyperledger/firefly-cli/internal/log" + "github.com/hyperledger/firefly-cli/pkg/types" +) + +var quorumImage = "quorumengineering/quorum:24.4" +var tesseraImage = "quorumengineering/tessera:24.4" +var ExposedBlockchainPortMultiplier = 10 + +// TODO: Probably randomize this and make it different per member? +var keyPassword = "correcthorsebatterystaple" + +type QuorumProvider struct { + ctx context.Context + stack *types.Stack + connector connector.Connector + dockerMgr docker.IDockerManager +} + +func NewQuorumProvider(ctx context.Context, stack *types.Stack) *QuorumProvider { + var connector connector.Connector + switch stack.BlockchainConnector { + case types.BlockchainConnectorEthconnect: + connector = ethconnect.NewEthconnect(ctx) + case types.BlockchainConnectorEvmconnect: + connector = evmconnect.NewEvmconnect(ctx) + } + + return &QuorumProvider{ + ctx: ctx, + stack: stack, + connector: connector, + dockerMgr: docker.NewDockerManager(), + } +} + +func (p *QuorumProvider) WriteConfig(options *types.InitOptions) error { + l := log.LoggerFromContext(p.ctx) + initDir := p.stack.InitDir + for i, member := range p.stack.Members { + // Generate the connector config for each member + connectorConfigPath := filepath.Join(initDir, "config", fmt.Sprintf("%s_%v.yaml", p.connector.Name(), i)) + if err := p.connector.GenerateConfig(p.stack, member, fmt.Sprintf("quorum_%d", i)).WriteConfig(connectorConfigPath, options.ExtraConnectorConfigPath); err != nil { + return nil + } + + // Generate tessera docker-entrypoint for each member + if !p.stack.PrivateTransactionManager.Equals(types.PrivateTransactionManagerNone) { + l.Info(fmt.Sprintf("generating tessera docker-entrypoint file for member %d", i)) + tesseraEntrypointOutputDirectory := filepath.Join(initDir, "tessera", fmt.Sprintf("tessera_%d", i)) + if err := CreateTesseraEntrypoint(p.ctx, tesseraEntrypointOutputDirectory, p.stack.Name, len(p.stack.Members)); err != nil { + return err + } + } + + // Generate quorum docker-entrypoint for each member + l.Info(fmt.Sprintf("generating quorum docker-entrypoint file for member %d", i)) + quorumEntrypointOutputDirectory := filepath.Join(initDir, "blockchain", fmt.Sprintf("quorum_%d", i)) + if err := CreateQuorumEntrypoint(p.ctx, quorumEntrypointOutputDirectory, p.stack.Consensus.String(), p.stack.Name, i, int(p.stack.ChainID()), options.BlockPeriod, p.stack.PrivateTransactionManager); err != nil { + return err + } + } + + // Create genesis.json + addresses := make([]string, len(p.stack.Members)) + for i, member := range p.stack.Members { + address := member.Account.(*ethereum.Account).Address + // Drop the 0x on the front of the address here because that's what quorum is expecting in the genesis.json + addresses[i] = address[2:] + } + genesis := CreateGenesis(addresses, options.BlockPeriod, p.stack.ChainID()) + if err := genesis.WriteGenesisJSON(filepath.Join(initDir, "blockchain", "genesis.json")); err != nil { + return err + } + + return nil +} + +func (p *QuorumProvider) FirstTimeSetup() error { + quorumVolumeName := fmt.Sprintf("%s_quorum", p.stack.Name) + tesseraVolumeName := fmt.Sprintf("%s_tessera", p.stack.Name) + blockchainDir := path.Join(p.stack.RuntimeDir, "blockchain") + tesseraDir := path.Join(p.stack.RuntimeDir, "tessera") + contractsDir := path.Join(p.stack.RuntimeDir, "contracts") + rootDir := "/" + + if err := p.connector.FirstTimeSetup(p.stack); err != nil { + return err + } + + if err := os.MkdirAll(contractsDir, 0755); err != nil { + return err + } + + for i := range p.stack.Members { + // Copy connector config to each member's volume + connectorConfigPath := filepath.Join(p.stack.StackDir, "runtime", "config", fmt.Sprintf("%s_%v.yaml", p.connector.Name(), i)) + connectorConfigVolumeName := fmt.Sprintf("%s_%s_config_%v", p.stack.Name, p.connector.Name(), i) + if err := p.dockerMgr.CopyFileToVolume(p.ctx, connectorConfigVolumeName, connectorConfigPath, "config.yaml"); err != nil { + return err + } + + // Volume name instantiation + quorumVolumeNameMember := fmt.Sprintf("%s_%d", quorumVolumeName, i) + tesseraVolumeNameMember := fmt.Sprintf("%s_%d", tesseraVolumeName, i) + + // Copy the wallet files of each member to their respective blockchain volume + keystoreDirectory := filepath.Join(blockchainDir, fmt.Sprintf("quorum_%d", i), "keystore") + if err := p.dockerMgr.CopyFileToVolume(p.ctx, quorumVolumeNameMember, keystoreDirectory, "/"); err != nil { + return err + } + + if !p.stack.PrivateTransactionManager.Equals(types.PrivateTransactionManagerNone) { + // Copy member specific tessera key files + if err := p.dockerMgr.MkdirInVolume(p.ctx, tesseraVolumeNameMember, rootDir); err != nil { + return err + } + tmKeystoreDirectory := filepath.Join(tesseraDir, fmt.Sprintf("tessera_%d", i), "keystore") + if err := p.dockerMgr.CopyFileToVolume(p.ctx, tesseraVolumeNameMember, tmKeystoreDirectory, rootDir); err != nil { + return err + } + // Copy tessera docker-entrypoint file + tmEntrypointPath := filepath.Join(tesseraDir, fmt.Sprintf("tessera_%d", i), DockerEntrypoint) + if err := p.dockerMgr.CopyFileToVolume(p.ctx, tesseraVolumeNameMember, tmEntrypointPath, rootDir); err != nil { + return err + } + } + + // Copy quorum docker-entrypoint file + quorumEntrypointPath := filepath.Join(blockchainDir, fmt.Sprintf("quorum_%d", i), DockerEntrypoint) + if err := p.dockerMgr.CopyFileToVolume(p.ctx, quorumVolumeNameMember, quorumEntrypointPath, rootDir); err != nil { + return err + } + + // Copy the genesis block information + if err := p.dockerMgr.CopyFileToVolume(p.ctx, quorumVolumeNameMember, path.Join(blockchainDir, "genesis.json"), "genesis.json"); err != nil { + return err + } + + // Initialize the genesis block + if err := p.dockerMgr.RunDockerCommand(p.ctx, p.stack.StackDir, "run", "--rm", "-v", fmt.Sprintf("%s:/data", quorumVolumeNameMember), quorumImage, "--datadir", "/data", "init", "/data/genesis.json"); err != nil { + return err + } + } + + return nil +} + +func (p *QuorumProvider) PreStart() error { + return nil +} + +func (p *QuorumProvider) PostStart(firstTimeSetup bool) error { + l := log.LoggerFromContext(p.ctx) + // Unlock accounts + for _, account := range p.stack.State.Accounts { + address := account.(*ethereum.Account).Address + l.Info(fmt.Sprintf("unlocking account %s", address)) + // Check which member the account belongs to + var memberIndex int + for _, member := range p.stack.Members { + if member.Account.(*ethereum.Account).Address == address { + memberIndex = *member.Index + break + } + } + if err := p.unlockAccount(address, keyPassword, memberIndex); err != nil { + return err + } + } + + return nil +} + +func (p *QuorumProvider) unlockAccount(address, password string, memberIndex int) error { + l := log.LoggerFromContext(p.ctx) + verbose := log.VerbosityFromContext(p.ctx) + // exposed blockchain port is the default for node 0, we need to add the port multiplier to get the right rpc for the correct node + quorumClient := NewQuorumClient(fmt.Sprintf("http://127.0.0.1:%v", p.stack.ExposedBlockchainPort+(memberIndex*ExposedBlockchainPortMultiplier))) + retries := 10 + for { + if err := quorumClient.UnlockAccount(address, password); err != nil { + if verbose { + l.Debug(err.Error()) + } + if retries == 0 { + return fmt.Errorf("unable to unlock account %s", address) + } + time.Sleep(time.Second * 1) + retries-- + } else { + break + } + } + return nil +} + +func (p *QuorumProvider) DeployFireFlyContract() (*types.ContractDeploymentResult, error) { + contract, err := ethereum.ReadFireFlyContract(p.ctx, p.stack) + if err != nil { + return nil, err + } + return p.connector.DeployContract(contract, "FireFly", p.stack.Members[0], nil) +} + +func (p *QuorumProvider) GetDockerServiceDefinitions() []*docker.ServiceDefinition { + memberCount := len(p.stack.Members) + serviceDefinitionsCount := memberCount + if !p.stack.PrivateTransactionManager.Equals(types.PrivateTransactionManagerNone) { + serviceDefinitionsCount *= 2 + } + serviceDefinitions := make([]*docker.ServiceDefinition, serviceDefinitionsCount) + connectorDependents := map[string]string{} + for i := 0; i < memberCount; i++ { + var quorumDependsOn map[string]map[string]string + if !p.stack.PrivateTransactionManager.Equals(types.PrivateTransactionManagerNone) { + quorumDependsOn = map[string]map[string]string{fmt.Sprintf("tessera_%d", i): {"condition": "service_started"}} + serviceDefinitions[i+memberCount] = &docker.ServiceDefinition{ + ServiceName: fmt.Sprintf("tessera_%d", i), + Service: &docker.Service{ + Image: tesseraImage, + ContainerName: fmt.Sprintf("%s_member%dtessera", p.stack.Name, i), + Volumes: []string{fmt.Sprintf("tessera_%d:/data", i)}, + Logging: docker.StandardLogOptions, + Ports: []string{fmt.Sprintf("%d:%s", p.stack.ExposedPtmPort+(i*ExposedBlockchainPortMultiplier), TmTpPort)}, // defaults 4100, 4110, 4120, 4130 + Environment: p.stack.EnvironmentVars, + EntryPoint: []string{"/bin/sh", "-c", "/data/docker-entrypoint.sh"}, + Deploy: map[string]interface{}{"restart_policy": map[string]string{"condition": "on-failure", "max_attempts": "3"}}, + }, + VolumeNames: []string{fmt.Sprintf("tessera_%d", i)}, + } + } + serviceDefinitions[i] = &docker.ServiceDefinition{ + ServiceName: fmt.Sprintf("quorum_%d", i), + Service: &docker.Service{ + Image: quorumImage, + ContainerName: fmt.Sprintf("%s_quorum_%d", p.stack.Name, i), + Volumes: []string{fmt.Sprintf("quorum_%d:/data", i)}, + Logging: docker.StandardLogOptions, + Ports: []string{fmt.Sprintf("%d:8545", p.stack.ExposedBlockchainPort+(i*ExposedBlockchainPortMultiplier))}, // defaults 5100, 5110, 5120, 5130 + Environment: p.stack.EnvironmentVars, + EntryPoint: []string{"/bin/sh", "-c", "/data/docker-entrypoint.sh"}, + DependsOn: quorumDependsOn, + }, + VolumeNames: []string{fmt.Sprintf("quorum_%d", i)}, + } + connectorDependents[fmt.Sprintf("quorum_%d", i)] = "service_started" + } + serviceDefinitions = append(serviceDefinitions, p.connector.GetServiceDefinitions(p.stack, connectorDependents)...) + return serviceDefinitions +} + +func (p *QuorumProvider) GetBlockchainPluginConfig(stack *types.Stack, m *types.Organization) (blockchainConfig *types.BlockchainConfig) { + var connectorURL string + if m.External { + connectorURL = p.GetConnectorExternalURL(m) + } else { + connectorURL = p.GetConnectorURL(m) + } + + blockchainConfig = &types.BlockchainConfig{ + Type: "ethereum", + Ethereum: &types.EthereumConfig{ + Ethconnect: &types.EthconnectConfig{ + URL: connectorURL, + Topic: m.ID, + }, + }, + } + return +} + +func (p *QuorumProvider) GetOrgConfig(stack *types.Stack, m *types.Organization) (orgConfig *types.OrgConfig) { + account := m.Account.(*ethereum.Account) + orgConfig = &types.OrgConfig{ + Name: m.OrgName, + Key: account.Address, + } + return +} + +func (p *QuorumProvider) Reset() error { + return nil +} + +func (p *QuorumProvider) GetContracts(filename string, extraArgs []string) ([]string, error) { + contracts, err := ethereum.ReadContractJSON(filename) + if err != nil { + return []string{}, err + } + contractNames := make([]string, len(contracts.Contracts)) + i := 0 + for contractName := range contracts.Contracts { + contractNames[i] = contractName + i++ + } + return contractNames, err +} + +func (p *QuorumProvider) DeployContract(filename, contractName, instanceName string, member *types.Organization, extraArgs []string) (*types.ContractDeploymentResult, error) { + contracts, err := ethereum.ReadContractJSON(filename) + if err != nil { + return nil, err + } + return p.connector.DeployContract(contracts.Contracts[contractName], instanceName, member, extraArgs) +} + +func (p *QuorumProvider) CreateAccount(args []string) (interface{}, error) { + l := log.LoggerFromContext(p.ctx) + memberIndex := args[2] + quorumVolumeName := fmt.Sprintf("%s_quorum_%s", p.stack.Name, memberIndex) + var directory string + stackHasRunBefore, err := p.stack.HasRunBefore() + if err != nil { + return nil, err + } + if stackHasRunBefore { + directory = p.stack.RuntimeDir + } else { + directory = p.stack.InitDir + } + + prefix := strconv.FormatInt(time.Now().UnixNano(), 10) + outputDirectory := filepath.Join(directory, "blockchain", fmt.Sprintf("quorum_%s", memberIndex), "keystore") + keyPair, walletFilePath, err := ethereum.CreateWalletFile(outputDirectory, prefix, keyPassword) + if err != nil { + return nil, err + } + + // Tessera is an optional add-on to the quorum blockchain node provider + var tesseraPubKey, tesseraKeysPath string + if !p.stack.PrivateTransactionManager.Equals(types.PrivateTransactionManagerNone) { + tesseraKeysOutputDirectory := filepath.Join(directory, "tessera", fmt.Sprintf("tessera_%s", memberIndex), "keystore") + _, tesseraPubKey, tesseraKeysPath, err = CreateTesseraKeys(p.ctx, tesseraImage, tesseraKeysOutputDirectory, "", "tm") + if err != nil { + return nil, err + } + l.Info(fmt.Sprintf("keys generated in %s", tesseraKeysPath)) + } + + if stackHasRunBefore { + if err := ethereum.CopyWalletFileToVolume(p.ctx, walletFilePath, quorumVolumeName); err != nil { + return nil, err + } + if memberIndexInt, err := strconv.Atoi(memberIndex); err != nil { + return nil, err + } else { + if err := p.unlockAccount(keyPair.Address.String(), keyPassword, memberIndexInt); err != nil { + return nil, err + } + } + } + + return ðereum.Account{ + Address: keyPair.Address.String(), + PrivateKey: hex.EncodeToString(keyPair.PrivateKeyBytes()), + PtmPublicKey: tesseraPubKey, + }, nil +} + +func (p *QuorumProvider) ParseAccount(account interface{}) interface{} { + accountMap := account.(map[string]interface{}) + ptmPublicKey := "" // if we start quorum without tessera, no public key will be generated + v, ok := accountMap["ptmPublicKey"] + if ok { + ptmPublicKey = v.(string) + } + + return ðereum.Account{ + Address: accountMap["address"].(string), + PrivateKey: accountMap["privateKey"].(string), + PtmPublicKey: ptmPublicKey, + } +} + +func (p *QuorumProvider) GetConnectorName() string { + return p.connector.Name() +} + +func (p *QuorumProvider) GetConnectorURL(org *types.Organization) string { + return fmt.Sprintf("http://%s_%s:%v", p.connector.Name(), org.ID, p.connector.Port()) +} + +func (p *QuorumProvider) GetConnectorExternalURL(org *types.Organization) string { + return fmt.Sprintf("http://127.0.0.1:%v", org.ExposedConnectorPort) +} diff --git a/internal/blockchain/ethereum/quorum/quorum_provider_test.go b/internal/blockchain/ethereum/quorum/quorum_provider_test.go new file mode 100644 index 00000000..2bee16cc --- /dev/null +++ b/internal/blockchain/ethereum/quorum/quorum_provider_test.go @@ -0,0 +1,640 @@ +package quorum + +import ( + "context" + "encoding/hex" + "fmt" + "os" + "path/filepath" + + "testing" + + "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum" + "github.com/hyperledger/firefly-cli/internal/docker/mocks" + "github.com/hyperledger/firefly-cli/internal/log" + "github.com/hyperledger/firefly-cli/internal/utils" + "github.com/hyperledger/firefly-cli/pkg/types" + "github.com/hyperledger/firefly-common/pkg/fftypes" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +func TestNewQuorumProvider(t *testing.T) { + var ctx context.Context + + testcases := []struct { + Name string + Ctx context.Context + Stack *types.Stack + }{ + { + Name: "testcase1", + Ctx: ctx, + Stack: &types.Stack{ + Name: "TestQuorumWithEvmConnect", + Members: []*types.Organization{ + { + OrgName: "Org1", + NodeName: "quorum", + Account: ðereum.Account{ + Address: "0x1234567890abcdef0123456789abcdef6789abcd", + PrivateKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + }, + }, + { + OrgName: "Org2", + NodeName: "quorum", + Account: ðereum.Account{ + Address: "0x1234567890abcdef012345670000000000000000", + PrivateKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + }, + }, + }, + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockchainConnector", "evmconnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + }, + }, + { + Name: "testcase2", + Ctx: ctx, + Stack: &types.Stack{ + Name: "TestQuorumWithEthConnect", + Members: []*types.Organization{ + { + OrgName: "Org55", + NodeName: "quorum", + Account: ðereum.Account{ + Address: "0x1f2a000000000000000000000000000000000000", + PrivateKey: "aabbccddeeff0011223344556677889900112233445566778899aabbccddeeff", + }, + }, + }, + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockchainConnector", "ethconnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + }, + }, + } + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + quorumProvider := NewQuorumProvider(tc.Ctx, tc.Stack) + assert.NotNil(t, quorumProvider) + }) + } +} + +func TestParseAccount(t *testing.T) { + testcases := []struct { + Name string + Address map[string]interface{} + ExpectedAccount *ethereum.Account + }{ + { + Name: "Account 1", + Address: map[string]interface{}{ + "address": "0x1234567890abcdef0123456789abcdef6789abcd", + "privateKey": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + "ptmPublicKey": "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + ExpectedAccount: ðereum.Account{ + Address: "0x1234567890abcdef0123456789abcdef6789abcd", + PrivateKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + PtmPublicKey: "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + }, + { + Name: "Account 2", + Address: map[string]interface{}{ + "address": "0x618E98197aF52F44D1B05Af0952a59b9f702dea4", + "privateKey": "1b2b1ac0127957bb57e914993c47bfd69c5b0acc86425ee8ab2108f684a68a15", + "ptmPublicKey": "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + ExpectedAccount: ðereum.Account{ + Address: "0x618E98197aF52F44D1B05Af0952a59b9f702dea4", + PrivateKey: "1b2b1ac0127957bb57e914993c47bfd69c5b0acc86425ee8ab2108f684a68a15", + PtmPublicKey: "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + }, + { + Name: "Account 3", + Address: map[string]interface{}{ + "address": "0x618E98197aF52F44D1B05Af0952a59b9f702dea4", + "privateKey": "1b2b1ac0127957bb57e914993c47bfd69c5b0acc86425ee8ab2108f684a68a15", + }, + ExpectedAccount: ðereum.Account{ + Address: "0x618E98197aF52F44D1B05Af0952a59b9f702dea4", + PrivateKey: "1b2b1ac0127957bb57e914993c47bfd69c5b0acc86425ee8ab2108f684a68a15", + }, + }, + } + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + quorumProvider := &QuorumProvider{} + result := quorumProvider.ParseAccount(tc.Address) + + _, ok := result.(*ethereum.Account) + if !ok { + t.Errorf("Expected result to be of type *ethereum.Account, but got %T", result) + } + assert.Equal(t, tc.ExpectedAccount, result, "Generated result unmatched") + }) + } +} + +func TestGetOrgConfig(t *testing.T) { + testCases := []struct { + Name string + Org *types.Organization + Stack *types.Stack + OrgConfig *types.OrgConfig + }{ + { + Name: "Testcase1", + Stack: &types.Stack{ + Name: "Org-1", + }, + Org: &types.Organization{ + OrgName: "Org-1", + NodeName: "quorum", + Account: ðereum.Account{ + Address: "0x1234567890abcdef0123456789abcdef6789abcd", + PrivateKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + }, + }, + OrgConfig: &types.OrgConfig{ + Name: "Org-1", + Key: "0x1234567890abcdef0123456789abcdef6789abcd", + }, + }, + { + Name: "Testcase2", + Stack: &types.Stack{ + Name: "Org-2", + }, + Org: &types.Organization{ + OrgName: "Org-2", + NodeName: "quorum", + Account: ðereum.Account{ + Address: "0x1f2a000000000000000000000000000000000000", + PrivateKey: "9876543210987654321098765432109876543210987654321098765432109876", + }, + }, + OrgConfig: &types.OrgConfig{ + Name: "Org-2", + Key: "0x1f2a000000000000000000000000000000000000", + }, + }, + { + Name: "Testcase3", + Stack: &types.Stack{ + Name: "Org-3", + }, + Org: &types.Organization{ + OrgName: "Org-3", + NodeName: "quorum", + Account: ðereum.Account{ + Address: "0xabcdeffedcba9876543210abcdeffedc00000000", + PrivateKey: "aabbccddeeff0011223344556677889900112233445566778899aabbccddeeff", + }, + }, + OrgConfig: &types.OrgConfig{ + Name: "Org-3", + Key: "0xabcdeffedcba9876543210abcdeffedc00000000", + }, + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + p := &QuorumProvider{} + + Orgconfig := p.GetOrgConfig(tc.Stack, tc.Org) + assert.NotNil(t, Orgconfig) + assert.Equal(t, tc.OrgConfig, Orgconfig) + }) + } +} + +func TestGetContracts(t *testing.T) { + FilePath := t.TempDir() + testContractFile := filepath.Join(FilePath, "/test_contracts.json") + // Sample contract JSON content for testing + const testContractJSON = `{ + "contracts": { + "Contract1": { + "name": "sample_1", + "abi": "sample_abi_1", + "bin": "sample_bin_1" + }, + "Contract2": { + "name": "sample_2", + "abi": "sample_abi_2", + "bin": "sample_bin_2" + } + } + }` + p := &QuorumProvider{} + + err := os.WriteFile(testContractFile, []byte(testContractJSON), 0755) + if err != nil { + t.Log("unable to write file:", err) + } + contracts, err := p.GetContracts(testContractFile, nil) + if err != nil { + t.Log("unable to get contract", err) + } + // Assert that the returned contracts match the expected contract names + expectedContracts := []string{"Contract1", "Contract2"} + assert.ElementsMatch(t, contracts, expectedContracts) +} +func TestGetConnectorExternal(t *testing.T) { + testcase := []struct { + Name string + Org *types.Organization + ExpectedPort string + }{ + { + Name: "testcase1", + Org: &types.Organization{ + OrgName: "Org-1", + NodeName: "quorum", + Account: ðereum.Account{ + Address: "0x1f2a000000000000000000000000000000000000", + PrivateKey: "9876543210987654321098765432109876543210987654321098765432109876", + }, + ExposedConnectorPort: 8584, + }, + ExpectedPort: "http://127.0.0.1:8584", + }, + { + Name: "testcase2", + Org: &types.Organization{ + OrgName: "Org-2", + NodeName: "quorum", + Account: ðereum.Account{ + Address: "0xabcdeffedcba9876543210abcdeffedc00000000", + PrivateKey: "aabbccddeeff0011223344556677889900112233445566778899aabbccddeeff", + }, + ExposedConnectorPort: 8000, + }, + ExpectedPort: "http://127.0.0.1:8000", + }, + } + for _, tc := range testcase { + p := &QuorumProvider{} + result := p.GetConnectorExternalURL(tc.Org) + assert.Equal(t, tc.ExpectedPort, result) + } +} + +func TestCreateAccount(t *testing.T) { + ctx := log.WithVerbosity(log.WithLogger(context.Background(), &log.StdoutLogger{}), false) + testCases := []struct { + Name string + Ctx context.Context + Stack *types.Stack + PrivateTransactionManager fftypes.FFEnum + Args []string + }{ + { + Name: "testcase1", + Ctx: ctx, + Stack: &types.Stack{ + Name: "Org-1_quorum", + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockChainConnector", "ethconnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + InitDir: t.TempDir(), + RuntimeDir: t.TempDir(), + PrivateTransactionManager: types.PrivateTransactionManagerNone, + }, + Args: []string{"Org-1_quorum", "Org-1_quorum", "0"}, + }, + { + Name: "testcase2", + Ctx: ctx, + Stack: &types.Stack{ + Name: "Org-2_quorum", + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockChainConnector", "ethconnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + InitDir: t.TempDir(), + RuntimeDir: t.TempDir(), + PrivateTransactionManager: types.PrivateTransactionManagerNone, + }, + Args: []string{"Org-2_quorum", "Org-2_quorum", "1"}, + }, + { + Name: "testcase3", + Ctx: ctx, + Stack: &types.Stack{ + Name: "Org-3_quorum", + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "Ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockChainConnector", "EvmConnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + InitDir: t.TempDir(), + RuntimeDir: t.TempDir(), + PrivateTransactionManager: types.PrivateTransactionManagerTessera, + }, + Args: []string{"Org-3_quorum", "Org-3_quorum", "1"}, + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + p := NewQuorumProvider(tc.Ctx, tc.Stack) + Account, err := p.CreateAccount(tc.Args) + if err != nil { + t.Log("unable to create account", err) + } + //validate properties of account + assert.NotNil(t, Account) + account, ok := Account.(*ethereum.Account) + assert.True(t, ok, "unexpected Type for account") + + //check if Ethereum Addresss is valid + assert.NotEmpty(t, account.Address) + // Check if the private key is a non-empty hex string + assert.NotEmpty(t, account.PrivateKey) + _, err = hex.DecodeString(account.PrivateKey) + assert.NoError(t, err, "invalid private key format") + // Check if the tessera public key is a non-empty string + if tc.Stack.PrivateTransactionManager.Equals(types.PrivateTransactionManagerTessera) { + assert.NotEmpty(t, account.PtmPublicKey) + } else { + assert.Empty(t, account.PtmPublicKey) + } + }) + } +} + +func TestPostStart(t *testing.T) { + ctx := log.WithVerbosity(log.WithLogger(context.Background(), &log.StdoutLogger{}), false) + testCases := []struct { + Name string + Ctx context.Context + Stack *types.Stack + PrivateTransactionManager fftypes.FFEnum + Args []string + }{ + { + Name: "testcase1_with_tessera_enabled", + Ctx: ctx, + Stack: &types.Stack{ + State: &types.StackState{ + DeployedContracts: make([]*types.DeployedContract, 0), + }, + ExposedBlockchainPort: 8545, + PrivateTransactionManager: types.PrivateTransactionManagerTessera, + Members: []*types.Organization{ + { + Index: &[]int{0}[0], + Account: ðereum.Account{ + Address: "0x1234567890abcdef0123456789abcdef6789abcd", + PrivateKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + PtmPublicKey: "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + }, + { + Index: &[]int{1}[0], + Account: ðereum.Account{ + Address: "0x618E98197aF52F44D1B05Af0952a59b9f702dea4", + PrivateKey: "1b2b1ac0127957bb57e914993c47bfd69c5b0acc86425ee8ab2108f684a68a15", + PtmPublicKey: "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + }, + }, + }, + }, + { + Name: "testcase2_with_tessera_disabled", + Ctx: ctx, + Stack: &types.Stack{ + State: &types.StackState{ + DeployedContracts: make([]*types.DeployedContract, 0), + }, + ExposedBlockchainPort: 8545, + PrivateTransactionManager: types.PrivateTransactionManagerNone, + Members: []*types.Organization{ + { + Index: &[]int{0}[0], + Account: ðereum.Account{ + Address: "0x1234567890abcdef0123456789abcdef6789abcd", + PrivateKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + PtmPublicKey: "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + }, + { + Index: &[]int{1}[0], + Account: ðereum.Account{ + Address: "0x618E98197aF52F44D1B05Af0952a59b9f702dea4", + PrivateKey: "1b2b1ac0127957bb57e914993c47bfd69c5b0acc86425ee8ab2108f684a68a15", + PtmPublicKey: "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + }, + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + accounts := make([]interface{}, len(tc.Stack.Members)) + for memberIndex, member := range tc.Stack.Members { + accounts[memberIndex] = member.Account + + } + tc.Stack.State.Accounts = accounts + p := NewQuorumProvider(tc.Ctx, tc.Stack) + utils.StartMockServer(t) + // mock quorum rpc response during the unlocking of accounts + for _, member := range tc.Stack.Members { + rpcUrl := fmt.Sprintf("http://127.0.0.1:%v", p.stack.ExposedBlockchainPort+(*member.Index*ExposedBlockchainPortMultiplier)) + httpmock.RegisterResponder( + "POST", + rpcUrl, + httpmock.NewStringResponder(200, "{\"JSONRPC\": \"2.0\"}")) + } + httpmock.Activate() + hasRunBefore, _ := p.stack.HasRunBefore() + err := p.PostStart(hasRunBefore) + assert.Nil(t, err, "post start should not have an error") + utils.StopMockServer(t) + }) + } +} + +func TestFirstTimeSetup(t *testing.T) { + ctx := log.WithVerbosity(log.WithLogger(context.Background(), &log.StdoutLogger{}), false) + testCases := []struct { + Name string + Ctx context.Context + Stack *types.Stack + PrivateTransactionManager fftypes.FFEnum + Args []string + }{ + { + Name: "testcase1_no_members", + Ctx: ctx, + Stack: &types.Stack{ + Name: "Org-1_quorum", + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockChainConnector", "evmconnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + InitDir: t.TempDir(), + RuntimeDir: t.TempDir(), + PrivateTransactionManager: types.PrivateTransactionManagerNone, + }, + }, + { + Name: "testcase2_with_members", + Ctx: ctx, + Stack: &types.Stack{ + Name: "Org-1_quorum", + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockChainConnector", "evmconnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + InitDir: t.TempDir(), + RuntimeDir: t.TempDir(), + PrivateTransactionManager: types.PrivateTransactionManagerNone, + Members: []*types.Organization{ + { + Index: &[]int{0}[0], + }, + { + Index: &[]int{1}[0], + }, + }, + }, + }, + { + Name: "testcase3_with_members_and_tessera_enabled", + Ctx: ctx, + Stack: &types.Stack{ + Name: "Org-1_quorum", + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockChainConnector", "evmconnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + InitDir: t.TempDir(), + RuntimeDir: t.TempDir(), + PrivateTransactionManager: types.PrivateTransactionManagerTessera, + Members: []*types.Organization{ + { + Index: &[]int{0}[0], + }, + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + p := NewQuorumProvider(tc.Ctx, tc.Stack) + p.dockerMgr = mocks.NewDockerManager() // docker related functionality should be tested in docker package + err := p.FirstTimeSetup() + assert.Nil(t, err, "first time setup should not throw an error") + }) + } +} + +func TestWriteConfig(t *testing.T) { + ctx := log.WithVerbosity(log.WithLogger(context.Background(), &log.StdoutLogger{}), false) + testCases := []struct { + Name string + Ctx context.Context + Stack *types.Stack + PrivateTransactionManager fftypes.FFEnum + Options *types.InitOptions + }{ + { + Name: "testcase1_no_members", + Ctx: ctx, + Stack: &types.Stack{ + Name: "Org-1_quorum", + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockChainConnector", "evmconnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + InitDir: t.TempDir(), + RuntimeDir: t.TempDir(), + PrivateTransactionManager: types.PrivateTransactionManagerNone, + }, + Options: &types.InitOptions{ + BlockPeriod: 5, + }, + }, + { + Name: "testcase2_with_members", + Ctx: ctx, + Stack: &types.Stack{ + Name: "Org-1_quorum", + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockChainConnector", "evmconnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + InitDir: t.TempDir(), + RuntimeDir: t.TempDir(), + PrivateTransactionManager: types.PrivateTransactionManagerNone, + Members: []*types.Organization{ + { + Index: &[]int{0}[0], + Account: ðereum.Account{ + Address: "0x1234567890abcdef0123456789abcdef6789abcd", + PrivateKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + PtmPublicKey: "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + }, + { + Index: &[]int{1}[0], + Account: ðereum.Account{ + Address: "0x618E98197aF52F44D1B05Af0952a59b9f702dea4", + PrivateKey: "1b2b1ac0127957bb57e914993c47bfd69c5b0acc86425ee8ab2108f684a68a15", + PtmPublicKey: "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + }, + }, + }, + Options: &types.InitOptions{ + BlockPeriod: 5, + ExtraConnectorConfigPath: "", + }, + }, + { + Name: "testcase3_with_members_and_tessera_enabled", + Ctx: ctx, + Stack: &types.Stack{ + Name: "Org-1_quorum", + BlockchainProvider: fftypes.FFEnumValue("BlockchainProvider", "ethereum"), + BlockchainConnector: fftypes.FFEnumValue("BlockChainConnector", "evmconnect"), + BlockchainNodeProvider: fftypes.FFEnumValue("BlockchainNodeProvider", "quorum"), + InitDir: t.TempDir(), + RuntimeDir: t.TempDir(), + PrivateTransactionManager: types.PrivateTransactionManagerTessera, + Members: []*types.Organization{ + { + Index: &[]int{0}[0], + Account: ðereum.Account{ + Address: "0x1234567890abcdef0123456789abcdef6789abcd", + PrivateKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + PtmPublicKey: "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + }, + { + Index: &[]int{1}[0], + Account: ðereum.Account{ + Address: "0x618E98197aF52F44D1B05Af0952a59b9f702dea4", + PrivateKey: "1b2b1ac0127957bb57e914993c47bfd69c5b0acc86425ee8ab2108f684a68a15", + PtmPublicKey: "SBEV8qc12zSe7XfhqSChloYryb5aDK0XdBF3IwxZADE=", + }, + }, + }, + }, + Options: &types.InitOptions{ + BlockPeriod: 5, + ExtraConnectorConfigPath: "", + }, + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + p := NewQuorumProvider(tc.Ctx, tc.Stack) + err := p.WriteConfig(tc.Options) + assert.Nil(t, err, "writing config should not throw an error") + }) + } +} diff --git a/internal/blockchain/ethereum/quorum/quorum_test.go b/internal/blockchain/ethereum/quorum/quorum_test.go new file mode 100644 index 00000000..df1f0208 --- /dev/null +++ b/internal/blockchain/ethereum/quorum/quorum_test.go @@ -0,0 +1,73 @@ +package quorum + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hyperledger/firefly-cli/internal/log" + "github.com/hyperledger/firefly-cli/pkg/types" + "github.com/hyperledger/firefly-common/pkg/fftypes" + "github.com/stretchr/testify/assert" +) + +func TestCreateQuorumEntrypoint(t *testing.T) { + ctx := log.WithVerbosity(log.WithLogger(context.Background(), &log.StdoutLogger{}), false) + testCases := []struct { + Name string + Stack *types.Stack + Consensus string + StackName string + MemberIndex int + ChainID int + BlockPeriodInSeconds int + PrivateTransactionManager fftypes.FFEnum + }{ + { + Name: "testcase1", + Stack: &types.Stack{ + Name: "Org-1_quorum", + InitDir: t.TempDir(), + }, + Consensus: "ibft", + StackName: "org1", + MemberIndex: 0, + ChainID: 1337, + BlockPeriodInSeconds: -1, + PrivateTransactionManager: types.PrivateTransactionManagerTessera, + }, + { + Name: "testcase2_with_tessera_disabled", + Stack: &types.Stack{ + Name: "Org-2_quorum", + InitDir: t.TempDir(), + }, + Consensus: "clique", + StackName: "org2", + MemberIndex: 1, + ChainID: 1337, + BlockPeriodInSeconds: 3, + PrivateTransactionManager: types.PrivateTransactionManagerNone, + }, + } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + err := CreateQuorumEntrypoint(ctx, tc.Stack.InitDir, tc.Consensus, tc.StackName, tc.MemberIndex, tc.ChainID, tc.BlockPeriodInSeconds, tc.PrivateTransactionManager) + if err != nil { + t.Log("unable to create quorum docker entrypoint", err) + } + path := filepath.Join(tc.Stack.InitDir, "docker-entrypoint.sh") + _, err = os.Stat(path) + assert.NoError(t, err, "docker entrypoint file not created") + + b, err := os.ReadFile(path) + assert.NoError(t, err, "unable to read docker entrypoint file") + output := string(b) + strings.Contains(output, fmt.Sprintf("member%dtessera", tc.MemberIndex)) + strings.Contains(output, fmt.Sprintf("GOQUORUM_CONS_ALGO=%s", tc.Consensus)) + }) + } +} diff --git a/internal/blockchain/fabric/fabric_docker.go b/internal/blockchain/fabric/fabric_docker.go index 2ab71143..f2d56122 100644 --- a/internal/blockchain/fabric/fabric_docker.go +++ b/internal/blockchain/fabric/fabric_docker.go @@ -31,14 +31,14 @@ func GenerateDockerServiceDefinitions(s *types.Stack) []*docker.ServiceDefinitio Service: &docker.Service{ Image: FabricCAImageName, ContainerName: fmt.Sprintf("%s_fabric_ca", s.Name), - Environment: map[string]interface{}{ + Environment: s.ConcatenateWithProvidedEnvironmentVars(map[string]interface{}{ "FABRIC_CA_HOME": "/etc/hyperledger/fabric-ca-server", "FABRIC_CA_SERVER_CA_NAME": "fabric_ca", "FABRIC_CA_SERVER_PORT": "7054", "FABRIC_CA_SERVER_OPERATIONS_LISTENADDRESS": "0.0.0.0:17054", "FABRIC_CA_SERVER_CA_CERTFILE": "/etc/firefly/organizations/peerOrganizations/org1.example.com/ca/fabric_ca.org1.example.com-cert.pem", "FABRIC_CA_SERVER_CA_KEYFILE": "/etc/firefly/organizations/peerOrganizations/org1.example.com/ca/priv_sk", - }, + }), Ports: []string{ "7054:7054", "17054:17054", @@ -57,7 +57,7 @@ func GenerateDockerServiceDefinitions(s *types.Stack) []*docker.ServiceDefinitio Service: &docker.Service{ Image: FabricOrdererImageName, ContainerName: fmt.Sprintf("%s_fabric_orderer", s.Name), - Environment: map[string]interface{}{ + Environment: s.ConcatenateWithProvidedEnvironmentVars(map[string]interface{}{ "FABRIC_LOGGING_SPEC": "INFO", "ORDERER_GENERAL_LISTENADDRESS": "0.0.0.0", "ORDERER_GENERAL_LISTENPORT": "7050", @@ -81,7 +81,7 @@ func GenerateDockerServiceDefinitions(s *types.Stack) []*docker.ServiceDefinitio "ORDERER_ADMIN_TLS_CLIENTROOTCAS": "[/etc/firefly/organizations/ordererOrganizations/example.com/orderers/fabric_orderer.example.com/tls/ca.crt]", "ORDERER_ADMIN_LISTENADDRESS": "0.0.0.0:7053", "ORDERER_OPERATIONS_LISTENADDRESS": "0.0.0.0:17050", - }, + }), WorkingDir: "/opt/gopath/src/github.com/hyperledger/fabric", Command: "orderer", Volumes: []string{ @@ -103,7 +103,7 @@ func GenerateDockerServiceDefinitions(s *types.Stack) []*docker.ServiceDefinitio Service: &docker.Service{ Image: FabricPeerImageName, ContainerName: fmt.Sprintf("%s_fabric_peer", s.Name), - Environment: map[string]interface{}{ + Environment: s.ConcatenateWithProvidedEnvironmentVars(map[string]interface{}{ "CORE_VM_ENDPOINT": "unix:///host/var/run/docker.sock", "CORE_VM_DOCKER_HOSTCONFIG_NETWORKMODE": fmt.Sprintf("%s_default", s.Name), "FABRIC_LOGGING_SPEC": "INFO", @@ -122,7 +122,7 @@ func GenerateDockerServiceDefinitions(s *types.Stack) []*docker.ServiceDefinitio "CORE_PEER_GOSSIP_EXTERNALENDPOINT": "fabric_peer:7051", "CORE_PEER_LOCALMSPID": "Org1MSP", "CORE_OPERATIONS_LISTENADDRESS": "0.0.0.0:17051", - }, + }), Volumes: []string{ "firefly_fabric:/etc/firefly", "fabric_peer:/var/hyperledger/production", diff --git a/internal/blockchain/fabric/fabric_provider.go b/internal/blockchain/fabric/fabric_provider.go index 8bac72dd..e5828ae1 100644 --- a/internal/blockchain/fabric/fabric_provider.go +++ b/internal/blockchain/fabric/fabric_provider.go @@ -290,7 +290,8 @@ func (p *FabricProvider) getFabconnectServiceDefinitions(members []*types.Organi HealthCheck: &docker.HealthCheck{ Test: []string{"CMD", "wget", "-O", "-", "http://localhost:3000/status"}, }, - Logging: docker.StandardLogOptions, + Logging: docker.StandardLogOptions, + Environment: p.stack.EnvironmentVars, }, VolumeNames: []string{ "fabconnect_receipts_" + member.ID, diff --git a/internal/blockchain/tezos/connector/tezosconnect/docker.go b/internal/blockchain/tezos/connector/tezosconnect/docker.go index 4770d1ef..7fa902fa 100644 --- a/internal/blockchain/tezos/connector/tezosconnect/docker.go +++ b/internal/blockchain/tezos/connector/tezosconnect/docker.go @@ -42,7 +42,8 @@ func (t *Tezosconnect) GetServiceDefinitions(s *types.Stack, dependentServices m fmt.Sprintf("tezosconnect_config_%s:/tezosconnect/config", member.ID), fmt.Sprintf("tezosconnect_leveldb_%s:/tezosconnect/leveldb", member.ID), }, - Logging: docker.StandardLogOptions, + Logging: docker.StandardLogOptions, + Environment: s.EnvironmentVars, }, VolumeNames: []string{ fmt.Sprintf("tezosconnect_config_%s", member.ID), diff --git a/internal/blockchain/tezos/tezossigner/tezossigner.go b/internal/blockchain/tezos/tezossigner/tezossigner.go index dae114ed..ab18ec36 100644 --- a/internal/blockchain/tezos/tezossigner/tezossigner.go +++ b/internal/blockchain/tezos/tezossigner/tezossigner.go @@ -113,6 +113,7 @@ func (p *TezosSignerProvider) GetDockerServiceDefinition(rpcURL string) *docker. fmt.Sprintf("%d:6732", p.stack.ExposedBlockchainPort), "9583:9583", }, + Environment: p.stack.EnvironmentVars, }, VolumeNames: []string{ "tezossigner", diff --git a/internal/docker/docker_config.go b/internal/docker/docker_config.go index 8a81ca00..897067a8 100644 --- a/internal/docker/docker_config.go +++ b/internal/docker/docker_config.go @@ -60,6 +60,7 @@ type Service struct { EntryPoint []string `yaml:"entrypoint,omitempty"` EnvFile string `yaml:"env_file,omitempty"` Expose []int `yaml:"expose,omitempty"` + Deploy map[string]interface{} `yaml:"deploy,omitempty"` } type DockerComposeConfig struct { @@ -99,8 +100,9 @@ func CreateDockerCompose(s *types.Stack) *DockerComposeConfig { fmt.Sprintf("%s:/etc/firefly/firefly.core.yml:ro", configFile), fmt.Sprintf("%s_data_%s:/etc/firefly/data", fireflyCore, member.ID), }, - DependsOn: map[string]map[string]string{}, - Logging: StandardLogOptions, + DependsOn: map[string]map[string]string{}, + Logging: StandardLogOptions, + Environment: s.EnvironmentVars, } compose.Volumes[fmt.Sprintf("%s_data_%s", fireflyCore, member.ID)] = struct{}{} compose.Services[fireflyCore+"_"+member.ID].DependsOn["dataexchange_"+member.ID] = map[string]string{"condition": "service_started"} @@ -111,10 +113,9 @@ func CreateDockerCompose(s *types.Stack) *DockerComposeConfig { Image: constants.PostgresImageName, ContainerName: fmt.Sprintf("%s_postgres_%s", s.Name, member.ID), Ports: []string{fmt.Sprintf("%d:5432", member.ExposedDatabasePort)}, - Environment: map[string]interface{}{ + Environment: s.ConcatenateWithProvidedEnvironmentVars(map[string]interface{}{ "POSTGRES_PASSWORD": "f1refly", - "PGDATA": "/var/lib/postgresql/data/pgdata", - }, + "PGDATA": "/var/lib/postgresql/data/pgdata"}), Volumes: []string{fmt.Sprintf("postgres_%s:/var/lib/postgresql/data", member.ID)}, HealthCheck: &HealthCheck{ Test: []string{"CMD-SHELL", "pg_isready -U postgres"}, @@ -149,10 +150,13 @@ func CreateDockerCompose(s *types.Stack) *DockerComposeConfig { }, } if s.IPFSMode.Equals(types.IPFSModePrivate) { - sharedStorage.Environment = map[string]interface{}{ + sharedStorage.Environment = s.ConcatenateWithProvidedEnvironmentVars(map[string]interface{}{ "IPFS_SWARM_KEY": s.SwarmKey, "LIBP2P_FORCE_PNET": "1", - } + }, + ) + } else { + sharedStorage.Environment = s.EnvironmentVars } compose.Services["ipfs_"+member.ID] = sharedStorage compose.Volumes[fmt.Sprintf("ipfs_staging_%s", member.ID)] = struct{}{} @@ -163,6 +167,7 @@ func CreateDockerCompose(s *types.Stack) *DockerComposeConfig { Ports: []string{fmt.Sprintf("%d:3000", member.ExposedDataexchangePort)}, Volumes: []string{fmt.Sprintf("dataexchange_%s:/data", member.ID)}, Logging: StandardLogOptions, + Environment: s.EnvironmentVars, } compose.Volumes[fmt.Sprintf("dataexchange_%s", member.ID)] = struct{}{} if s.SandboxEnabled { @@ -170,9 +175,9 @@ func CreateDockerCompose(s *types.Stack) *DockerComposeConfig { Image: constants.SandboxImageName, ContainerName: fmt.Sprintf("%s_sandbox_%s", s.Name, member.ID), Ports: []string{fmt.Sprintf("%d:3001", member.ExposedSandboxPort)}, - Environment: map[string]interface{}{ + Environment: s.ConcatenateWithProvidedEnvironmentVars(map[string]interface{}{ "FF_ENDPOINT": fmt.Sprintf("http://firefly_core_%d:%d", *member.Index, member.ExposedFireflyPort), - }, + }), } } } @@ -184,6 +189,7 @@ func CreateDockerCompose(s *types.Stack) *DockerComposeConfig { Ports: []string{fmt.Sprintf("%d:9090", s.ExposedPrometheusPort)}, Volumes: []string{"prometheus_data:/prometheus", "prometheus_config:/etc/prometheus"}, Logging: StandardLogOptions, + Environment: s.EnvironmentVars, } compose.Volumes["prometheus_data"] = struct{}{} compose.Volumes["prometheus_config"] = struct{}{} diff --git a/internal/docker/docker_config_test.go b/internal/docker/docker_config_test.go new file mode 100644 index 00000000..b2acdcfb --- /dev/null +++ b/internal/docker/docker_config_test.go @@ -0,0 +1,54 @@ +package docker + +import ( + "testing" + + "github.com/hyperledger/firefly-cli/pkg/types" + "github.com/stretchr/testify/assert" +) + +type MockManfest struct { + types.ManifestEntry +} + +func TestCreateDockerComposeEnvironmentVars(t *testing.T) { + getManifest := &MockManfest{} + testStacks := []struct { + Id int + EnvironmentVars map[string]interface{} + Members []*types.Organization + VersionManifest *types.VersionManifest + }{ + { + Id: 1, + Members: []*types.Organization{{ID: "firefly_1"}}, + VersionManifest: &types.VersionManifest{FireFly: &getManifest.ManifestEntry, DataExchange: &getManifest.ManifestEntry}, + EnvironmentVars: map[string]interface{}{ + "http_proxy": "", + "https_proxy": "", + "HTTP_PROXY": "127.0.0.1:8080", + "HTTPS_PROXY": "127.0.0.1:8080", + "no_proxy": "", + }, + }, + { + Id: 2, + Members: []*types.Organization{{ID: "firefly_2"}}, + VersionManifest: &types.VersionManifest{FireFly: &getManifest.ManifestEntry, DataExchange: &getManifest.ManifestEntry}, + EnvironmentVars: nil, + }, + } + for _, test := range testStacks { + cfg := CreateDockerCompose(&types.Stack{ + Members: test.Members, + VersionManifest: test.VersionManifest, + EnvironmentVars: test.EnvironmentVars, + }) + for _, service := range cfg.Services { + assert.Equal(t, len(test.EnvironmentVars), len(service.Environment), "service [%v] test ID [%v]", service.ContainerName, test.Id) + for envVar := range service.Environment { + assert.Equal(t, test.EnvironmentVars[envVar], service.Environment[envVar]) + } + } + } +} diff --git a/internal/docker/docker_manager.go b/internal/docker/docker_manager.go new file mode 100644 index 00000000..ae21ce26 --- /dev/null +++ b/internal/docker/docker_manager.go @@ -0,0 +1,104 @@ +// Copyright © 2024 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package docker + +import ( + "context" +) + +// DockerInterface combines all Docker-related operations into a single interface. +type IDockerManager interface { + // Command Execution + RunDockerCommand(ctx context.Context, workingDir string, command ...string) error + RunDockerCommandLine(ctx context.Context, workingDir string, command string) error + RunDockerComposeCommand(ctx context.Context, workingDir string, command ...string) error + RunDockerCommandBuffered(ctx context.Context, workingDir string, command ...string) (string, error) + RunDockerComposeCommandReturnsStdout(workingDir string, command ...string) ([]byte, error) + + // Image Inspection + GetImageConfig(image string) (map[string]interface{}, error) + GetImageLabel(image, label string) (string, error) + GetImageDigest(image string) (string, error) + + // Volume Management + CreateVolume(ctx context.Context, volumeName string) error + CopyFileToVolume(ctx context.Context, volumeName string, sourcePath string, destPath string) error + MkdirInVolume(ctx context.Context, volumeName string, directory string) error + RemoveVolume(ctx context.Context, volumeName string) error + + // Container Interaction + CopyFromContainer(ctx context.Context, containerName string, sourcePath string, destPath string) error +} + +// DockerManager implements IDockerManager +type DockerManager struct{} + +func NewDockerManager() *DockerManager { + return &DockerManager{} +} + +func (mgr *DockerManager) RunDockerCommand(ctx context.Context, workingDir string, command ...string) error { + return RunDockerCommand(ctx, workingDir, command...) +} + +func (mgr *DockerManager) RunDockerCommandLine(ctx context.Context, workingDir string, command string) error { + return RunDockerCommandLine(ctx, workingDir, command) +} + +func (mgr *DockerManager) RunDockerComposeCommand(ctx context.Context, workingDir string, command ...string) error { + return RunDockerComposeCommand(ctx, workingDir, command...) +} + +func (mgr *DockerManager) RunDockerCommandBuffered(ctx context.Context, workingDir string, command ...string) (string, error) { + return RunDockerCommandBuffered(ctx, workingDir, command...) +} + +func (mgr *DockerManager) RunDockerComposeCommandReturnsStdout(workingDir string, command ...string) ([]byte, error) { + return RunDockerComposeCommandReturnsStdout(workingDir, command...) +} + +func (mgr *DockerManager) GetImageConfig(image string) (map[string]interface{}, error) { + return GetImageConfig(image) +} + +func (mgr *DockerManager) GetImageLabel(image, label string) (string, error) { + return GetImageLabel(image, label) +} + +func (mgr *DockerManager) GetImageDigest(image string) (string, error) { + return GetImageDigest(image) +} + +func (mgr *DockerManager) CreateVolume(ctx context.Context, volumeName string) error { + return CreateVolume(ctx, volumeName) +} + +func (mgr *DockerManager) CopyFileToVolume(ctx context.Context, volumeName string, sourcePath string, destPath string) error { + return CopyFileToVolume(ctx, volumeName, sourcePath, destPath) +} + +func (mgr *DockerManager) MkdirInVolume(ctx context.Context, volumeName string, directory string) error { + return MkdirInVolume(ctx, volumeName, directory) +} + +func (mgr *DockerManager) RemoveVolume(ctx context.Context, volumeName string) error { + return RemoveVolume(ctx, volumeName) +} + +func (mgr *DockerManager) CopyFromContainer(ctx context.Context, containerName string, sourcePath string, destPath string) error { + return CopyFromContainer(ctx, containerName, sourcePath, destPath) +} diff --git a/internal/docker/mocks/docker_manager.go b/internal/docker/mocks/docker_manager.go new file mode 100644 index 00000000..982a8d94 --- /dev/null +++ b/internal/docker/mocks/docker_manager.go @@ -0,0 +1,62 @@ +// DockerManager is a mock that implements IDockerManager +package mocks + +import "context" + +type DockerManager struct{} + +func NewDockerManager() *DockerManager { + return &DockerManager{} +} + +func (mgr *DockerManager) RunDockerCommand(ctx context.Context, workingDir string, command ...string) error { + return nil +} + +func (mgr *DockerManager) RunDockerCommandLine(ctx context.Context, workingDir string, command string) error { + return nil +} + +func (mgr *DockerManager) RunDockerComposeCommand(ctx context.Context, workingDir string, command ...string) error { + return nil +} + +func (mgr *DockerManager) RunDockerCommandBuffered(ctx context.Context, workingDir string, command ...string) (string, error) { + return "", nil +} + +func (mgr *DockerManager) RunDockerComposeCommandReturnsStdout(workingDir string, command ...string) ([]byte, error) { + return nil, nil +} + +func (mgr *DockerManager) GetImageConfig(image string) (map[string]interface{}, error) { + return nil, nil +} + +func (mgr *DockerManager) GetImageLabel(image, label string) (string, error) { + return "", nil +} + +func (mgr *DockerManager) GetImageDigest(image string) (string, error) { + return "", nil +} + +func (mgr *DockerManager) CreateVolume(ctx context.Context, volumeName string) error { + return nil +} + +func (mgr *DockerManager) CopyFileToVolume(ctx context.Context, volumeName string, sourcePath string, destPath string) error { + return nil +} + +func (mgr *DockerManager) MkdirInVolume(ctx context.Context, volumeName string, directory string) error { + return nil +} + +func (mgr *DockerManager) RemoveVolume(ctx context.Context, volumeName string) error { + return nil +} + +func (mgr *DockerManager) CopyFromContainer(ctx context.Context, containerName string, sourcePath string, destPath string) error { + return nil +} diff --git a/internal/stacks/stack_manager.go b/internal/stacks/stack_manager.go index ccd4e2e7..2fa45da6 100644 --- a/internal/stacks/stack_manager.go +++ b/internal/stacks/stack_manager.go @@ -25,6 +25,7 @@ import ( "os/exec" "path" "path/filepath" + "strconv" "strings" "sync" "syscall" @@ -34,6 +35,7 @@ import ( "github.com/hyperledger/firefly-cli/internal/blockchain" "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum/besu" "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum/geth" + "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum/quorum" ethremoterpc "github.com/hyperledger/firefly-cli/internal/blockchain/ethereum/remoterpc" "github.com/hyperledger/firefly-cli/internal/blockchain/fabric" tezosremoterpc "github.com/hyperledger/firefly-cli/internal/blockchain/tezos/remoterpc" @@ -88,18 +90,25 @@ func NewStackManager(ctx context.Context) *StackManager { } func (s *StackManager) InitStack(options *types.InitOptions) (err error) { + environmentVarsMap := make(map[string]interface{}) + for key, value := range options.EnvironmentVars { + environmentVarsMap[key] = value + } s.Stack = &types.Stack{ - Name: options.StackName, - Members: make([]*types.Organization, options.MemberCount), - ExposedBlockchainPort: options.ServicesBasePort, - Database: fftypes.FFEnum(options.DatabaseProvider), - BlockchainProvider: fftypes.FFEnum(options.BlockchainProvider), - BlockchainNodeProvider: fftypes.FFEnum(options.BlockchainNodeProvider), - BlockchainConnector: fftypes.FFEnum(options.BlockchainConnector), - ContractAddress: options.ContractAddress, - StackDir: filepath.Join(constants.StacksDir, options.StackName), - InitDir: filepath.Join(constants.StacksDir, options.StackName, "init"), - RuntimeDir: filepath.Join(constants.StacksDir, options.StackName, "runtime"), + Name: options.StackName, + Members: make([]*types.Organization, options.MemberCount), + ExposedBlockchainPort: options.ServicesBasePort, + ExposedPtmPort: options.PtmBasePort, + Database: fftypes.FFEnum(options.DatabaseProvider), + BlockchainProvider: fftypes.FFEnum(options.BlockchainProvider), + BlockchainNodeProvider: fftypes.FFEnum(options.BlockchainNodeProvider), + BlockchainConnector: fftypes.FFEnum(options.BlockchainConnector), + PrivateTransactionManager: fftypes.FFEnum(options.PrivateTransactionManager), + Consensus: fftypes.FFEnum(options.Consensus), + ContractAddress: options.ContractAddress, + StackDir: filepath.Join(constants.StacksDir, options.StackName), + InitDir: filepath.Join(constants.StacksDir, options.StackName, "init"), + RuntimeDir: filepath.Join(constants.StacksDir, options.StackName, "runtime"), State: &types.StackState{ DeployedContracts: make([]*types.DeployedContract, 0), Accounts: make([]interface{}, options.MemberCount), @@ -114,6 +123,7 @@ func (s *StackManager) InitStack(options *types.InitOptions) (err error) { ChaincodeName: options.ChaincodeName, CustomPinSupport: options.CustomPinSupport, RemoteNodeDeploy: options.RemoteNodeDeploy, + EnvironmentVars: environmentVarsMap, } tokenProviders, err := types.FFEnumArray(s.ctx, options.TokenProviders) @@ -547,6 +557,7 @@ func (s *StackManager) copyDataExchangeConfigToVolumes() error { func (s *StackManager) createMember(id string, index int, options *types.InitOptions, external bool) (*types.Organization, error) { serviceBase := options.ServicesBasePort + (index * 100) + ptmBase := options.PtmBasePort + (index * 10) member := &types.Organization{ ID: id, Index: &index, @@ -555,6 +566,7 @@ func (s *StackManager) createMember(id string, index int, options *types.InitOpt ExposedConnectorPort: serviceBase + 2, ExposedUIPort: serviceBase + 3, ExposedDatabasePort: serviceBase + 4, + ExposePtmTpPort: ptmBase, External: external, OrgName: options.OrgNames[index], NodeName: options.NodeNames[index], @@ -579,7 +591,7 @@ func (s *StackManager) createMember(id string, index int, options *types.InitOpt nextPort++ } - account, err := s.blockchainProvider.CreateAccount([]string{member.OrgName, member.OrgName}) + account, err := s.blockchainProvider.CreateAccount([]string{member.OrgName, member.OrgName, strconv.Itoa(index)}) if err != nil { return nil, err } @@ -680,10 +692,15 @@ func (s *StackManager) PullStack(options *types.PullOptions) error { } // Use docker to pull every image - retry on failure + hasPulled := map[string]bool{} for _, image := range images { - s.Log.Info(fmt.Sprintf("pulling '%s'", image)) - if err := docker.RunDockerCommandRetry(s.ctx, s.Stack.InitDir, options.Retries, "pull", image); err != nil { - return err + if _, ok := hasPulled[image]; !ok { + s.Log.Info(fmt.Sprintf("pulling '%s'", image)) + if err := docker.RunDockerCommandRetry(s.ctx, s.Stack.InitDir, options.Retries, "pull", image); err != nil { + return err + } else { + hasPulled[image] = true + } } } return nil @@ -768,6 +785,7 @@ func (s *StackManager) checkPortsAvailable() error { ports = append(ports, member.ExposedDatabasePort) ports = append(ports, member.ExposedUIPort) ports = append(ports, member.ExposedTokensPorts...) + ports = append(ports, member.ExposePtmTpPort) if !member.External { ports = append(ports, member.ExposedFireflyAdminSPIPort) @@ -1284,6 +1302,11 @@ func (s *StackManager) getBlockchainProvider() blockchain.IBlockchainProvider { s.Stack.BlockchainNodeProvider = types.BlockchainNodeProviderGeth } + if s.Stack.BlockchainProvider.Equals(types.BlockchainNodeProviderQuorum) { + s.Stack.BlockchainProvider = types.BlockchainProviderEthereum + s.Stack.BlockchainNodeProvider = types.BlockchainNodeProviderQuorum + } + if s.Stack.BlockchainProvider.Equals(types.BlockchainNodeProviderBesu) { s.Stack.BlockchainProvider = types.BlockchainProviderEthereum s.Stack.BlockchainNodeProvider = types.BlockchainNodeProviderBesu @@ -1292,7 +1315,7 @@ func (s *StackManager) getBlockchainProvider() blockchain.IBlockchainProvider { // Fallbacks for old stacks that don't have a specific blockchain connector set if s.Stack.BlockchainConnector == "" { switch s.Stack.BlockchainProvider { - case types.BlockchainProviderEthereum, types.BlockchainNodeProviderGeth, types.BlockchainNodeProviderBesu: + case types.BlockchainProviderEthereum, types.BlockchainNodeProviderGeth, types.BlockchainNodeProviderQuorum, types.BlockchainNodeProviderBesu: // Ethconnect used to be the only option for ethereum before it was configurable so set it as the fallback s.Stack.BlockchainConnector = types.BlockchainConnectorEthconnect case types.BlockchainProviderFabric: @@ -1310,6 +1333,8 @@ func (s *StackManager) getBlockchainProvider() blockchain.IBlockchainProvider { return geth.NewGethProvider(s.ctx, s.Stack) case types.BlockchainNodeProviderBesu: return besu.NewBesuProvider(s.ctx, s.Stack) + case types.BlockchainNodeProviderQuorum: + return quorum.NewQuorumProvider(s.ctx, s.Stack) case types.BlockchainNodeProviderRemoteRPC: s.Stack.DisableTokenFactories = true return ethremoterpc.NewRemoteRPCProvider(s.ctx, s.Stack) diff --git a/internal/tokens/erc1155/erc1155_provider.go b/internal/tokens/erc1155/erc1155_provider.go index f8ee7948..dbc07aea 100644 --- a/internal/tokens/erc1155/erc1155_provider.go +++ b/internal/tokens/erc1155/erc1155_provider.go @@ -96,12 +96,12 @@ func (p *ERC1155Provider) GetDockerServiceDefinitions(tokenIdx int) []*docker.Se } } - env := map[string]interface{}{ + env := p.stack.ConcatenateWithProvidedEnvironmentVars(map[string]interface{}{ "ETHCONNECT_URL": p.blockchainProvider.GetConnectorURL(member), "ETHCONNECT_TOPIC": connectorName, "AUTO_INIT": "false", "CONTRACT_ADDRESS": contractAddress, - } + }) serviceDefinitions = append(serviceDefinitions, &docker.ServiceDefinition{ ServiceName: connectorName, Service: &docker.Service{ diff --git a/internal/tokens/erc20erc721/erc20_erc721_provider.go b/internal/tokens/erc20erc721/erc20_erc721_provider.go index 61e4e578..ea1dfd31 100644 --- a/internal/tokens/erc20erc721/erc20_erc721_provider.go +++ b/internal/tokens/erc20erc721/erc20_erc721_provider.go @@ -96,11 +96,11 @@ func (p *ERC20ERC721Provider) GetDockerServiceDefinitions(tokenIdx int) []*docke } } - env := map[string]interface{}{ + env := p.stack.ConcatenateWithProvidedEnvironmentVars(map[string]interface{}{ "ETHCONNECT_URL": p.blockchainProvider.GetConnectorURL(member), "ETHCONNECT_TOPIC": connectorName, "AUTO_INIT": "false", - } + }) if !p.stack.DisableTokenFactories && factoryAddress != "" { env["FACTORY_CONTRACT_ADDRESS"] = factoryAddress diff --git a/pkg/types/options.go b/pkg/types/options.go index ed9cdf60..a43b0770 100644 --- a/pkg/types/options.go +++ b/pkg/types/options.go @@ -31,40 +31,44 @@ type StartOptions struct { } type InitOptions struct { - StackName string - MemberCount int - FireFlyBasePort int - ServicesBasePort int - DatabaseProvider string - ExternalProcesses int - OrgNames []string - NodeNames []string - BlockchainConnector string - BlockchainProvider string - BlockchainNodeProvider string - TokenProviders []string - FireFlyVersion string - ManifestPath string - PrometheusEnabled bool - PrometheusPort int - SandboxEnabled bool - ExtraCoreConfigPath string - ExtraConnectorConfigPath string - BlockPeriod int - ContractAddress string - RemoteNodeURL string - ChainID int64 - DisableTokenFactories bool - RequestTimeout int - ReleaseChannel string - MultipartyEnabled bool - IPFSMode string - CCPYAMLPaths []string - MSPPaths []string - ChannelName string - ChaincodeName string - CustomPinSupport bool - RemoteNodeDeploy bool + StackName string + MemberCount int + FireFlyBasePort int + ServicesBasePort int + PtmBasePort int + DatabaseProvider string + ExternalProcesses int + OrgNames []string + NodeNames []string + BlockchainConnector string + BlockchainProvider string + BlockchainNodeProvider string + PrivateTransactionManager string + Consensus string + TokenProviders []string + FireFlyVersion string + ManifestPath string + PrometheusEnabled bool + PrometheusPort int + SandboxEnabled bool + ExtraCoreConfigPath string + ExtraConnectorConfigPath string + BlockPeriod int + ContractAddress string + RemoteNodeURL string + ChainID int64 + DisableTokenFactories bool + RequestTimeout int + ReleaseChannel string + MultipartyEnabled bool + IPFSMode string + CCPYAMLPaths []string + MSPPaths []string + ChannelName string + ChaincodeName string + CustomPinSupport bool + RemoteNodeDeploy bool + EnvironmentVars map[string]string } const IPFSMode = "ipfs_mode" @@ -96,10 +100,27 @@ const BlockchainNodeProvider = "blockchain_node_provider" var ( BlockchainNodeProviderGeth = fftypes.FFEnumValue(BlockchainNodeProvider, "geth") + BlockchainNodeProviderQuorum = fftypes.FFEnumValue(BlockchainNodeProvider, "quorum") BlockchainNodeProviderBesu = fftypes.FFEnumValue(BlockchainNodeProvider, "besu") BlockchainNodeProviderRemoteRPC = fftypes.FFEnumValue(BlockchainNodeProvider, "remote-rpc") ) +const Consensus = "consensus" + +var ( + ConsensusClique = fftypes.FFEnumValue(Consensus, "clique") + ConsensusRaft = fftypes.FFEnumValue(Consensus, "raft") + ConsensusIbft = fftypes.FFEnumValue(Consensus, "ibft") + ConsensusQbft = fftypes.FFEnumValue(Consensus, "qbft") +) + +const PrivateTransactionManager = "private_transaction_manager" + +var ( + PrivateTransactionManagerNone = fftypes.FFEnumValue(PrivateTransactionManager, "none") + PrivateTransactionManagerTessera = fftypes.FFEnumValue(PrivateTransactionManager, "tessera") +) + const DatabaseSelection = "database_selection" var ( diff --git a/pkg/types/organization.go b/pkg/types/organization.go index cbcd2469..4ecae854 100644 --- a/pkg/types/organization.go +++ b/pkg/types/organization.go @@ -32,6 +32,7 @@ type Organization struct { ExposedUIPort int `json:"exposedUiPort,omitempty"` ExposedSandboxPort int `json:"exposedSandboxPort,omitempty"` ExposedTokensPorts []int `json:"exposedTokensPorts,omitempty"` + ExposePtmTpPort int `json:"exposePtmTpPort,omitempty"` External bool `json:"external,omitempty"` OrgName string `json:"orgName,omitempty"` NodeName string `json:"nodeName,omitempty"` diff --git a/pkg/types/stack.go b/pkg/types/stack.go index 89c5c777..c490c873 100644 --- a/pkg/types/stack.go +++ b/pkg/types/stack.go @@ -25,35 +25,39 @@ import ( ) type Stack struct { - Name string `json:"name,omitempty"` - Members []*Organization `json:"members,omitempty"` - SwarmKey string `json:"swarmKey,omitempty"` - ExposedBlockchainPort int `json:"exposedBlockchainPort,omitempty"` - Database fftypes.FFEnum `json:"database"` - BlockchainProvider fftypes.FFEnum `json:"blockchainProvider"` - BlockchainConnector fftypes.FFEnum `json:"blockchainConnector"` - BlockchainNodeProvider fftypes.FFEnum `json:"blockchainNodeProvider"` - TokenProviders []fftypes.FFEnum `json:"tokenProviders"` - VersionManifest *VersionManifest `json:"versionManifest,omitempty"` - PrometheusEnabled bool `json:"prometheusEnabled,omitempty"` - SandboxEnabled bool `json:"sandboxEnabled,omitempty"` - MultipartyEnabled bool `json:"multiparty"` - ExposedPrometheusPort int `json:"exposedPrometheusPort,omitempty"` - ContractAddress string `json:"contractAddress,omitempty"` - ChainIDPtr *int64 `json:"chainID,omitempty"` - RemoteNodeURL string `json:"remoteNodeURL,omitempty"` - DisableTokenFactories bool `json:"disableTokenFactories,omitempty"` - RequestTimeout int `json:"requestTimeout,omitempty"` - IPFSMode fftypes.FFEnum `json:"ipfsMode"` - RemoteFabricNetwork bool `json:"remoteFabricNetwork,omitempty"` - ChannelName string `json:"channelName,omitempty"` - ChaincodeName string `json:"chaincodeName,omitempty"` - CustomPinSupport bool `json:"customPinSupport,omitempty"` - RemoteNodeDeploy bool `json:"remoteNodeDeploy,omitempty"` - InitDir string `json:"-"` - RuntimeDir string `json:"-"` - StackDir string `json:"-"` - State *StackState `json:"-"` + Name string `json:"name,omitempty"` + Members []*Organization `json:"members,omitempty"` + SwarmKey string `json:"swarmKey,omitempty"` + ExposedBlockchainPort int `json:"exposedBlockchainPort,omitempty"` + ExposedPtmPort int `json:"exposedPtmPort,omitempty"` + Database fftypes.FFEnum `json:"database"` + BlockchainProvider fftypes.FFEnum `json:"blockchainProvider"` + BlockchainConnector fftypes.FFEnum `json:"blockchainConnector"` + BlockchainNodeProvider fftypes.FFEnum `json:"blockchainNodeProvider"` + PrivateTransactionManager fftypes.FFEnum `json:"privateTransactionManager"` + Consensus fftypes.FFEnum `json:"consensus"` + TokenProviders []fftypes.FFEnum `json:"tokenProviders"` + VersionManifest *VersionManifest `json:"versionManifest,omitempty"` + PrometheusEnabled bool `json:"prometheusEnabled,omitempty"` + SandboxEnabled bool `json:"sandboxEnabled,omitempty"` + MultipartyEnabled bool `json:"multiparty"` + ExposedPrometheusPort int `json:"exposedPrometheusPort,omitempty"` + ContractAddress string `json:"contractAddress,omitempty"` + ChainIDPtr *int64 `json:"chainID,omitempty"` + RemoteNodeURL string `json:"remoteNodeURL,omitempty"` + DisableTokenFactories bool `json:"disableTokenFactories,omitempty"` + RequestTimeout int `json:"requestTimeout,omitempty"` + IPFSMode fftypes.FFEnum `json:"ipfsMode"` + RemoteFabricNetwork bool `json:"remoteFabricNetwork,omitempty"` + ChannelName string `json:"channelName,omitempty"` + ChaincodeName string `json:"chaincodeName,omitempty"` + CustomPinSupport bool `json:"customPinSupport,omitempty"` + RemoteNodeDeploy bool `json:"remoteNodeDeploy,omitempty"` + EnvironmentVars map[string]interface{} `json:"environmentVars"` + InitDir string `json:"-"` + RuntimeDir string `json:"-"` + StackDir string `json:"-"` + State *StackState `json:"-"` } func (s *Stack) ChainID() int64 { @@ -106,3 +110,14 @@ func (s *Stack) IsOldFileStructure() (bool, error) { return false, nil } } + +func (s *Stack) ConcatenateWithProvidedEnvironmentVars(input map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + for k, v := range input { + result[k] = v + } + for k, v := range s.EnvironmentVars { + result[k] = v // Overwrites existing keys from previous map + } + return result +}