Changes for new version of esplora image (#62)

* changes for new version of esplora image

* add electrs port and esplora url env vars in compose yaml files

* wrap viper methods into Config type and use constants package

* add controller to interact with nigiri resources:
 * .env for docker-compose
 * docker daemon
 * json config file

* add use of constants and config packages and change start flag from --port to --env

* add package for global constants and variables

* add use of controller and constants packages instead of local methods and vars

* bump version

* use contants in logs command tests
This commit is contained in:
Pietralberto Mazza
2019-12-09 14:58:32 +00:00
committed by Marco Argentieri
parent d0b3676c14
commit e02c2fdd0d
15 changed files with 909 additions and 458 deletions

View File

@@ -3,12 +3,11 @@ package cmd
import (
"encoding/json"
"os"
"path/filepath"
"github.com/mitchellh/go-homedir"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/vulpemventures/nigiri/cli/config"
"github.com/vulpemventures/nigiri/cli/constants"
)
var (
@@ -18,38 +17,25 @@ var (
flagAttachLiquid bool
flagLiquidService bool
flagEnv string
defaultPorts = map[string]map[string]int{
"bitcoin": {
"node": 18443,
"electrs_rpc": 60401,
"chopsticks": 3000,
"esplora": 5000,
},
"liquid": {
"node": 7041,
"electrs_rpc": 51401,
"chopsticks": 3001,
"esplora": 5001,
},
}
)
var RootCmd = &cobra.Command{
Use: "nigiri",
Short: "Nigiri lets you manage a full dockerized bitcoin environment",
Long: "Nigiri lets you create your dockerized environment with a bitcoin and optionally a liquid node + block explorer powered by an electrum server for every network",
Version: "0.0.3",
Version: "0.0.4",
}
func init() {
defaultDir := getDefaultDir()
ports, _ := json.Marshal(defaultPorts)
c := &config.Config{}
viper := c.Viper()
defaultDir := c.GetPath()
defaultJSON, _ := json.Marshal(constants.DefaultEnv)
RootCmd.PersistentFlags().StringVar(&flagDatadir, "datadir", defaultDir, "Set nigiri default directory")
StartCmd.PersistentFlags().StringVar(&flagNetwork, "network", "regtest", "Set bitcoin network - regtest only for now")
StartCmd.PersistentFlags().BoolVar(&flagAttachLiquid, "liquid", false, "Enable liquid sidechain")
StartCmd.PersistentFlags().StringVar(&flagEnv, "ports", string(ports), "Set services ports in JSON format")
StartCmd.PersistentFlags().StringVar(&flagEnv, "env", string(defaultJSON), "Set compose env in JSON format")
StopCmd.PersistentFlags().BoolVar(&flagDelete, "delete", false, "Stop and delete nigiri")
LogsCmd.PersistentFlags().BoolVar(&flagLiquidService, "liquid", false, "Set to see logs of a liquid service")
@@ -57,18 +43,12 @@ func init() {
RootCmd.AddCommand(StopCmd)
RootCmd.AddCommand(LogsCmd)
viper := config.Viper()
viper.BindPFlag(config.Datadir, RootCmd.PersistentFlags().Lookup("datadir"))
viper.BindPFlag(config.Network, StartCmd.PersistentFlags().Lookup("network"))
viper.BindPFlag(config.AttachLiquid, StartCmd.PersistentFlags().Lookup("liquid"))
viper.BindPFlag(constants.Datadir, RootCmd.PersistentFlags().Lookup("datadir"))
viper.BindPFlag(constants.Network, StartCmd.PersistentFlags().Lookup("network"))
viper.BindPFlag(constants.AttachLiquid, StartCmd.PersistentFlags().Lookup("liquid"))
cobra.OnInitialize(func() {
log.SetOutput(os.Stdout)
log.SetLevel(log.InfoLevel)
})
}
func getDefaultDir() string {
home, _ := homedir.Expand("~")
return filepath.Join(home, ".nigiri")
}

View File

@@ -1,12 +1,11 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"reflect"
"github.com/vulpemventures/nigiri/cli/config"
"github.com/vulpemventures/nigiri/cli/constants"
"github.com/vulpemventures/nigiri/cli/controller"
"github.com/spf13/cobra"
)
@@ -18,42 +17,39 @@ var LogsCmd = &cobra.Command{
PreRunE: logsChecks,
}
var services = map[string]bool{
"node": true,
"electrs": true,
"esplora": true,
"chopsticks": true,
}
func logsChecks(cmd *cobra.Command, args []string) error {
datadir, _ := cmd.Flags().GetString("datadir")
isLiquidService, _ := cmd.Flags().GetBool("liquid")
if !isDatadirOk(datadir) {
return fmt.Errorf("Invalid datadir, it must be an absolute path: %s", datadir)
}
if len(args) != 1 {
return fmt.Errorf("Invalid number of args, expected 1, got: %d", len(args))
}
service := args[0]
if !services[service] {
return fmt.Errorf("Invalid service, must be one of %s. Got: %s", reflect.ValueOf(services).MapKeys(), service)
}
isRunning, err := nigiriIsRunning()
ctl, err := controller.NewController()
if err != nil {
return err
}
if !isRunning {
return fmt.Errorf("Nigiri is not running")
if err := ctl.ParseDatadir(datadir); err != nil {
return err
}
if len(args) != 1 {
return constants.ErrInvalidArgs
}
if err := config.ReadFromFile(datadir); err != nil {
service := args[0]
if err := ctl.ParseServiceName(service); err != nil {
return err
}
if isLiquidService && isLiquidService != config.GetBool(config.AttachLiquid) {
return fmt.Errorf("Nigiri has been started with no Liquid sidechain.\nPlease stop and restart it using the --liquid flag")
if isRunning, err := ctl.IsNigiriRunning(); err != nil {
return err
} else if !isRunning {
return constants.ErrNigiriNotRunning
}
if err := ctl.ReadConfigFile(datadir); err != nil {
return err
}
if isLiquidService && isLiquidService != ctl.GetConfigBoolField(constants.AttachLiquid) {
return constants.ErrNigiriLiquidNotEnabled
}
return nil
@@ -64,10 +60,15 @@ func logs(cmd *cobra.Command, args []string) error {
datadir, _ := cmd.Flags().GetString("datadir")
isLiquidService, _ := cmd.Flags().GetBool("liquid")
serviceName := getServiceName(service, isLiquidService)
composePath := getPath(datadir, "compose")
envPath := getPath(datadir, "env")
env := loadEnv(envPath)
ctl, err := controller.NewController()
if err != nil {
return err
}
serviceName := ctl.GetServiceName(service, isLiquidService)
composePath := ctl.GetResourcePath(datadir, "compose")
envPath := ctl.GetResourcePath(datadir, "env")
env := ctl.LoadComposeEnvironment(envPath)
bashCmd := exec.Command("docker-compose", "-f", composePath, "logs", serviceName)
bashCmd.Stdout = os.Stdout
@@ -80,19 +81,3 @@ func logs(cmd *cobra.Command, args []string) error {
return nil
}
func getServiceName(name string, liquid bool) string {
service := name
if service == "node" {
service = "bitcoin"
}
if liquid {
if service == "bitcoin" {
service = "liquid"
} else {
service = fmt.Sprintf("%s-liquid", service)
}
}
return service
}

View File

@@ -2,6 +2,8 @@ package cmd
import (
"testing"
"github.com/vulpemventures/nigiri/cli/constants"
)
var (
@@ -41,7 +43,7 @@ func TestLogLiquidServices(t *testing.T) {
}
func TestLogShouldFail(t *testing.T) {
expectedError := "Nigiri is not running"
expectedError := constants.ErrNigiriNotRunning.Error()
err := testCommand("logs", serviceList[0], bitcoin)
if err == nil {
@@ -65,7 +67,7 @@ func TestStartBitcoinAndLogNigiriServicesShouldFail(t *testing.T) {
t.Fatal(err)
}
expectedError := "Nigiri has been started with no Liquid sidechain.\nPlease stop and restart it using the --liquid flag"
expectedError := constants.ErrNigiriLiquidNotEnabled.Error()
err := testCommand("logs", serviceList[0], liquid)
if err == nil {

View File

@@ -1,25 +1,16 @@
package cmd
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/vulpemventures/nigiri/cli/config"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/spf13/cobra"
"github.com/vulpemventures/nigiri/cli/constants"
"github.com/vulpemventures/nigiri/cli/controller"
)
const listAll = true
var StartCmd = &cobra.Command{
Use: "start",
Short: "Build and start Nigiri",
@@ -30,27 +21,30 @@ var StartCmd = &cobra.Command{
func startChecks(cmd *cobra.Command, args []string) error {
network, _ := cmd.Flags().GetString("network")
datadir, _ := cmd.Flags().GetString("datadir")
ports, _ := cmd.Flags().GetString("ports")
env, _ := cmd.Flags().GetString("env")
// check flags
if !isNetworkOk(network) {
return fmt.Errorf("Invalid network: %s", network)
}
if !isDatadirOk(datadir) {
return fmt.Errorf("Invalid datadir, it must be an absolute path: %s", datadir)
}
if !isEnvOk(ports) {
return fmt.Errorf("Invalid env JSON, it must contain a \"bitcoin\" object with at least one service specified. It can optionally contain a \"liquid\" object with at least one service specified.\nGot: %s", ports)
}
// if nigiri is already running return error
isRunning, err := nigiriIsRunning()
ctl, err := controller.NewController()
if err != nil {
return err
}
if isRunning {
return fmt.Errorf("Nigiri is already running, please stop it first")
if err := ctl.ParseNetwork(network); err != nil {
return err
}
if err := ctl.ParseDatadir(datadir); err != nil {
return err
}
composeEnv, err := ctl.ParseEnv(env)
if err != nil {
return err
}
// if nigiri is already running return error
if isRunning, err := ctl.IsNigiriRunning(); err != nil {
return err
} else if isRunning {
return constants.ErrNigiriAlreadyRunning
}
// scratch datadir if not exists
@@ -60,65 +54,77 @@ func startChecks(cmd *cobra.Command, args []string) error {
// if datadir is set we must copy the resources directory from ~/.nigiri
// to the new one
if datadir != getDefaultDir() {
if err := copyResources(datadir); err != nil {
if datadir != ctl.GetDefaultDatadir() {
if err := ctl.NewDatadirFromDefault(datadir); err != nil {
return err
}
}
// if nigiri not exists, we need to write the configuration file and then
// read from it to get viper updated, otherwise we just read from it.
exists, err := nigiriExistsAndNotRunning()
if err != nil {
if isStopped, err := ctl.IsNigiriStopped(); err != nil {
return err
}
if !exists {
filedir := getPath(datadir, "config")
if err := config.WriteConfig(filedir); err != nil {
} else if isStopped {
if err := ctl.ReadConfigFile(datadir); err != nil {
return err
}
} else {
filedir := ctl.GetResourcePath(datadir, "config")
if err := ctl.WriteConfigFile(filedir); err != nil {
return err
}
// .env must be in the directory where docker-compose is run from, not where YAML files are placed
// https://docs.docker.com/compose/env-file/
filedir = getPath(datadir, "env")
if err := writeComposeEnvFile(filedir, ports); err != nil {
filedir = ctl.GetResourcePath(datadir, "env")
if err := ctl.WriteComposeEnvironment(filedir, composeEnv); err != nil {
return err
}
}
if err := config.ReadFromFile(datadir); err != nil {
return err
}
return nil
}
func start(cmd *cobra.Command, args []string) error {
datadir, _ := cmd.Flags().GetString("datadir")
liquidEnabled, _ := cmd.Flags().GetBool("liquid")
bashCmd, err := getStartBashCmd(datadir)
ctl, err := controller.NewController()
if err != nil {
return err
}
datadir, _ := cmd.Flags().GetString("datadir")
liquidEnabled := ctl.GetConfigBoolField(constants.AttachLiquid)
envPath := ctl.GetResourcePath(datadir, "env")
composePath := ctl.GetResourcePath(datadir, "compose")
bashCmd := exec.Command("docker-compose", "-f", composePath, "up", "-d")
if isStopped, err := ctl.IsNigiriStopped(); err != nil {
return err
} else if isStopped {
bashCmd = exec.Command("docker-compose", "-f", composePath, "start")
}
bashCmd.Stdout = os.Stdout
bashCmd.Stderr = os.Stderr
bashCmd.Env = ctl.LoadComposeEnvironment(envPath)
if err := bashCmd.Run(); err != nil {
return err
}
path := getPath(datadir, "env")
ports, err := readComposeEnvFile(path)
path := ctl.GetResourcePath(datadir, "env")
env, err := ctl.ReadComposeEnvironment(path)
if err != nil {
return err
}
prettyPrintServices := func(chain string, services map[string]int) {
fmt.Printf("%s services:\n", chain)
fmt.Printf("%s services:\n", strings.Title(chain))
for name, port := range services {
formatName := fmt.Sprintf("%s:", name)
fmt.Printf(" %-14s localhost:%d\n", formatName, port)
}
}
for chain, services := range ports {
for chain, services := range env["ports"].(map[string]map[string]int) {
if chain == "bitcoin" {
prettyPrintServices(chain, services)
} else if liquidEnabled {
@@ -128,226 +134,3 @@ func start(cmd *cobra.Command, args []string) error {
return nil
}
var images = map[string]bool{
"vulpemventures/bitcoin:latest": true,
"vulpemventures/liquid:latest": true,
"vulpemventures/electrs:latest": true,
"vulpemventures/electrs-liquid:latest": true,
"vulpemventures/esplora:latest": true,
"vulpemventures/esplora-liquid:latest": true,
"vulpemventures/nigiri-chopsticks:latest": true,
}
func copyResources(datadir string) error {
defaultDatadir := getDefaultDir()
cmd := exec.Command("cp", "-R", filepath.Join(defaultDatadir, "resources"), datadir)
return cmd.Run()
}
func nigiriExists(listAll bool) (bool, error) {
cli, err := client.NewEnvClient()
if err != nil {
return false, err
}
containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{All: listAll})
if err != nil {
return false, err
}
for _, container := range containers {
if images[container.Image] {
return true, nil
}
}
return false, nil
}
func isNetworkOk(network string) bool {
var ok bool
for _, n := range []string{"regtest"} {
if network == n {
ok = true
}
}
return ok
}
func isDatadirOk(datadir string) bool {
return filepath.IsAbs(datadir)
}
func isEnvOk(stringifiedJSON string) bool {
var parsedJSON map[string]map[string]int
err := json.Unmarshal([]byte(stringifiedJSON), &parsedJSON)
if err != nil {
return false
}
if len(parsedJSON) <= 0 {
return false
}
if len(parsedJSON["bitcoin"]) <= 0 {
return false
}
if parsedJSON["bitcoin"]["node"] <= 0 &&
parsedJSON["bitcoin"]["electrs"] <= 0 &&
parsedJSON["bitcoin"]["esplora"] <= 0 &&
parsedJSON["bitcoin"]["chopsticks"] <= 0 {
return false
}
if len(parsedJSON["liquid"]) > 0 &&
parsedJSON["liquid"]["node"] <= 0 &&
parsedJSON["liquid"]["electrs"] <= 0 &&
parsedJSON["liquid"]["esplora"] <= 0 &&
parsedJSON["liquid"]["chopsticks"] <= 0 {
return false
}
return true
}
func getPath(datadir, t string) string {
viper := config.Viper()
if t == "compose" {
network := viper.GetString("network")
attachLiquid := viper.GetBool("attachLiquid")
if attachLiquid {
network += "-liquid"
}
return filepath.Join(datadir, "resources", fmt.Sprintf("docker-compose-%s.yml", network))
}
if t == "env" {
return filepath.Join(datadir, ".env")
}
if t == "config" {
return filepath.Join(datadir, "nigiri.config.json")
}
return ""
}
func nigiriIsRunning() (bool, error) {
listOnlyRunningContainers := !listAll
return nigiriExists(listOnlyRunningContainers)
}
func nigiriExistsAndNotRunning() (bool, error) {
return nigiriExists(listAll)
}
func getStartBashCmd(datadir string) (*exec.Cmd, error) {
composePath := getPath(datadir, "compose")
envPath := getPath(datadir, "env")
env := loadEnv(envPath)
bashCmd := exec.Command("docker-compose", "-f", composePath, "up", "-d")
isStopped, err := nigiriExistsAndNotRunning()
if err != nil {
return nil, err
}
if isStopped {
bashCmd = exec.Command("docker-compose", "-f", composePath, "start")
}
bashCmd.Stdout = os.Stdout
bashCmd.Stderr = os.Stderr
bashCmd.Env = env
return bashCmd, nil
}
func writeComposeEnvFile(path string, stringifiedJSON string) error {
defaultJSON, _ := json.Marshal(defaultPorts)
env := map[string]map[string]int{}
json.Unmarshal([]byte(stringifiedJSON), &env)
if stringifiedJSON != string(defaultJSON) {
env = mergeComposeEnvFiles([]byte(stringifiedJSON))
}
fileContent := ""
for chain, services := range env {
for k, v := range services {
fileContent += fmt.Sprintf("%s_%s_PORT=%d\n", strings.ToUpper(chain), strings.ToUpper(k), v)
}
}
return ioutil.WriteFile(path, []byte(fileContent), os.ModePerm)
}
func readComposeEnvFile(path string) (map[string]map[string]int, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
ports := map[string]map[string]int{
"bitcoin": map[string]int{},
"liquid": map[string]int{},
}
// Each line is in the format PREFIX_SERVICE_NAME_SUFFIX=value
// PREFIX is either 'BITCOIN' or 'LIQUID', while SUFFIX is always 'PORT'
for scanner.Scan() {
line := scanner.Text()
splitLine := strings.Split(line, "=")
key := splitLine[0]
value, _ := strconv.Atoi(splitLine[1])
chain := "bitcoin"
if strings.HasPrefix(key, strings.ToUpper("liquid")) {
chain = "liquid"
}
suffix := "_PORT"
prefix := strings.ToUpper(fmt.Sprintf("%s_", chain))
trimmedKey := strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(key, prefix), suffix))
ports[chain][trimmedKey] = value
}
return ports, nil
}
func mergeComposeEnvFiles(rawJSON []byte) map[string]map[string]int {
newPorts := map[string]map[string]int{}
json.Unmarshal(rawJSON, &newPorts)
mergedPorts := map[string]map[string]int{}
for chain, services := range defaultPorts {
mergedPorts[chain] = make(map[string]int)
for name, port := range services {
newPort := newPorts[chain][name]
if newPort > 0 && newPort != port {
mergedPorts[chain][name] = newPort
} else {
mergedPorts[chain][name] = port
}
}
}
return mergedPorts
}
func loadEnv(path string) []string {
content, _ := ioutil.ReadFile(path)
lines := strings.Split(string(content), "\n")
env := os.Environ()
for _, line := range lines {
if line != "" {
env = append(env, line)
}
}
return env
}

View File

@@ -3,6 +3,9 @@ package cmd
import (
"fmt"
"testing"
"github.com/vulpemventures/nigiri/cli/constants"
"github.com/vulpemventures/nigiri/cli/controller"
)
const (
@@ -37,7 +40,7 @@ func TestStartStopBitcoin(t *testing.T) {
}
func TestStopBeforeStartShouldFail(t *testing.T) {
expectedError := "Nigiri is neither running nor stopped, please create it first"
expectedError := constants.ErrNigiriNotRunning.Error()
err := testCommand("stop", "", !delete)
if err == nil {
@@ -47,6 +50,7 @@ func TestStopBeforeStartShouldFail(t *testing.T) {
t.Fatalf("Expected error: %s, got: %s", expectedError, err)
}
expectedError = constants.ErrNigiriNotExisting.Error()
err = testCommand("stop", "", delete)
if err == nil {
t.Fatal("Should return error when trying to delete before starting")
@@ -57,7 +61,7 @@ func TestStopBeforeStartShouldFail(t *testing.T) {
}
func TestStartAfterStartShouldFail(t *testing.T) {
expectedError := "Nigiri is already running, please stop it first"
expectedError := constants.ErrNigiriAlreadyRunning.Error()
if err := testCommand("start", "", bitcoin); err != nil {
t.Fatal(err)
@@ -85,30 +89,48 @@ func TestStartAfterStartShouldFail(t *testing.T) {
}
func testStart(t *testing.T, flag bool) {
ctl, err := controller.NewController()
if err != nil {
t.Fatal(err)
}
if err := testCommand("start", "", flag); err != nil {
t.Fatal(err)
}
if isRunning, _ := nigiriIsRunning(); !isRunning {
t.Fatal("Nigiri should be started but services have not been found among running containers")
if isRunning, err := ctl.IsNigiriRunning(); err != nil {
t.Fatal(err)
} else if !isRunning {
t.Fatal("Nigiri should have been started but services have not been found among running containers")
}
}
func testStop(t *testing.T) {
fmt.Println(!delete)
ctl, err := controller.NewController()
if err != nil {
t.Fatal(err)
}
if err := testCommand("stop", "", !delete); err != nil {
t.Fatal(err)
}
if isStopped, _ := nigiriExistsAndNotRunning(); !isStopped {
t.Fatal("Nigiri should be stopped but services have not been found among stopped containers")
if isStopped, err := ctl.IsNigiriStopped(); err != nil {
t.Fatal(err)
} else if !isStopped {
t.Fatal("Nigiri should have been stopped but services have not been found among stopped containers")
}
}
func testDelete(t *testing.T) {
ctl, err := controller.NewController()
if err != nil {
t.Fatal(err)
}
if err := testCommand("stop", "", delete); err != nil {
t.Fatal(err)
}
if isStopped, _ := nigiriExistsAndNotRunning(); isStopped {
t.Fatal("Nigiri should be terminated at this point but services have found among stopped containers")
if isStopped, err := ctl.IsNigiriStopped(); err != nil {
t.Fatal(err)
} else if isStopped {
t.Fatal("Nigiri should have been terminated at this point but services have been found among stopped containers")
}
}

View File

@@ -2,13 +2,12 @@ package cmd
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"github.com/vulpemventures/nigiri/cli/config"
"github.com/spf13/cobra"
"github.com/vulpemventures/nigiri/cli/constants"
"github.com/vulpemventures/nigiri/cli/controller"
)
var StopCmd = &cobra.Command{
@@ -20,24 +19,36 @@ var StopCmd = &cobra.Command{
func stopChecks(cmd *cobra.Command, args []string) error {
datadir, _ := cmd.Flags().GetString("datadir")
delete, _ := cmd.Flags().GetBool("delete")
if !isDatadirOk(datadir) {
return fmt.Errorf("Invalid datadir, it must be an absolute path: %s", datadir)
}
if _, err := os.Stat(datadir); os.IsNotExist(err) {
return fmt.Errorf("Datadir do not exists: %s", datadir)
}
nigiriExists, err := nigiriExistsAndNotRunning()
ctl, err := controller.NewController()
if err != nil {
return err
}
if !nigiriExists {
return fmt.Errorf("Nigiri is neither running nor stopped, please create it first")
if err := ctl.ParseDatadir(datadir); err != nil {
return err
}
if err := config.ReadFromFile(datadir); err != nil {
if _, err := os.Stat(datadir); os.IsNotExist(err) {
return constants.ErrDatadirNotExisting
}
if isRunning, err := ctl.IsNigiriRunning(); err != nil {
return err
} else if !isRunning {
if delete {
if isStopped, err := ctl.IsNigiriStopped(); err != nil {
return err
} else if !isStopped {
return constants.ErrNigiriNotExisting
}
} else {
return constants.ErrNigiriNotRunning
}
}
if err := ctl.ReadConfigFile(datadir); err != nil {
return err
}
return nil
@@ -47,40 +58,15 @@ func stop(cmd *cobra.Command, args []string) error {
delete, _ := cmd.Flags().GetBool("delete")
datadir, _ := cmd.Flags().GetString("datadir")
bashCmd := getStopBashCmd(datadir, delete)
if err := bashCmd.Run(); err != nil {
ctl, err := controller.NewController()
if err != nil {
return err
}
if delete {
fmt.Println("Removing data from volumes...")
if err := cleanVolumes(datadir); err != nil {
return err
}
configFile := getPath(datadir, "config")
envFile := getPath(datadir, "env")
fmt.Println("Removing configuration file...")
if err := os.Remove(configFile); err != nil {
return err
}
fmt.Println("Removing environmet file...")
if err := os.Remove(envFile); err != nil {
return err
}
fmt.Println("Nigiri has been cleaned up successfully.")
}
return nil
}
func getStopBashCmd(datadir string, delete bool) *exec.Cmd {
composePath := getPath(datadir, "compose")
envPath := getPath(datadir, "env")
env := loadEnv(envPath)
composePath := ctl.GetResourcePath(datadir, "compose")
configPath := ctl.GetResourcePath(datadir, "config")
envPath := ctl.GetResourcePath(datadir, "env")
env := ctl.LoadComposeEnvironment(envPath)
bashCmd := exec.Command("docker-compose", "-f", composePath, "stop")
if delete {
@@ -90,34 +76,27 @@ func getStopBashCmd(datadir string, delete bool) *exec.Cmd {
bashCmd.Stderr = os.Stderr
bashCmd.Env = env
return bashCmd
}
// cleanVolumes navigates into <datadir>/resources/volumes/<network>
// and deletes all files and directories but the *.conf config files.
func cleanVolumes(datadir string) error {
network := config.GetString(config.Network)
attachLiquid := config.GetBool(config.AttachLiquid)
if attachLiquid {
network = fmt.Sprintf("liquid%s", network)
}
volumedir := filepath.Join(datadir, "resources", "volumes", network)
subdirs, err := ioutil.ReadDir(volumedir)
if err != nil {
if err := bashCmd.Run(); err != nil {
return err
}
for _, d := range subdirs {
volumedir := filepath.Join(volumedir, d.Name())
subsubdirs, _ := ioutil.ReadDir(volumedir)
for _, sd := range subsubdirs {
if sd.IsDir() {
if err := os.RemoveAll(filepath.Join(volumedir, sd.Name())); err != nil {
return err
}
}
if delete {
fmt.Println("Removing data from volumes...")
if err := ctl.CleanResourceVolumes(datadir); err != nil {
return err
}
fmt.Println("Removing configuration file...")
if err := os.Remove(configPath); err != nil {
return err
}
fmt.Println("Removing environmet file...")
if err := os.Remove(envPath); err != nil {
return err
}
fmt.Println("Nigiri has been cleaned up successfully.")
}
return nil

View File

@@ -5,14 +5,7 @@ import (
homedir "github.com/mitchellh/go-homedir"
"github.com/spf13/viper"
)
const (
Datadir = "datadir"
Network = "network"
Filename = "nigiri.config.json"
AttachLiquid = "attachLiquid"
Version = "version"
"github.com/vulpemventures/nigiri/cli/constants"
)
var vip *viper.Viper
@@ -25,37 +18,43 @@ func init() {
setConfigFromDefaults(vip, defaults)
}
func Viper() *viper.Viper {
type Config struct{}
func (c *Config) Viper() *viper.Viper {
return vip
}
func ReadFromFile(path string) error {
vip.SetConfigFile(filepath.Join(path, Filename))
func (c *Config) ReadFromFile(path string) error {
vip.SetConfigFile(filepath.Join(path, constants.Filename))
return vip.ReadInConfig()
}
func WriteConfig(path string) error {
func (c *Config) WriteConfig(path string) error {
vip.SetConfigFile(path)
return vip.WriteConfig()
}
func GetString(str string) string {
func (c *Config) GetString(str string) string {
return vip.GetString(str)
}
func GetBool(str string) bool {
func (c *Config) GetBool(str string) bool {
return vip.GetBool(str)
}
func GetPath() string {
func (c *Config) GetPath() string {
return getPath()
}
func getPath() string {
home, _ := homedir.Expand("~")
return filepath.Join(home, ".nigiri")
}
func newDefaultConfig(v *viper.Viper) {
v.SetDefault(Datadir, GetPath())
v.SetDefault(Network, "regtest")
v.SetDefault(AttachLiquid, false)
v.SetDefault(constants.Datadir, getPath())
v.SetDefault(constants.Network, "regtest")
v.SetDefault(constants.AttachLiquid, false)
}
func setConfigFromDefaults(v *viper.Viper, d *viper.Viper) {

View File

@@ -0,0 +1,66 @@
package constants
import (
"errors"
)
const (
// Datadir key in config json
Datadir = "datadir"
// Network key in config json
Network = "network"
// Filename key in config json
Filename = "nigiri.config.json"
// AttachLiquid key in config json
AttachLiquid = "attachLiquid"
// Version key in config json
Version = "version"
)
var (
AvaliableNetworks = []string{"regtest"}
NigiriImages = []string{
"vulpemventures/bitcoin:latest",
"vulpemventures/electrs:latest",
"vulpemventures/esplora:latest",
"vulpemventures/nigiri-chopsticks:latest",
"vulpemventures/liquid:latest",
"vulpemventures/electrs-liquid:latest",
}
DefaultEnv = map[string]interface{}{
"ports": map[string]map[string]int{
"bitcoin": map[string]int{
"node": 18433,
"esplora": 5000,
"electrs": 3002,
"electrs_rpc": 51401,
"chopsticks": 3000,
},
"liquid": map[string]int{
"node": 7041,
"esplora": 5001,
"electrs": 3012,
"electrs_rpc": 60401,
"chopsticks": 3001,
},
},
"urls": map[string]string{
"bitcoin_esplora": "http://localhost:3000",
"liquid_esplora": "http://localhost:3001",
},
}
ErrInvalidNetwork = errors.New("Network provided is not valid")
ErrInvalidDatadir = errors.New("Datadir provided is not valid: it must be an absolute path")
ErrInvalidServiceName = errors.New("Service provided is not valid")
ErrInvalidArgs = errors.New("Invalid number of args")
ErrInvalidJSON = errors.New("JSON environment provided is not valid: missing required fields")
ErrMalformedJSON = errors.New("Failed to parse malformed JSON environment")
ErrEmptyJSON = errors.New("JSON environment provided is not valid: it must not be empty")
ErrDatadirNotExisting = errors.New("Datadir provided is not valid: it must be an existing path")
ErrNigiriNotRunning = errors.New("Nigiri is not running")
ErrNigiriNotExisting = errors.New("Nigiri does not exists, cannot delete")
ErrNigiriAlreadyRunning = errors.New("Nigiri is already running, please stop it first")
ErrNigiriLiquidNotEnabled = errors.New("Nigiri has been started with no Liquid sidechain.\nPlease stop and restart it using the --liquid flag")
ErrDockerNotRunning = errors.New("Nigiri requires the Docker daemon to be running, but it not seems to be started")
)

View File

@@ -0,0 +1,187 @@
package controller
import (
"fmt"
"os/exec"
"path/filepath"
"github.com/vulpemventures/nigiri/cli/config"
"github.com/vulpemventures/nigiri/cli/constants"
)
var services = map[string]bool{
"node": true,
"esplora": true,
"electrs": true,
"chopsticks": true,
}
// Controller implements useful functions to securely parse flags provided at run-time
// and to interact with the resources used by Nigiri:
// * docker
// * .env for docker-compose
// * nigiri.config.json config file
type Controller struct {
config *config.Config
parser *Parser
docker *Docker
env *Env
}
// NewController returns a new Controller instance or error
func NewController() (*Controller, error) {
c := &Controller{}
dockerClient := &Docker{}
if err := dockerClient.New(); err != nil {
return nil, err
}
c.env = &Env{}
c.parser = newParser(services)
c.docker = dockerClient
c.config = &config.Config{}
return c, nil
}
// ParseNetwork checks if a valid network has been provided
func (c *Controller) ParseNetwork(network string) error {
return c.parser.parseNetwork(network)
}
// ParseDatadir checks if a valid datadir has been provided
func (c *Controller) ParseDatadir(path string) error {
return c.parser.parseDatadir(path)
}
// ParseEnv checks if a valid JSON format for docker compose has been provided
func (c *Controller) ParseEnv(env string) (string, error) {
return c.parser.parseEnvJSON(env)
}
// ParseServiceName checks if a valid service has been provided
func (c *Controller) ParseServiceName(name string) error {
return c.parser.parseServiceName(name)
}
// IsNigiriRunning checks if nigiri is running by looking if the bitcoin
// services are in the list of docker running containers
func (c *Controller) IsNigiriRunning() (bool, error) {
if !c.docker.isDockerRunning() {
return false, constants.ErrDockerNotRunning
}
return c.docker.isNigiriRunning(), nil
}
// IsNigiriStopped checks that nigiri is not actually running and that
// the bitcoin services appear in the list of non running containers
func (c *Controller) IsNigiriStopped() (bool, error) {
if !c.docker.isDockerRunning() {
return false, constants.ErrDockerNotRunning
}
return c.docker.isNigiriStopped(), nil
}
// WriteComposeEnvironment creates a .env in datadir used by
// the docker-compose YAML file resource
func (c *Controller) WriteComposeEnvironment(datadir, env string) error {
return c.env.writeEnvForCompose(datadir, env)
}
// ReadComposeEnvironment reads from .env and returns it as a useful type
func (c *Controller) ReadComposeEnvironment(datadir string) (map[string]interface{}, error) {
return c.env.readEnvForCompose(datadir)
}
// LoadComposeEnvironment returns an os.Environ created from datadir/.env resource
func (c *Controller) LoadComposeEnvironment(datadir string) []string {
return c.env.load(datadir)
}
// WriteConfigFile writes the configuration handled by the underlying viper
// into the file at filedir path
func (c *Controller) WriteConfigFile(filedir string) error {
return c.config.WriteConfig(filedir)
}
// ReadConfigFile reads the configuration of the file at filedir path
func (c *Controller) ReadConfigFile(filedir string) error {
return c.config.ReadFromFile(filedir)
}
// GetConfigBoolField returns a bool field of the config file
func (c *Controller) GetConfigBoolField(field string) bool {
return c.config.GetBool(field)
}
// GetConfigStringField returns a string field of the config file
func (c *Controller) GetConfigStringField(field string) string {
return c.config.GetString(field)
}
// NewDatadirFromDefault copies the default ~/.nigiri at the desidered path
// and cleans the docker volumes to make a fresh Nigiri instance
func (c *Controller) NewDatadirFromDefault(datadir string) error {
defaultDatadir := c.config.GetPath()
cmd := exec.Command("cp", "-R", filepath.Join(defaultDatadir, "resources"), datadir)
if err := cmd.Run(); err != nil {
return err
}
c.CleanResourceVolumes(datadir)
return nil
}
// GetResourcePath returns the absolute path of the requested resource
func (c *Controller) GetResourcePath(datadir, resource string) string {
if resource == "compose" {
network := c.config.GetString(constants.Network)
if c.config.GetBool(constants.AttachLiquid) {
network += "-liquid"
}
return filepath.Join(datadir, "resources", fmt.Sprintf("docker-compose-%s.yml", network))
}
if resource == "env" {
return filepath.Join(datadir, ".env")
}
if resource == "config" {
return filepath.Join(datadir, "nigiri.config.json")
}
return ""
}
// CleanResourceVolumes recursively deletes the content of the
// docker volumes in the resource path
func (c *Controller) CleanResourceVolumes(datadir string) error {
network := c.config.GetString(constants.Network)
attachLiquid := c.config.GetBool(constants.AttachLiquid)
if attachLiquid {
network = fmt.Sprintf("liquid%s", network)
}
volumedir := filepath.Join(datadir, "resources", "volumes", network)
return c.docker.cleanVolumes(volumedir)
}
// GetDefaultDatadir returns the absolute path of Nigiri default directory
func (c *Controller) GetDefaultDatadir() string {
return c.config.GetPath()
}
// GetServiceName returns the right name of the requested service
// If requesting a name for a Liquid service, then the suffix -liquid
// is appended to the canonical name except for "node" that is mapped
// to either "bitcoin" or "liquid"
func (c *Controller) GetServiceName(name string, liquid bool) string {
service := name
if service == "node" {
service = "bitcoin"
}
if liquid {
if service == "bitcoin" {
service = "liquid"
} else {
service = fmt.Sprintf("%s-liquid", service)
}
}
return service
}

101
cli/controller/docker.go Normal file
View File

@@ -0,0 +1,101 @@
package controller
import (
"context"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/vulpemventures/nigiri/cli/constants"
)
// Docker type handles interfaction with containers via docker and docker-compose
type Docker struct {
cli *client.Client
}
// New initialize a new Docker handler
func (d *Docker) New() error {
cli, err := client.NewEnvClient()
if err != nil {
return err
}
d.cli = cli
return nil
}
func (d *Docker) isDockerRunning() bool {
_, err := d.cli.ContainerList(context.Background(), types.ContainerListOptions{All: false})
if err != nil {
return false
}
return true
}
func (d *Docker) findNigiriContainers(listAllContainers bool) bool {
containers, _ := d.cli.ContainerList(context.Background(), types.ContainerListOptions{All: listAllContainers})
if len(containers) <= 0 {
return false
}
images := []string{}
for _, c := range containers {
images = append(images, c.Image)
}
for _, nigiriImage := range constants.NigiriImages {
// just check if services for bitcoin chain are up and running
if !strings.Contains(nigiriImage, "liquid") {
if !contains(images, nigiriImage) {
return false
}
}
}
return true
}
func (d *Docker) isNigiriRunning() bool {
return d.findNigiriContainers(false)
}
func (d *Docker) isNigiriStopped() bool {
isRunning := d.isNigiriRunning()
if !isRunning {
return d.findNigiriContainers(true)
}
return false
}
func (d *Docker) cleanVolumes(path string) error {
subdirs, err := ioutil.ReadDir(path)
if err != nil {
return err
}
for _, d := range subdirs {
path := filepath.Join(path, d.Name())
subsubdirs, _ := ioutil.ReadDir(path)
for _, sd := range subsubdirs {
if sd.IsDir() {
if err := os.RemoveAll(filepath.Join(path, sd.Name())); err != nil {
return err
}
}
}
}
return nil
}
func contains(list []string, elem string) bool {
for _, l := range list {
if l == elem {
return true
}
}
return false
}

124
cli/controller/env.go Normal file
View File

@@ -0,0 +1,124 @@
package controller
import (
"bufio"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
"github.com/vulpemventures/nigiri/cli/constants"
)
// Env implements functions for interacting with the environment file used by
// docker-compose to dynamically set service ports and variables
type Env struct{}
func (e *Env) writeEnvForCompose(path, strJSON string) error {
var env map[string]interface{}
err := json.Unmarshal([]byte(strJSON), &env)
if err != nil {
return constants.ErrMalformedJSON
}
fileContent := ""
for chain, services := range env["ports"].(map[string]interface{}) {
for k, v := range services.(map[string]interface{}) {
fileContent += fmt.Sprintf("%s_%s_PORT=%d\n", strings.ToUpper(chain), strings.ToUpper(k), int(v.(float64)))
}
}
for hostname, url := range env["urls"].(map[string]interface{}) {
fileContent += fmt.Sprintf("%s_URL=%s\n", strings.ToUpper(hostname), url.(string))
}
return ioutil.WriteFile(path, []byte(fileContent), os.ModePerm)
}
func (e *Env) readEnvForCompose(path string) (map[string]interface{}, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
ports := map[string]map[string]int{
"bitcoin": map[string]int{},
"liquid": map[string]int{},
}
urls := map[string]string{}
// Each line is in the format PREFIX_SERVICE_NAME_SUFFIX=value
// PREFIX is either 'BITCOIN' or 'LIQUID', while SUFFIX is either 'PORT' or 'URL'
for scanner.Scan() {
line := scanner.Text()
splitLine := strings.Split(line, "=")
key := splitLine[0]
if strings.Contains(key, "PORT") {
value, _ := strconv.Atoi(splitLine[1])
chain := "bitcoin"
if strings.HasPrefix(key, strings.ToUpper("liquid")) {
chain = "liquid"
}
prefix := strings.ToUpper(fmt.Sprintf("%s_", chain))
suffix := "_PORT"
trimmedKey := strings.ToLower(
strings.TrimSuffix(strings.TrimPrefix(key, prefix), suffix),
)
ports[chain][trimmedKey] = value
} else {
// Here the prefix is not trimmed
value := splitLine[1]
suffix := "_URL"
trimmedKey := strings.ToLower(strings.TrimSuffix(key, suffix))
urls[trimmedKey] = value
}
}
return map[string]interface{}{"ports": ports, "urls": urls}, nil
}
func (e *Env) load(path string) []string {
content, _ := ioutil.ReadFile(path)
lines := strings.Split(string(content), "\n")
env := os.Environ()
for _, line := range lines {
if line != "" {
env = append(env, line)
}
}
return env
}
type envPortsData struct {
Node int `json:"node,omitempty"`
Esplora int `json:"esplora,omitempty"`
Electrs int `json:"electrs,omitempty"`
ElectrsRPC int `json:"electrs_rpc,omitempty"`
Chopsticks int `json:"chopsticks,omitempty"`
}
type envPorts struct {
Bitcoin *envPortsData `json:"bitcoin,omitempty"`
Liquid *envPortsData `json:"liquid,omitempty"`
}
type envUrls struct {
BitcoinEsplora string `json:"bitcoin_esplora,omitempty"`
LiquidEsplora string `json:"liquid_esplora,omitempty"`
}
type envJSON struct {
Ports *envPorts `json:"ports,omitempty"`
Urls *envUrls `json:"urls,omitempty"`
}
func (e envJSON) copy() envJSON {
var v envJSON
bytes, _ := json.Marshal(e)
json.Unmarshal(bytes, &v)
return v
}

58
cli/controller/parser.go Normal file
View File

@@ -0,0 +1,58 @@
package controller
import (
"encoding/json"
"fmt"
"path/filepath"
"github.com/vulpemventures/nigiri/cli/constants"
)
// Parser implements functions for parsing flags, JSON files
// and system directories passed to the CLI commands
type Parser struct {
services map[string]bool
}
func newParser(services map[string]bool) *Parser {
p := &Parser{services}
return p
}
func (p *Parser) parseNetwork(network string) error {
for _, n := range constants.AvaliableNetworks {
if network == n {
return nil
}
}
return constants.ErrInvalidNetwork
}
func (p *Parser) parseDatadir(path string) error {
if !filepath.IsAbs(path) {
return constants.ErrInvalidDatadir
}
return nil
}
func (p *Parser) parseEnvJSON(strJSON string) (string, error) {
// merge default json and incoming json by parsing DefaultEnv to
// envJSON type and then parsing the incoming json using the same variable
var parsedJSON envJSON
defaultJSON, _ := json.Marshal(constants.DefaultEnv)
json.Unmarshal(defaultJSON, &parsedJSON)
err := json.Unmarshal([]byte(strJSON), &parsedJSON)
if err != nil {
fmt.Println(err)
return "", constants.ErrMalformedJSON
}
merged, _ := json.Marshal(parsedJSON)
return string(merged), nil
}
func (p *Parser) parseServiceName(name string) error {
if !p.services[name] {
return constants.ErrInvalidServiceName
}
return nil
}

View File

@@ -0,0 +1,159 @@
package controller
import (
"encoding/json"
"os"
"testing"
"github.com/vulpemventures/nigiri/cli/constants"
)
func TestParserParseNetwork(t *testing.T) {
p := &Parser{}
validNetworks := []string{"regtest"}
for _, n := range validNetworks {
err := p.parseNetwork(n)
if err != nil {
t.Fatal(err)
}
}
}
func TestParserParseDatadir(t *testing.T) {
p := &Parser{}
currentDir, _ := os.Getwd()
validDatadirs := []string{currentDir}
for _, n := range validDatadirs {
err := p.parseDatadir(n)
if err != nil {
t.Fatal(err)
}
}
}
func TestParserParseEnvJSON(t *testing.T) {
p := &Parser{}
for _, e := range testJSONs {
parsedJSON, _ := json.Marshal(e)
mergedJSON, err := p.parseEnvJSON(string(parsedJSON))
if err != nil {
t.Fatal(err)
}
t.Log(mergedJSON)
}
}
func TestParserParseNetworkShouldFail(t *testing.T) {
p := &Parser{}
invalidNetworks := []string{"simnet", "testnet"}
for _, n := range invalidNetworks {
err := p.parseNetwork(n)
if err == nil {
t.Fatalf("Should have been failed before")
}
if err != constants.ErrInvalidNetwork {
t.Fatalf("Got: %s, wanted: %s", err, constants.ErrInvalidNetwork)
}
}
}
func TestParserParseDatadirShouldFail(t *testing.T) {
p := &Parser{}
invalidDatadirs := []string{"."}
for _, d := range invalidDatadirs {
err := p.parseDatadir(d)
if err == nil {
t.Fatalf("Should have been failed before")
}
if err != constants.ErrInvalidDatadir {
t.Fatalf("Got: %s, wanted: %s", err, constants.ErrInvalidDatadir)
}
}
}
var testJSONs = []map[string]interface{}{
// only btc services
{
"urls": map[string]string{
"bitcoin_esplora": "https://blockstream.info/",
},
"ports": map[string]map[string]int{
"bitcoin": map[string]int{
"node": 1111,
"esplora": 2222,
"electrs": 3333,
"chopsticks": 4444,
},
},
},
// btc and liquid services
{
"urls": map[string]string{
"bitcoin_esplora": "https://blockstream.info/",
"liquid_esplora": "http://blockstream.info/liquid",
},
"ports": map[string]map[string]int{
"bitcoin": map[string]int{
"node": 1111,
"esplora": 2222,
"electrs": 3333,
"chopsticks": 4444,
},
"liquid": map[string]int{
"node": 5555,
"esplora": 6666,
"electrs": 7777,
"chopsticks": 8888,
},
},
},
// incomplete examples:
// incomplete bitcoin services
{
"ports": map[string]map[string]int{
"bitcoin": map[string]int{
"esplora": 1111,
"electrs": 2222,
"chopsticks": 3333,
},
},
"urls": map[string]string{
"bitcoin_esplora": "http://test.com/api",
},
},
// bitcoin services ports and liquid service url
{
"ports": map[string]map[string]int{
"bitcoin": map[string]int{
"node": 1111,
"esplora": 2222,
"electrs": 3333,
"chopsticks": 4444,
},
},
"urls": map[string]string{
"liquid_esplora": "http://test.com/liquid/api",
},
},
// liquid services ports and bitcoin service url
{
"ports": map[string]map[string]int{
"liquid": map[string]int{
"node": 1111,
"esplora": 2222,
"electrs": 3333,
"chopsticks": 4444,
},
},
"urls": map[string]string{
"bitcoin_esplora": "http://test.com/api",
},
},
// empty config
{},
}

View File

@@ -52,7 +52,7 @@ services:
- bitcoin
ports:
- ${BITCOIN_ELECTRS_RPC_PORT}:60401
- 3002:3002
- ${BITCOIN_ELECTRS_PORT}:3002
volumes:
- ./volumes/liquidregtest/config/:/config
restart: unless-stopped
@@ -85,7 +85,7 @@ services:
- liquid
ports:
- ${LIQUID_ELECTRS_RPC_PORT}:60401
- 3022:3002
- ${LIQUID_ELECTRS_PORT}:3002
volumes:
- ./volumes/liquidregtest/liquid-config/:/config
restart: unless-stopped
@@ -96,21 +96,25 @@ services:
local:
ipv4_address: 10.10.0.14
links:
- electrs
- chopsticks
depends_on:
- electrs
- chopsticks
environment:
API_URL: ${BITCOIN_ESPLORA_URL}
ports:
- ${BITCOIN_ESPLORA_PORT}:5000
restart: unless-stopped
esplora-liquid:
image: vulpemventures/esplora-liquid:latest
image: vulpemventures/esplora:latest
networks:
local:
ipv4_address: 10.10.0.15
links:
- electrs-liquid
- chopsticks-liquid
depends_on:
- electrs-liquid
- chopsticks-liquid
environment:
API_URL: ${LIQUID_ESPLORA_URL}
ports:
- ${LIQUID_ESPLORA_PORT}:5000
restart: unless-stopped

View File

@@ -41,7 +41,7 @@ services:
- bitcoin
ports:
- ${BITCOIN_ELECTRS_RPC_PORT}:60401
- 3002:3002
- ${BITCOIN_ELECTRS_PORT}:3002
volumes:
- ./volumes/regtest/config/:/config
restart: unless-stopped
@@ -52,9 +52,11 @@ services:
local:
ipv4_address: 10.10.0.12
links:
- electrs
- chopsticks
depends_on:
- electrs
- chopsticks
environment:
API_URL: ${BITCOIN_ESPLORA_URL}
ports:
- ${BITCOIN_ESPLORA_PORT}:5000
restart: unless-stopped