diff --git a/Taskfile.helper.yml b/Taskfile.helper.yml index c78a91e53..fadd964c3 100644 --- a/Taskfile.helper.yml +++ b/Taskfile.helper.yml @@ -297,7 +297,7 @@ tasks: status: - kind get clusters | grep -q "${SOLO_CLUSTER_NAME}" cmds: - - kind create cluster -n "${SOLO_CLUSTER_NAME}" --image "${KIND_IMAGE}" + - kind create cluster -n "${SOLO_CLUSTER_NAME}" --image "${KIND_IMAGE}" --config ~/Documents/kind-cluster.yaml - sleep 10 # wait for control plane to come up - kubectl config set-context kind-${SOLO_CLUSTER_NAME} @@ -434,6 +434,15 @@ tasks: sleep 4 fi + solo:mirror-node-only: + silent: true + desc: solo mirror-node deploy with port forward on explorer + deps: + - task: "init" + cmds: + - SOLO_HOME_DIR=${SOLO_HOME_DIR} npm run solo -- mirror-node deploy --namespace "${SOLO_NAMESPACE}" ${SOLO_CHARTS_DIR_FLAG} ${MIRROR_NODE_DEPLOY_EXTRA_FLAGS} --pinger -q --dev + + solo:destroy-mirror-node: silent: true desc: solo mirror-node destroy @@ -464,3 +473,21 @@ tasks: cmds: - echo "Cleaning up temporary files..." - rm -f /tmp/solo-${USER}-* || true + + solo:external-database: + silent: false + desc: setup external database PostgreSQL with helm + cmds: + - | + {{.solo_bin_dir}}/helm install my-postgresql bitnami/postgresql \ + --namespace database --create-namespace \ + --set global.postgresql.auth.postgresPassword={{.postgres_password}} \ + --set primary.persistence.enabled=false --set secondary.enabled=false + - name: "Wait for PostgreSQL pod to be ready" + cmd: kubectl wait --for=condition=ready pod/my-postgresql-0 -n database --timeout=60s + - name: "Copy init.sql inside the database pod" + cmd: kubectl cp ../custom-mirror-node-database/scripts/init.sh my-postgresql-0:/tmp/init.sh -n database + - name: "Make init.sh executable" + cmd: kubectl exec -it my-postgresql-0 -n database -- chmod +x /tmp/init.sh + - name: "Execute init.sh inside the database pod" + cmd: kubectl exec -it my-postgresql-0 -n database -- /bin/bash /tmp/init.sh diff --git a/examples/custom-mirror-node-database/scripts/init.sh b/examples/custom-mirror-node-database/scripts/init.sh index 70c27b821..ff68e1e1d 100644 --- a/examples/custom-mirror-node-database/scripts/init.sh +++ b/examples/custom-mirror-node-database/scripts/init.sh @@ -1,7 +1,3 @@ -# Example if initialization script for the external database, -# values must match those inside values.yaml if used - -cat > init1.sh << 'EOF' #!/bin/bash set -e @@ -139,6 +135,3 @@ if [[ -f "${PGHBACONF}.bak" ]]; then mv "${PGHBACONF}.bak" "${PGHBACONF}" pg_ctl reload fi -EOF -chmod +x init1.sh -./init1.sh diff --git a/examples/external-database-test/Taskfile.yml b/examples/external-database-test/Taskfile.yml new file mode 100644 index 000000000..496e0920b --- /dev/null +++ b/examples/external-database-test/Taskfile.yml @@ -0,0 +1,50 @@ +version: 3 +includes: + main: + taskfile: ../../Taskfile.helper.yml + flatten: true +vars: + solo_home_override_dir: "/Users/{{.HOME}}/.solo" + use_port_forwards: "true" + postgres_username: "postgres" + postgres_password: "XXXXXXXXXXXX" + postgres_host: "my-postgresql-0.database.svc.cluster.local" +env: + SOLO_NETWORK_SIZE: "1" + SOLO_NAMESPACE: "external-database-test" + MIRROR_NODE_DEPLOY_EXTRA_FLAGS: "--use-external-database --external-database-host {{.postgres_host}} --external-database-owner-username {{.postgres_username}} --external-database-owner-password {{.postgres_password}}" +tasks: + default: + silent: true + desc: install Solo, create a kind cluster, deploy the network, set it up, and start it + deps: + - task: "init" + cmds: + - echo "This command is meant to deploy a Solo network to a Kind cluster on your local machine, " + - echo "ctrl-c if this is not what you want to do." + - sleep 5 + + install: + desc: create the cluster, solo init, solo cluster create, solo node keys, solo network deploy + deps: + - task: "init" + cmds: + - task: "cluster:create" + - task: "solo:init" + - task: "solo:cluster:setup" + - task: "solo:keys" + - task: "solo:network:deploy" + - task: "solo:node:setup" + - task: "solo:node:start" + - task: "solo:external-database" + - task: "solo:mirror-node-only" + - name: "Copy database-seeding-query.sql inside the database pod" + cmd: kubectl cp {{.HOME}}/.solo/cache/database-seeding-query.sql my-postgresql-0:/tmp/database-seeding-query.sql -n database + - name: "Execute the database-seeding–query.sql against the database" + cmd: kubectl exec -it my-postgresql-0 -n database -- env PGPASSWORD={{.postgres_password}} psql -U {{.postgres_username}} -d mirror_node -f /tmp/database-seeding-query.sql + destroy: + desc: destroy relay, mirror-node, and network + deps: + - task: "init" + cmds: + - task: "cluster:destroy" diff --git a/examples/solo-gke-test/Taskfile.yml b/examples/solo-gke-test/Taskfile.yml index 2bd1340f0..5e36ab091 100644 --- a/examples/solo-gke-test/Taskfile.yml +++ b/examples/solo-gke-test/Taskfile.yml @@ -27,3 +27,5 @@ env: NETWORK_DEPLOY_EXTRA_FLAGS: "--haproxy-ips node1=,node2=,node3=,node4= --pvcs" MIRROR_NODE_DEPLOY_EXTRA_FLAGS: "--values-file {{.USER_WORKING_DIR}}/mirror-and-explorer-values.yaml" RELAY_NODE_DEPLOY_EXTRA_FLAGS: "--values-file {{.USER_WORKING_DIR}}/relay-values.yaml" + + diff --git a/src/commands/flags.ts b/src/commands/flags.ts index db955b6d3..b6268097b 100644 --- a/src/commands/flags.ts +++ b/src/commands/flags.ts @@ -1513,15 +1513,76 @@ export class Flags { }, }; - static readonly customMirrorNodeDatabaseValuePath: CommandFlag = { - constName: 'customMirrorNodeDatabaseValuePath', - name: 'custom-mirror-node-database-values-path', + static readonly useExternalDatabase: CommandFlag = { + constName: 'useExternalDatabase', + name: 'use-external-database', definition: { - describe: 'Path to custom mirror node database values', + describe: + 'Set to true if you have an external database to use instead of the database that the Mirror Node Helm chart supplies', + defaultValue: false, + type: 'boolean', + }, + prompt: undefined, + }; + + static readonly externalDatabaseHost: CommandFlag = { + constName: 'externalDatabaseHost', + name: 'external-database-host', + definition: { + describe: "Use to provide the external database host if the '--use-external-database' is passed", defaultValue: '', type: 'string', }, - prompt: undefined, + prompt: async function promptGrpcWebTlsKeyPath(task: ListrTaskWrapper, input: any) { + return await Flags.promptText( + task, + input, + Flags.externalDatabaseHost.definition.defaultValue, + 'Enter host of the external database', + null, + Flags.externalDatabaseHost.name, + ); + }, + }; + + static readonly externalDatabaseOwnerUsername: CommandFlag = { + constName: 'externalDatabaseOwnerUsername', + name: 'external-database-owner-username', + definition: { + describe: "Use to provide the external database owner's username if the '--use-external-database' is passed", + defaultValue: '', + type: 'string', + }, + prompt: async function promptGrpcWebTlsKeyPath(task: ListrTaskWrapper, input: any) { + return await Flags.promptText( + task, + input, + Flags.externalDatabaseOwnerUsername.definition.defaultValue, + 'Enter username of the external database owner', + null, + Flags.externalDatabaseOwnerUsername.name, + ); + }, + }; + + static readonly externalDatabaseOwnerPassword: CommandFlag = { + constName: 'externalDatabaseOwnerPassword', + name: 'external-database-owner-password', + definition: { + describe: "Use to provide the external database owner's password if the '--use-external-database' is passed", + defaultValue: '', + type: 'string', + }, + prompt: async function promptGrpcWebTlsKeyPath(task: ListrTaskWrapper, input: any) { + return await Flags.promptText( + task, + input, + Flags.externalDatabaseOwnerPassword.definition.defaultValue, + 'Enter password of the external database owner', + null, + Flags.externalDatabaseOwnerPassword.name, + ); + }, }; static readonly grpcTlsKeyPath: CommandFlag = { @@ -1807,7 +1868,10 @@ export class Flags { Flags.upgradeZipFile, Flags.userEmailAddress, Flags.valuesFile, - Flags.customMirrorNodeDatabaseValuePath, + Flags.useExternalDatabase, + Flags.externalDatabaseHost, + Flags.externalDatabaseOwnerUsername, + Flags.externalDatabaseOwnerPassword, ]; /** Resets the definition.disablePrompt for all flags */ diff --git a/src/commands/mirror_node.ts b/src/commands/mirror_node.ts index f13e694b0..f500f3e00 100644 --- a/src/commands/mirror_node.ts +++ b/src/commands/mirror_node.ts @@ -9,7 +9,7 @@ import {type AccountManager} from '../core/account_manager.js'; import {type ProfileManager} from '../core/profile_manager.js'; import {BaseCommand} from './base.js'; import {Flags as flags} from './flags.js'; -import {getEnvValue} from '../core/helpers.js'; +import * as helpers from '../core/helpers.js'; import {type CommandBuilder, type PodName} from '../types/aliases.js'; import {type Opts} from '../types/command_types.js'; import {ListrLease} from '../core/lease/listr_lease.js'; @@ -19,6 +19,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import {type Optional, type SoloListrTask} from '../types/index.js'; import * as Base64 from 'js-base64'; +import chalk from 'chalk'; +import {type CommandFlag} from '../types/flag_types.js'; interface MirrorNodeDeployConfigClass { chartDirectory: string; @@ -28,18 +30,22 @@ interface MirrorNodeDeployConfigClass { valuesFile: string; chartPath: string; valuesArg: string; + quiet: boolean; mirrorNodeVersion: string; getUnusedConfigs: () => string[]; pinger: boolean; operatorId: string; operatorKey: string; - customMirrorNodeDatabaseValuePath: Optional; + useExternalDatabase: boolean; storageType: constants.StorageType; storageAccessKey: string; storageSecrets: string; storageEndpoint: string; storageBucket: string; storageBucketPrefix: string; + externalDatabaseHost: Optional; + externalDatabaseOwnerUsername: Optional; + externalDatabaseOwnerPassword: Optional; } interface Context { @@ -76,7 +82,7 @@ export class MirrorNodeCommand extends BaseCommand { flags.valuesFile, flags.mirrorNodeVersion, flags.pinger, - flags.customMirrorNodeDatabaseValuePath, + flags.useExternalDatabase, flags.operatorId, flags.operatorKey, flags.storageType, @@ -85,6 +91,9 @@ export class MirrorNodeCommand extends BaseCommand { flags.storageEndpoint, flags.storageBucket, flags.storageBucketPrefix, + flags.externalDatabaseHost, + flags.externalDatabaseOwnerUsername, + flags.externalDatabaseOwnerPassword, ]; } @@ -127,6 +136,42 @@ export class MirrorNodeCommand extends BaseCommand { valuesArg += ` --set importer.env.HEDERA_MIRROR_IMPORTER_DOWNLOADER_SOURCES_0_CREDENTIALS_ACCESSKEY=${config.storageAccessKey}`; valuesArg += ` --set importer.env.HEDERA_MIRROR_IMPORTER_DOWNLOADER_SOURCES_0_CREDENTIALS_SECRETKEY=${config.storageSecrets}`; } + + // if the useExternalDatabase populate all the required values before installing the chart + if (config.useExternalDatabase) { + const { + externalDatabaseHost: host, + externalDatabaseOwnerUsername: username, + externalDatabaseOwnerPassword: password, + } = config; + + config.valuesArg += helpers.populateHelmArgs({ + // Disable default database deployment + 'stackgres.enabled': false, + 'postgresql.enabled': false, + + // Set the host and name + 'db.host': host, + 'db.name': 'mirror_node', + + // set the usernames + 'db.owner.username': username, + 'importer.db.username': username, + 'grpc.db.username': username, + 'rest.db.username': username, + 'restjava.db.username': username, + 'web3.db.username': username, + + // set the passwords + 'db.owner.password': password, + 'importer.db.password': password, + 'grpc.db.password': password, + 'rest.db.password': password, + 'restjava.db.password': password, + 'web3.db.password': password, + }); + } + return valuesArg; } @@ -148,6 +193,10 @@ export class MirrorNodeCommand extends BaseCommand { flags.pinger, flags.operatorId, flags.operatorKey, + flags.useExternalDatabase, + flags.externalDatabaseHost, + flags.externalDatabaseOwnerUsername, + flags.externalDatabaseOwnerPassword, ]); await self.configManager.executePrompt(task, MirrorNodeCommand.DEPLOY_FLAGS_LIST); @@ -197,13 +246,42 @@ export class MirrorNodeCommand extends BaseCommand { const operatorKeyFromK8 = Base64.decode(secrets[0].data.privateKey); ctx.config.valuesArg += ` --set monitor.config.hedera.mirror.monitor.operator.privateKey=${operatorKeyFromK8}`; } - } catch (e: Error | any) { + } catch (e) { throw new SoloError(`Error getting operator key: ${e.message}`, e); } } } } + const isQuiet = ctx.config.quiet; + + // In case the useExternalDatabase is set, prompt for the rest of the required data + if (ctx.config.useExternalDatabase && !isQuiet) { + await self.configManager.executePrompt(task, [ + flags.externalDatabaseHost, + flags.externalDatabaseOwnerUsername, + flags.externalDatabaseOwnerPassword, + ]); + } else if (ctx.config.useExternalDatabase) { + if ( + !ctx.config.externalDatabaseHost || + !ctx.config.externalDatabaseOwnerUsername || + !ctx.config.externalDatabaseOwnerPassword + ) { + const missingFlags: CommandFlag[] = []; + if (!ctx.config.externalDatabaseHost) missingFlags.push(flags.externalDatabaseHost); + if (!ctx.config.externalDatabaseOwnerUsername) missingFlags.push(flags.externalDatabaseOwnerUsername); + if (!ctx.config.externalDatabaseOwnerPassword) missingFlags.push(flags.externalDatabaseOwnerPassword); + if (missingFlags.length) { + const errorMessage = + 'There are missing values that need to be provided when' + + `${chalk.cyan(`--${flags.useExternalDatabase.name}`)} is provided: `; + + throw new SoloError(`${errorMessage} ${missingFlags.map(flag => `--${flag.name}`).join(', ')}`); + } + } + } + if (!(await self.k8.hasNamespace(ctx.config.namespace))) { throw new SoloError(`namespace ${ctx.config.namespace} does not exist`); } @@ -226,20 +304,6 @@ export class MirrorNodeCommand extends BaseCommand { { title: 'Deploy mirror-node', task: async ctx => { - if (ctx.config.customMirrorNodeDatabaseValuePath) { - if (!fs.existsSync(ctx.config.customMirrorNodeDatabaseValuePath)) { - throw new SoloError('Path provided for custom mirror node database value is not found'); - } - - // Check if the file has a .yaml or .yml extension - const fileExtension = path.extname(ctx.config.customMirrorNodeDatabaseValuePath); - if (fileExtension !== '.yaml' && fileExtension !== '.yml') { - throw new SoloError('The provided file is not a valid YAML file (.yaml or .yml)'); - } - - ctx.config.valuesArg += ` --values ${ctx.config.customMirrorNodeDatabaseValuePath}`; - } - await self.chartManager.install( ctx.config.namespace, constants.MIRROR_NODE_RELEASE_NAME, @@ -271,7 +335,7 @@ export class MirrorNodeCommand extends BaseCommand { constants.PODS_READY_MAX_ATTEMPTS, constants.PODS_READY_DELAY, ), - skip: ctx => !!ctx.config.customMirrorNodeDatabaseValuePath, + skip: ctx => !!ctx.config.useExternalDatabase, }, { title: 'Check REST API', @@ -344,9 +408,29 @@ export class MirrorNodeCommand extends BaseCommand { }, ${exchangeRatesFileIdNum}, 17);`; const sqlQuery = [importFeesQuery, importExchangeRatesQuery].join('\n'); - if (ctx.config.customMirrorNodeDatabaseValuePath) { - fs.writeFileSync(path.join(constants.SOLO_CACHE_DIR, 'database-seeding-query.sql'), sqlQuery); - return; + // When useExternalDatabase flag is enabled, the query is not executed, + // but exported to the specified path inside the cache directory, + // and the user has the responsibility to execute it manually on his own + if (ctx.config.useExternalDatabase) { + // Build the path + const databaseSeedingQueryPath = path.join( + constants.SOLO_CACHE_DIR, + 'database-seeding-query.sql', + ); + + // Write the file database seeding query inside the cache + fs.writeFileSync(databaseSeedingQueryPath, sqlQuery); + + // Notify the user + self.logger.showUser( + chalk.cyan( + 'Please run the following SQL script against the external database ' + + 'to enable Mirror Node to function correctly:', + ), + chalk.yellow(databaseSeedingQueryPath), + ); + + return; //! stop the execution } const pods = await this.k8.getPodsByLabel(['app.kubernetes.io/name=postgres']); @@ -361,15 +445,15 @@ export class MirrorNodeCommand extends BaseCommand { '/bin/bash -c printenv', ); const mirrorEnvVarsArray = mirrorEnvVars.split('\n'); - const HEDERA_MIRROR_IMPORTER_DB_OWNER = getEnvValue( + const HEDERA_MIRROR_IMPORTER_DB_OWNER = helpers.getEnvValue( mirrorEnvVarsArray, 'HEDERA_MIRROR_IMPORTER_DB_OWNER', ); - const HEDERA_MIRROR_IMPORTER_DB_OWNERPASSWORD = getEnvValue( + const HEDERA_MIRROR_IMPORTER_DB_OWNERPASSWORD = helpers.getEnvValue( mirrorEnvVarsArray, 'HEDERA_MIRROR_IMPORTER_DB_OWNERPASSWORD', ); - const HEDERA_MIRROR_IMPORTER_DB_NAME = getEnvValue( + const HEDERA_MIRROR_IMPORTER_DB_NAME = helpers.getEnvValue( mirrorEnvVarsArray, 'HEDERA_MIRROR_IMPORTER_DB_NAME', ); diff --git a/src/core/helpers.ts b/src/core/helpers.ts index b62fe9cd3..39c454a89 100644 --- a/src/core/helpers.ts +++ b/src/core/helpers.ts @@ -371,3 +371,13 @@ export function resolveValidJsonFilePath(filePath: string, defaultPath?: string) throw new SoloError(`Invalid JSON data in file: ${filePath}`); } } + +export function populateHelmArgs(valuesMapping: Record): string { + let valuesArg = ''; + + for (const [key, value] of Object.entries(valuesMapping)) { + valuesArg += ` --set ${key}=${value}`; + } + + return valuesArg; +} diff --git a/test/e2e/commands/mirror_node.test.ts b/test/e2e/commands/mirror_node.test.ts index fdf0f7eef..c196f1305 100644 --- a/test/e2e/commands/mirror_node.test.ts +++ b/test/e2e/commands/mirror_node.test.ts @@ -89,6 +89,9 @@ e2eTestSuite(testName, argv, undefined, undefined, undefined, undefined, undefin flags.quiet.constName, flags.storageSecrets.constName, flags.storageEndpoint.constName, + flags.externalDatabaseHost.constName, + flags.externalDatabaseOwnerUsername.constName, + flags.externalDatabaseOwnerPassword.constName, ]); expect(explorerCommand.getUnusedConfigs(MirrorNodeCommand.DEPLOY_CONFIGS_NAME)).to.deep.equal([ flags.profileFile.constName,