Skip to content

Commit

Permalink
op-supervisor: initial service draft (#10703)
Browse files Browse the repository at this point in the history
* op-supervisor: initial service draft

* supervisor: Fix log capitalization

* op-supervisor: fix mockrun flag

* op-supervisor: CLI tests

---------

Co-authored-by: Adrian Sutton <[email protected]>
  • Loading branch information
protolambda and ajsutton authored Jun 17, 2024
1 parent 19d7b72 commit 55b67e0
Show file tree
Hide file tree
Showing 16 changed files with 1,017 additions and 0 deletions.
56 changes: 56 additions & 0 deletions op-supervisor/cmd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package main

import (
"context"
"os"

"github.com/urfave/cli/v2"

"github.com/ethereum/go-ethereum/log"

opservice "github.com/ethereum-optimism/optimism/op-service"
"github.com/ethereum-optimism/optimism/op-service/cliapp"
oplog "github.com/ethereum-optimism/optimism/op-service/log"
"github.com/ethereum-optimism/optimism/op-service/metrics/doc"
"github.com/ethereum-optimism/optimism/op-service/opio"
"github.com/ethereum-optimism/optimism/op-supervisor/flags"
"github.com/ethereum-optimism/optimism/op-supervisor/metrics"
"github.com/ethereum-optimism/optimism/op-supervisor/supervisor"
)

var (
Version = "v0.0.1"
GitCommit = ""
GitDate = ""
)

func main() {
ctx := opio.WithInterruptBlocker(context.Background())
err := run(ctx, os.Args, fromConfig)
if err != nil {
log.Crit("Application failed", "message", err)
}
}

func run(ctx context.Context, args []string, fn supervisor.MainFn) error {
oplog.SetupDefaults()

app := cli.NewApp()
app.Flags = cliapp.ProtectFlags(flags.Flags)
app.Version = opservice.FormatVersion(Version, GitCommit, GitDate, "")
app.Name = "op-supervisor"
app.Usage = "op-supervisor monitors cross-L2 interop messaging"
app.Description = "The op-supervisor monitors cross-L2 interop messaging by pre-fetching events and then resolving the cross-L2 dependencies to answer safety queries."
app.Action = cliapp.LifecycleCmd(supervisor.Main(Version, fn))
app.Commands = []*cli.Command{
{
Name: "doc",
Subcommands: doc.NewSubcommands(metrics.NewMetrics("default")),
},
}
return app.RunContext(ctx, args)
}

func fromConfig(ctx context.Context, cfg *supervisor.CLIConfig, logger log.Logger) (cliapp.Lifecycle, error) {
return supervisor.SupervisorFromCLIConfig(ctx, cfg, logger)
}
112 changes: 112 additions & 0 deletions op-supervisor/cmd/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package main

import (
"context"
"errors"
"fmt"
"testing"

"github.com/stretchr/testify/require"

"github.com/ethereum/go-ethereum/log"

"github.com/ethereum-optimism/optimism/op-service/cliapp"
"github.com/ethereum-optimism/optimism/op-supervisor/supervisor"
)

func TestLogLevel(t *testing.T) {
t.Run("RejectInvalid", func(t *testing.T) {
verifyArgsInvalid(t, "unknown level: foo", addRequiredArgs("--log.level=foo"))
})

for _, lvl := range []string{"trace", "debug", "info", "error", "crit"} {
lvl := lvl
t.Run("AcceptValid_"+lvl, func(t *testing.T) {
logger, _, err := dryRunWithArgs(addRequiredArgs("--log.level", lvl))
require.NoError(t, err)
require.NotNil(t, logger)
})
}
}

func TestDefaultCLIOptionsMatchDefaultConfig(t *testing.T) {
cfg := configForArgs(t, addRequiredArgs())
defaultCfgTempl := supervisor.DefaultCLIConfig()
defaultCfg := *defaultCfgTempl
defaultCfg.Version = Version
require.Equal(t, defaultCfg, *cfg)
}

func TestL2RPCs(t *testing.T) {
t.Run("Required", func(t *testing.T) {
verifyArgsInvalid(t, "flag l2-rpcs is required", addRequiredArgsExcept("--l2-rpcs"))
})

t.Run("Valid", func(t *testing.T) {
url1 := "http://example.com:1234"
url2 := "http://foobar.com:1234"
cfg := configForArgs(t, addRequiredArgsExcept("--l2-rpcs", "--l2-rpcs="+url1+","+url2))
require.Equal(t, []string{url1, url2}, cfg.L2RPCs)
})
}

func TestMockRun(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
cfg := configForArgs(t, addRequiredArgs("--mock-run"))
require.Equal(t, true, cfg.MockRun)
})
}

func verifyArgsInvalid(t *testing.T, messageContains string, cliArgs []string) {
_, _, err := dryRunWithArgs(cliArgs)
require.ErrorContains(t, err, messageContains)
}

func configForArgs(t *testing.T, cliArgs []string) *supervisor.CLIConfig {
_, cfg, err := dryRunWithArgs(cliArgs)
require.NoError(t, err)
return cfg
}

func dryRunWithArgs(cliArgs []string) (log.Logger, *supervisor.CLIConfig, error) {
cfg := new(supervisor.CLIConfig)
var logger log.Logger
fullArgs := append([]string{"op-supervisor"}, cliArgs...)
testErr := errors.New("dry-run")
err := run(context.Background(), fullArgs, func(ctx context.Context, config *supervisor.CLIConfig, log log.Logger) (cliapp.Lifecycle, error) {
logger = log
cfg = config
return nil, testErr
})
if errors.Is(err, testErr) { // expected error
err = nil
}
return logger, cfg, err
}

func addRequiredArgs(args ...string) []string {
req := requiredArgs()
combined := toArgList(req)
return append(combined, args...)
}

func addRequiredArgsExcept(name string, optionalArgs ...string) []string {
req := requiredArgs()
delete(req, name)
return append(toArgList(req), optionalArgs...)
}

func toArgList(req map[string]string) []string {
var combined []string
for name, value := range req {
combined = append(combined, fmt.Sprintf("%s=%s", name, value))
}
return combined
}

func requiredArgs() map[string]string {
args := map[string]string{
"--l2-rpcs": "http://localhost:8545",
}
return args
}
64 changes: 64 additions & 0 deletions op-supervisor/flags/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package flags

import (
"fmt"

"github.com/urfave/cli/v2"

opservice "github.com/ethereum-optimism/optimism/op-service"
oplog "github.com/ethereum-optimism/optimism/op-service/log"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
"github.com/ethereum-optimism/optimism/op-service/oppprof"
oprpc "github.com/ethereum-optimism/optimism/op-service/rpc"
)

const EnvVarPrefix = "OP_SUPERVISOR"

func prefixEnvVars(name string) []string {
return opservice.PrefixEnvVar(EnvVarPrefix, name)
}

var (
L2RPCsFlag = &cli.StringSliceFlag{
Name: "l2-rpcs",
Usage: "L2 RPC sources.",
EnvVars: prefixEnvVars("L2_RPCS"),
Value: cli.NewStringSlice("http://localhost:8545"),
}
MockRunFlag = &cli.BoolFlag{
Name: "mock-run",
Usage: "Mock run, no actual backend used, just presenting the service",
EnvVars: prefixEnvVars("MOCK_RUN"),
Hidden: true, // this is for testing only
}
)

var requiredFlags = []cli.Flag{
L2RPCsFlag,
}

var optionalFlags = []cli.Flag{
MockRunFlag,
}

func init() {
optionalFlags = append(optionalFlags, oprpc.CLIFlags(EnvVarPrefix)...)
optionalFlags = append(optionalFlags, oplog.CLIFlags(EnvVarPrefix)...)
optionalFlags = append(optionalFlags, opmetrics.CLIFlags(EnvVarPrefix)...)
optionalFlags = append(optionalFlags, oppprof.CLIFlags(EnvVarPrefix)...)

Flags = append(Flags, requiredFlags...)
Flags = append(Flags, optionalFlags...)
}

// Flags contains the list of configuration options available to the binary.
var Flags []cli.Flag

func CheckRequired(ctx *cli.Context) error {
for _, f := range requiredFlags {
if !ctx.IsSet(f.Names()[0]) {
return fmt.Errorf("flag %s is required", f.Names()[0])
}
}
return nil
}
89 changes: 89 additions & 0 deletions op-supervisor/flags/flags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package flags

import (
"strings"
"testing"

"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"

opservice "github.com/ethereum-optimism/optimism/op-service"
)

// TestOptionalFlagsDontSetRequired asserts that all flags deemed optional set
// the Required field to false.
func TestOptionalFlagsDontSetRequired(t *testing.T) {
for _, flag := range optionalFlags {
reqFlag, ok := flag.(cli.RequiredFlag)
require.True(t, ok)
require.False(t, reqFlag.IsRequired())
}
}

// TestUniqueFlags asserts that all flag names are unique, to avoid accidental conflicts between the many flags.
func TestUniqueFlags(t *testing.T) {
seenCLI := make(map[string]struct{})
for _, flag := range Flags {
for _, name := range flag.Names() {
if _, ok := seenCLI[name]; ok {
t.Errorf("duplicate flag %s", name)
continue
}
seenCLI[name] = struct{}{}
}
}
}

// TestBetaFlags test that all flags starting with "beta." have "BETA_" in the env var, and vice versa.
func TestBetaFlags(t *testing.T) {
for _, flag := range Flags {
envFlag, ok := flag.(interface {
GetEnvVars() []string
})
if !ok || len(envFlag.GetEnvVars()) == 0 { // skip flags without env-var support
continue
}
name := flag.Names()[0]
envName := envFlag.GetEnvVars()[0]
if strings.HasPrefix(name, "beta.") {
require.Contains(t, envName, "BETA_", "%q flag must contain BETA in env var to match \"beta.\" flag name", name)
}
if strings.Contains(envName, "BETA_") {
require.True(t, strings.HasPrefix(name, "beta."), "%q flag must start with \"beta.\" in flag name to match \"BETA_\" env var", name)
}
}
}

func TestHasEnvVar(t *testing.T) {
for _, flag := range Flags {
flag := flag
flagName := flag.Names()[0]

t.Run(flagName, func(t *testing.T) {
envFlagGetter, ok := flag.(interface {
GetEnvVars() []string
})
envFlags := envFlagGetter.GetEnvVars()
require.True(t, ok, "must be able to cast the flag to an EnvVar interface")
require.Equal(t, 1, len(envFlags), "flags should have exactly one env var")
})
}
}

func TestEnvVarFormat(t *testing.T) {
for _, flag := range Flags {
flag := flag
flagName := flag.Names()[0]

t.Run(flagName, func(t *testing.T) {
envFlagGetter, ok := flag.(interface {
GetEnvVars() []string
})
envFlags := envFlagGetter.GetEnvVars()
require.True(t, ok, "must be able to cast the flag to an EnvVar interface")
require.Equal(t, 1, len(envFlags), "flags should have exactly one env var")
expectedEnvVar := opservice.FlagNameToEnvVarName(flagName, "OP_SUPERVISOR")
require.Equal(t, expectedEnvVar, envFlags[0])
})
}
}
Loading

0 comments on commit 55b67e0

Please sign in to comment.