diff --git a/testutil/compose/compose/main.go b/testutil/compose/compose/main.go
index d2803ec5d..63bab437b 100644
--- a/testutil/compose/compose/main.go
+++ b/testutil/compose/compose/main.go
@@ -17,7 +17,7 @@
// using docker-compose.
//
// It consists of three steps:
-// - compose define: Creates compose.yml (and p2pkeys) that defines a desired cluster including keygen.
+// - compose define: Creates charon-compose.yml (and p2pkeys) that defines a desired cluster including keygen.
// - compose lock: Creates docker-compose.yml to generates keys and cluster lock file.
// - compose run: Creates docker-compose.yml that runs the cluster.
package main
@@ -41,17 +41,33 @@ func newRootCmd() *cobra.Command {
}
root.AddCommand(newDefineCmd())
+ root.AddCommand(newLockCmd())
return root
}
+func newLockCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "lock",
+ Short: "Create a docker-compose.yml from charon-compose.yml for generating keys and a cluster lock file.",
+ }
+
+ dir := cmd.Flags().String("compose-dir", ".", "Directory to use for compose artifacts")
+
+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
+ return compose.Lock(cmd.Context(), *dir)
+ }
+
+ return cmd
+}
+
func newDefineCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "define",
- Short: "Define a cluster; including both keygen and running definitions",
+ Short: "Create a charon-compose.yml definition; including both keygen and running definitions",
}
- dir := cmd.Flags().String("compose-dir", "", "Directory to use for compose artifacts")
+ dir := cmd.Flags().String("compose-dir", ".", "Directory to use for compose artifacts")
clean := cmd.Flags().Bool("clean", true, "Clean compose dir before defining a new cluster")
seed := cmd.Flags().Int("seed", int(time.Now().UnixNano()), "Randomness seed")
diff --git a/testutil/compose/config.go b/testutil/compose/config.go
index b125a1482..6ca2c37df 100644
--- a/testutil/compose/config.go
+++ b/testutil/compose/config.go
@@ -13,6 +13,7 @@
// You should have received a copy of the GNU General Public License along with
// this program. If not, see .
+//nolint:deadcode,varcheck // Busy implementing
package compose
import (
@@ -21,13 +22,18 @@ import (
const (
version = "obol/charon/compose/1.0.0"
- composeFile = "compose.yml"
+ composeFile = "charon-compose.yml"
defaultImageTag = "latest"
defaultBeaconNode = "mock"
- defaultKeyGen = keyGenDKG
+ defaultKeyGen = keyGenCreate
defaultNumVals = 1
defaultNumNodes = 4
defaultThreshold = 3
+
+ containerBinary = "/usr/local/bin/charon"
+ cmdRun = "run"
+ cmdDKG = "dkg"
+ cmdCreateCluster = "[create,cluster]"
)
// vcType defines a validator client type.
@@ -44,8 +50,8 @@ type keyGen string
const (
keyGenDKG keyGen = "dkg"
- keyGenCreate keyGen = "create" //nolint:deadcode,varcheck
- keyGenSplit keyGen = "split" //nolint:deadcode,varcheck
+ keyGenCreate keyGen = "create"
+ keyGenSplit keyGen = "split"
)
// config defines a local compose cluster; including both keygen and running a cluster.
@@ -69,8 +75,8 @@ type config struct {
Def cluster.Definition `json:"definition"`
}
-// newDefaultConfig returns a new default config excluding cluster definition.
-func newDefaultConfig() config {
+// newBaseConfig returns a new base config excluding cluster definition.
+func newBaseConfig() config {
return config{
Version: version,
ImageTag: defaultImageTag,
diff --git a/testutil/compose/define.go b/testutil/compose/define.go
index c1b06699e..4eddb902c 100644
--- a/testutil/compose/define.go
+++ b/testutil/compose/define.go
@@ -17,6 +17,7 @@ package compose
import (
"context"
+ "crypto/ecdsa"
"encoding/json"
"fmt"
"os"
@@ -50,12 +51,35 @@ func Define(ctx context.Context, dir string, clean bool, seed int) error {
}
}
+ conf, p2pkeys := newDefaultConfig(seed)
+
// TODO(corver): Serve a web UI to allow configuration of default values.
log.Info(ctx, "Using default config")
+ for i, key := range p2pkeys {
+ // Best effort creation of folder, rather fail when saving p2pkey file next.
+ _ = os.MkdirAll(nodeFile(dir, i, ""), 0o755)
+
+ err := crypto.SaveECDSA(nodeFile(dir, i, "p2pkey"), key)
+ if err != nil {
+ return errors.Wrap(err, "save p2pkey")
+ }
+ }
+
+ if err := writeConfig(dir, conf); err != nil {
+ return err
+ }
+
+ log.Info(ctx, "Created charon-compose.yml and node*/p2pkey")
+
+ return nil
+}
+
+func newDefaultConfig(seed int) (config, []*ecdsa.PrivateKey) {
lock, p2pkeys, _ := cluster.NewForT(&testing.T{}, defaultNumVals, defaultThreshold, defaultNumNodes, seed)
- conf := newDefaultConfig()
+
+ conf := newBaseConfig()
conf.Def = lock.Definition
conf.Def.Name = "compose"
conf.Def.FeeRecipientAddress = ""
@@ -64,16 +88,16 @@ func Define(ctx context.Context, dir string, clean bool, seed int) error {
conf.Def.Operators[i].Address = ""
}
- for i, key := range p2pkeys {
- // Best effort creation of folder, rather fail when saving p2pkey file next.
- _ = os.MkdirAll(nodeFile(dir, i, ""), 0o755)
+ return conf, p2pkeys
+}
- err := crypto.SaveECDSA(nodeFile(dir, i, "p2pkey"), key)
- if err != nil {
- return errors.Wrap(err, "save p2pkey")
- }
- }
+// nodeFile returns the path to a file in a node folder.
+func nodeFile(dir string, i int, file string) string {
+ return path.Join(dir, fmt.Sprintf("node%d", i), file)
+}
+// writeConfig writes the config as yaml to disk.
+func writeConfig(dir string, conf config) error {
b, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return errors.Wrap(err, "marshal config")
@@ -89,12 +113,5 @@ func Define(ctx context.Context, dir string, clean bool, seed int) error {
return errors.Wrap(err, "write config")
}
- log.Info(ctx, "Created config.yml and p2pkeys")
-
return nil
}
-
-// nodeFile returns the path to a file in a node folder.
-func nodeFile(dir string, i int, file string) string {
- return path.Join(dir, fmt.Sprintf("node%d", i), file)
-}
diff --git a/testutil/compose/define_test.go b/testutil/compose/define_test.go
index 82967b1bd..a4a7e3dc2 100644
--- a/testutil/compose/define_test.go
+++ b/testutil/compose/define_test.go
@@ -27,8 +27,6 @@ import (
"github.com/obolnetwork/charon/testutil/compose"
)
-//go:generate go test . -update -clean
-
func TestDefine(t *testing.T) {
dir, err := os.MkdirTemp("", "")
require.NoError(t, err)
@@ -36,7 +34,7 @@ func TestDefine(t *testing.T) {
err = compose.Define(context.Background(), dir, false, 1)
require.NoError(t, err)
- conf, err := os.ReadFile(path.Join(dir, "compose.yml"))
+ conf, err := os.ReadFile(path.Join(dir, "charon-compose.yml"))
require.NoError(t, err)
testutil.RequireGoldenBytes(t, conf)
diff --git a/testutil/compose/docker-compose.template b/testutil/compose/docker-compose.template
new file mode 100644
index 000000000..43326fcf9
--- /dev/null
+++ b/testutil/compose/docker-compose.template
@@ -0,0 +1,77 @@
+version: "3.8"
+
+x-node-base: &node-base
+ image: ghcr.io/obolnetwork/charon:{{.CharonImageTag}}
+ entrypoint: {{.CharonEntrypoint}}
+ command: {{.CharonCommand}}
+ networks: [compose]
+ volumes: [{{.ComposeDir}}:/compose]
+ {{if not .NodeOnly }}depends_on: [bootnode] {{end}}
+
+services:
+ {{ range $i, $node := .Nodes}}
+ node{{$i}}:
+ <<: *node-base
+ environment:
+ {{- range $node.EnvVars}}
+ CHARON_{{.KeyUpper}}: {{.Value}}
+ {{- end}}
+ {{if .Ports}}
+ ports:
+ {{- range $node.Ports}}
+ - "{{.External}}:{{.Internal}}"
+ {{- end}}
+ {{- end}}
+ {{ end -}}
+
+ {{if not .NodeOnly }}
+ bootnode:
+ <<: *node-base
+ command: bootnode
+ depends_on: []
+ environment:
+ CHARON_BOOTNODE_HTTP_ADDRESS: 0.0.0.0:16000
+ CHARON_DATA_DIR: /compose/bootnode
+ CHARON_P2P_BOOTNODES: ""
+ CHARON_P2P_EXTERNAL_HOSTNAME: bootnode
+
+ {{- range $i, $vc := .VCs}}
+ vc{{$i}}-{{$vc.Label}}:
+ {{if $vc.Build}} build: {{$vc.Build}} {{end}}
+ {{if $vc.Image}} image: {{$vc.Image}} {{end}}
+ {{if $vc.Command}} command: {{$vc.Command}} {{end}}
+ networks: [compose]
+ depends_on: [node{{$i}}]
+ environment:
+ NODE: node{{$i}}
+ volumes:
+ - .:/compose
+ {{end}}
+
+ prometheus:
+ image: prom/prometheus:latest
+ ports:
+ - "9090:9090"
+ networks: [compose]
+ volumes:
+ - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
+
+ grafana:
+ image: grafana/grafana:latest
+ ports:
+ - "3000:3000"
+ networks: [compose]
+ volumes:
+ - ./grafana/datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml
+ - ./grafana/dashboards.yml:/etc/grafana/provisioning/dashboards/datasource.yml
+ - ./grafana/grafana.ini:/etc/grafana/grafana.ini:ro
+ - ./grafana/simnet_dash.json:/etc/dashboards/simnet_dash.json
+
+ jaeger:
+ image: jaegertracing/all-in-one:latest
+ networks: [compose]
+ ports:
+ - "16686:16686"
+ {{end}}
+networks:
+ compose:
diff --git a/testutil/compose/lock.go b/testutil/compose/lock.go
new file mode 100644
index 000000000..7138710d6
--- /dev/null
+++ b/testutil/compose/lock.go
@@ -0,0 +1,93 @@
+// Copyright © 2022 Obol Labs Inc.
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package compose
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path"
+
+ "github.com/goccy/go-yaml"
+
+ "github.com/obolnetwork/charon/app/errors"
+ "github.com/obolnetwork/charon/app/log"
+)
+
+func Lock(ctx context.Context, dir string) error {
+ ctx = log.WithTopic(ctx, "lock")
+
+ conf, err := loadConfig(dir)
+ if err != nil {
+ return err
+ }
+
+ if conf.KeyGen != keyGenCreate {
+ return errors.New("only keygen create supported")
+ }
+
+ // Only single node to call charon create cluster generate keys
+ n := node{EnvVars: []kv{
+ {"threshold", fmt.Sprint(conf.Def.Threshold)},
+ {"nodes", fmt.Sprint(len(conf.Def.Operators))},
+ {"cluster_dir", "/compose"},
+ }}
+
+ data := tmplData{
+ NodeOnly: true,
+ ComposeDir: dir,
+ CharonImageTag: conf.ImageTag,
+ CharonEntrypoint: containerBinary,
+ CharonCommand: cmdCreateCluster,
+ Nodes: []node{n},
+ }
+
+ log.Info(ctx, "Created docker-compose.yml")
+ log.Info(ctx, "Create keys and cluster lock with: docker-compose up")
+
+ return writeDockerCompose(dir, data)
+}
+
+//nolint:deadcode // Busy implementing.
+func newNodeEnvs(mockValidator bool) []kv {
+ return []kv{
+ {"jaeger_address", "jaeger:6831"},
+ {"definition_file", "/compose/cluster-definition.json"},
+ {"lock_file", "/compose/cluster-lock.json"},
+ {"monitoring_address", "0.0.0.0:16001"},
+ {"validator_api_address", "0.0.0.0:16002"},
+ {"p2p_tcp_address", "0.0.0.0:16003"},
+ {"p2p_udp_address", "0.0.0.0:16004"},
+ {"p2p_bootnodes", "http://bootnode:16000/enr"},
+ {"simnet_validator_mock", fmt.Sprint(mockValidator)},
+ {"log_level", "info"},
+ }
+}
+
+// loadConfig returns the config loaded from disk.
+func loadConfig(dir string) (config, error) {
+ b, err := os.ReadFile(path.Join(dir, composeFile))
+ if err != nil {
+ return config{}, errors.Wrap(err, "load config")
+ }
+
+ var resp config
+ if err := yaml.Unmarshal(b, &resp); err != nil {
+ return config{}, errors.Wrap(err, "unmarshal config")
+ }
+
+ return resp, nil
+}
diff --git a/testutil/compose/lock_internal_test.go b/testutil/compose/lock_internal_test.go
new file mode 100644
index 000000000..f5a4fb5ff
--- /dev/null
+++ b/testutil/compose/lock_internal_test.go
@@ -0,0 +1,55 @@
+// Copyright © 2022 Obol Labs Inc.
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package compose
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path"
+ "testing"
+ "text/template"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/obolnetwork/charon/testutil"
+)
+
+//go:generate go test . -update -clean
+
+func TestLockCompose(t *testing.T) {
+ dir, err := os.MkdirTemp("", "")
+ require.NoError(t, err)
+
+ conf, _ := newDefaultConfig(1)
+
+ err = writeConfig(dir, conf)
+ require.NoError(t, err)
+
+ err = Lock(context.Background(), dir)
+ require.NoError(t, err)
+
+ compose, err := os.ReadFile(path.Join(dir, "docker-compose.yml"))
+ require.NoError(t, err)
+ compose = bytes.ReplaceAll(compose, []byte(dir), []byte("testdir"))
+
+ testutil.RequireGoldenBytes(t, compose)
+}
+
+func TestParseTemplate(t *testing.T) {
+ _, err := template.New("").Parse(string(tmpl))
+ require.NoError(t, err)
+}
diff --git a/testutil/compose/template.go b/testutil/compose/template.go
new file mode 100644
index 000000000..4d6424451
--- /dev/null
+++ b/testutil/compose/template.go
@@ -0,0 +1,95 @@
+// Copyright © 2022 Obol Labs Inc.
+//
+// This program is free software: you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation, either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+// more details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program. If not, see .
+
+package compose
+
+import (
+ "bytes"
+ _ "embed"
+ "os"
+ "path"
+ "strings"
+ "text/template"
+
+ "github.com/obolnetwork/charon/app/errors"
+)
+
+//go:embed docker-compose.template
+var tmpl []byte
+
+// tmplData is the docker-compose.yml template data.
+type tmplData struct {
+ ComposeDir string
+
+ CharonImageTag string
+ CharonEntrypoint string
+ CharonCommand string
+
+ Nodes []node
+ VCs []vc
+
+ NodeOnly bool
+}
+
+// vc represents a validator client service in a docker-compose.yml.
+type vc struct {
+ Label string
+ Image string
+ Build string
+ Command string
+ Ports []port
+}
+
+// node represents a charon node service in a docker-compose.yml.
+type node struct {
+ EnvVars []kv
+ Ports []port
+}
+
+// kv is a key value pair.
+type kv struct {
+ Key string
+ Value string
+}
+
+func (kv kv) KeyUpper() string {
+ return strings.ToUpper(kv.Key)
+}
+
+// port is a port mapping in a docker-compose.yml.
+type port struct {
+ External int
+ Internal int
+}
+
+// writeDockerCompose generates the docker-compose.yml template and writes it to disk.
+func writeDockerCompose(dir string, data tmplData) error {
+ tpl, err := template.New("").Parse(string(tmpl))
+ if err != nil {
+ return errors.Wrap(err, "new template")
+ }
+
+ var buf bytes.Buffer
+ if err := tpl.Execute(&buf, data); err != nil {
+ return errors.Wrap(err, "exec template")
+ }
+
+ err = os.WriteFile(path.Join(dir, "docker-compose.yml"), buf.Bytes(), 0o755) //nolint:gosec
+ if err != nil {
+ return errors.Wrap(err, "write docker-compose")
+ }
+
+ return nil
+}
diff --git a/testutil/compose/testdata/TestDefine.golden b/testutil/compose/testdata/TestDefine.golden
index 319e834d8..05a4d4c68 100644
--- a/testutil/compose/testdata/TestDefine.golden
+++ b/testutil/compose/testdata/TestDefine.golden
@@ -4,7 +4,7 @@ validator_clients:
- teku
- lighthouse
- mock
-key_gen: dkg
+key_gen: create
beacon_node: mock
definition:
name: compose
diff --git a/testutil/compose/testdata/TestLockCompose.golden b/testutil/compose/testdata/TestLockCompose.golden
new file mode 100644
index 000000000..f80bc0473
--- /dev/null
+++ b/testutil/compose/testdata/TestLockCompose.golden
@@ -0,0 +1,22 @@
+version: "3.8"
+
+x-node-base: &node-base
+ image: ghcr.io/obolnetwork/charon:latest
+ entrypoint: /usr/local/bin/charon
+ command: [create,cluster]
+ networks: [compose]
+ volumes: [testdir:/compose]
+
+
+services:
+
+ node0:
+ <<: *node-base
+ environment:
+ CHARON_THRESHOLD: 3
+ CHARON_NODES: 4
+ CHARON_CLUSTER_DIR: /compose
+
+
+networks:
+ compose: