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: