Commit 003d648e authored by Yann Hodique's avatar Yann Hodique Committed by GitHub

feat(kurtosis-devnet): subshells (#13759)

This is a convenience feature that allows a user to "enter" a devnet /
chain and get a subshell with relevant data (in particular env
variables).

The goal is to try an minimize the amount of error-prone log-browsing
and copy-pasting before being able to interact with the environment.
parent b08f5f08
...@@ -86,3 +86,7 @@ interop-devnet-test: (devnet-test "interop-devnet" "interop-smoke-test.sh") ...@@ -86,3 +86,7 @@ interop-devnet-test: (devnet-test "interop-devnet" "interop-smoke-test.sh")
# User devnet # User devnet
user-devnet DATA_FILE: user-devnet DATA_FILE:
{{just_executable()}} devnet "user.yaml" {{DATA_FILE}} {{file_stem(DATA_FILE)}} {{just_executable()}} devnet "user.yaml" {{DATA_FILE}} {{file_stem(DATA_FILE)}}
# subshells
enter-devnet DEVNET CHAIN='Ethereum':
exec go run pkg/shell/cmd/enter/main.go --devnet tests/{{DEVNET}}.json --chain {{CHAIN}}
package main
import (
"fmt"
"os"
"os/exec"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/shell/env"
"github.com/urfave/cli/v2"
)
func run(ctx *cli.Context) error {
devnetFile := ctx.String("devnet")
chainName := ctx.String("chain")
devnetEnv, err := env.LoadDevnetEnv(devnetFile)
if err != nil {
return err
}
chain, err := devnetEnv.GetChain(chainName)
if err != nil {
return err
}
chainEnv, err := chain.GetEnv()
if err != nil {
return err
}
if motd := chainEnv.Motd; motd != "" {
fmt.Println(motd)
}
// Get current environment and append chain-specific vars
env := os.Environ()
for key, value := range chainEnv.EnvVars {
env = append(env, fmt.Sprintf("%s=%s", key, value))
}
// Get current shell
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
// Execute new shell
cmd := exec.Command(shell)
cmd.Env = env
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("error executing shell: %w", err)
}
return nil
}
func main() {
app := &cli.App{
Name: "enter",
Usage: "Enter a shell with devnet environment variables set",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "devnet",
Usage: "Path to devnet JSON file",
EnvVars: []string{env.EnvFileVar},
Required: true,
},
&cli.StringFlag{
Name: "chain",
Usage: "Name of the chain to connect to",
EnvVars: []string{env.ChainNameVar},
Required: true,
},
},
Action: run,
}
if err := app.Run(os.Args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
package main
import (
"fmt"
"os"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/shell/env"
"github.com/urfave/cli/v2"
)
func run(ctx *cli.Context) error {
devnetFile := ctx.String("devnet")
chainName := ctx.String("chain")
devnetEnv, err := env.LoadDevnetEnv(devnetFile)
if err != nil {
return err
}
chain, err := devnetEnv.GetChain(chainName)
if err != nil {
return err
}
chainEnv, err := chain.GetEnv()
if err != nil {
return err
}
fmt.Println(chainEnv.Motd)
return nil
}
func main() {
app := &cli.App{
Name: "motd",
Usage: "Display the Message of the Day for a chain environment",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "devnet",
Usage: "Path to devnet JSON file",
EnvVars: []string{env.EnvFileVar},
Required: true,
},
&cli.StringFlag{
Name: "chain",
Usage: "Name of the chain to get MOTD for",
EnvVars: []string{env.ChainNameVar},
Required: true,
},
},
Action: run,
}
if err := app.Run(os.Args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
package env
import (
"bytes"
"fmt"
"html/template"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/descriptors"
)
const (
EnvFileVar = "DEVNET_ENV_FILE"
ChainNameVar = "DEVNET_CHAIN_NAME"
)
type ChainConfig struct {
chain *descriptors.Chain
devnetFile string
name string
}
type ChainEnv struct {
Motd string
EnvVars map[string]string
}
func (c *ChainConfig) getRpcUrl() (string, error) {
if len(c.chain.Nodes) == 0 {
return "", fmt.Errorf("chain '%s' has no nodes", c.chain.Name)
}
// Get RPC endpoint from the first node's execution layer service
elService, ok := c.chain.Nodes[0].Services["el"]
if !ok {
return "", fmt.Errorf("no execution layer service found for chain '%s'", c.chain.Name)
}
rpcEndpoint, ok := elService.Endpoints["rpc"]
if !ok {
return "", fmt.Errorf("no RPC endpoint found for chain '%s'", c.chain.Name)
}
return fmt.Sprintf("http://%s:%d", rpcEndpoint.Host, rpcEndpoint.Port), nil
}
func (c *ChainConfig) getJwtSecret() (string, error) {
jwt := c.chain.JWT
if len(jwt) >= 2 && jwt[:2] == "0x" {
jwt = jwt[2:]
}
return jwt, nil
}
func (c *ChainConfig) motd() string {
tmpl := `You're in a {{.Name}} chain subshell.
Some addresses of interest:
{{ range $key, $value := .Addresses -}}
{{ printf "%-35s" $key }} = {{ $value }}
{{ end -}}
`
t := template.Must(template.New("motd").Parse(tmpl))
var buf bytes.Buffer
if err := t.Execute(&buf, c.chain); err != nil {
panic(err)
}
return buf.String()
}
func (c *ChainConfig) GetEnv() (*ChainEnv, error) {
mapping := map[string]func() (string, error){
"ETH_RPC_URL": c.getRpcUrl,
"ETH_RPC_JWT_SECRET": c.getJwtSecret,
}
motd := c.motd()
envVars := make(map[string]string)
for key, fn := range mapping {
value, err := fn()
if err != nil {
return nil, err
}
envVars[key] = value
}
// To allow commands within the shell to know which devnet and chain they are in
envVars[EnvFileVar] = c.devnetFile
envVars[ChainNameVar] = c.name
return &ChainEnv{
Motd: motd,
EnvVars: envVars,
}, nil
}
package env
import (
"encoding/json"
"fmt"
"os"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/descriptors"
)
type DevnetEnv struct {
config descriptors.DevnetEnvironment
fname string
}
func LoadDevnetEnv(devnetFile string) (*DevnetEnv, error) {
data, err := os.ReadFile(devnetFile)
if err != nil {
return nil, fmt.Errorf("error reading devnet file: %w", err)
}
var config descriptors.DevnetEnvironment
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("error parsing JSON: %w", err)
}
return &DevnetEnv{
config: config,
fname: devnetFile,
}, nil
}
func (d *DevnetEnv) GetChain(chainName string) (*ChainConfig, error) {
var chain *descriptors.Chain
if d.config.L1.Name == chainName {
chain = d.config.L1
} else {
for _, l2Chain := range d.config.L2 {
if l2Chain.Name == chainName {
chain = l2Chain
break
}
}
}
if chain == nil {
return nil, fmt.Errorf("chain '%s' not found in devnet config", chainName)
}
return &ChainConfig{
chain: chain,
devnetFile: d.fname,
name: chainName,
}, nil
}
package env
import (
"os"
"path/filepath"
"testing"
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/descriptors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadDevnetEnv(t *testing.T) {
// Create a temporary test file
content := `{
"l1": {
"name": "l1",
"nodes": [{
"services": {
"el": {
"endpoints": {
"rpc": {
"host": "localhost",
"port": 8545
}
}
}
}
}],
"jwt": "0x1234567890abcdef",
"addresses": {
"deployer": "0x123"
}
},
"l2": [{
"name": "op",
"nodes": [{
"services": {
"el": {
"endpoints": {
"rpc": {
"host": "localhost",
"port": 9545
}
}
}
}
}],
"jwt": "0xdeadbeef",
"addresses": {
"deployer": "0x456"
}
}]
}`
tmpfile, err := os.CreateTemp("", "devnet-*.json")
require.NoError(t, err)
defer os.Remove(tmpfile.Name())
_, err = tmpfile.Write([]byte(content))
require.NoError(t, err)
err = tmpfile.Close()
require.NoError(t, err)
// Test successful load
t.Run("successful load", func(t *testing.T) {
env, err := LoadDevnetEnv(tmpfile.Name())
require.NoError(t, err)
assert.Equal(t, "l1", env.config.L1.Name)
assert.Equal(t, "op", env.config.L2[0].Name)
})
// Test loading non-existent file
t.Run("non-existent file", func(t *testing.T) {
_, err := LoadDevnetEnv("non-existent.json")
assert.Error(t, err)
})
// Test loading invalid JSON
t.Run("invalid JSON", func(t *testing.T) {
invalidFile := filepath.Join(t.TempDir(), "invalid.json")
err := os.WriteFile(invalidFile, []byte("{invalid json}"), 0644)
require.NoError(t, err)
_, err = LoadDevnetEnv(invalidFile)
assert.Error(t, err)
})
}
func TestGetChain(t *testing.T) {
devnet := &DevnetEnv{
config: descriptors.DevnetEnvironment{
L1: &descriptors.Chain{
Name: "l1",
Nodes: []descriptors.Node{
{
Services: descriptors.ServiceMap{
"el": {
Endpoints: descriptors.EndpointMap{
"rpc": {
Host: "localhost",
Port: 8545,
},
},
},
},
},
},
JWT: "0x1234",
},
L2: []*descriptors.Chain{
{
Name: "op",
Nodes: []descriptors.Node{
{
Services: descriptors.ServiceMap{
"el": {
Endpoints: descriptors.EndpointMap{
"rpc": {
Host: "localhost",
Port: 9545,
},
},
},
},
},
},
JWT: "0x5678",
},
},
},
fname: "test.json",
}
// Test getting L1 chain
t.Run("get L1 chain", func(t *testing.T) {
chain, err := devnet.GetChain("l1")
require.NoError(t, err)
assert.Equal(t, "l1", chain.name)
assert.Equal(t, "0x1234", chain.chain.JWT)
})
// Test getting L2 chain
t.Run("get L2 chain", func(t *testing.T) {
chain, err := devnet.GetChain("op")
require.NoError(t, err)
assert.Equal(t, "op", chain.name)
assert.Equal(t, "0x5678", chain.chain.JWT)
})
// Test getting non-existent chain
t.Run("get non-existent chain", func(t *testing.T) {
_, err := devnet.GetChain("invalid")
assert.Error(t, err)
})
}
func TestChainConfig(t *testing.T) {
chain := &ChainConfig{
chain: &descriptors.Chain{
Name: "test",
Nodes: []descriptors.Node{
{
Services: descriptors.ServiceMap{
"el": {
Endpoints: descriptors.EndpointMap{
"rpc": {
Host: "localhost",
Port: 8545,
},
},
},
},
},
},
JWT: "0x1234",
Addresses: map[string]string{
"deployer": "0x123",
},
},
devnetFile: "test.json",
name: "test",
}
// Test getting environment variables
t.Run("get environment variables", func(t *testing.T) {
env, err := chain.GetEnv()
require.NoError(t, err)
assert.Equal(t, "http://localhost:8545", env.EnvVars["ETH_RPC_URL"])
assert.Equal(t, "1234", env.EnvVars["ETH_RPC_JWT_SECRET"])
assert.Equal(t, "test.json", env.EnvVars[EnvFileVar])
assert.Equal(t, "test", env.EnvVars[ChainNameVar])
assert.Contains(t, env.Motd, "deployer")
assert.Contains(t, env.Motd, "0x123")
})
// Test chain with no nodes
t.Run("chain with no nodes", func(t *testing.T) {
noNodesChain := &ChainConfig{
chain: &descriptors.Chain{
Name: "test",
Nodes: []descriptors.Node{},
},
}
_, err := noNodesChain.GetEnv()
assert.Error(t, err)
})
// Test chain with missing service
t.Run("chain with missing service", func(t *testing.T) {
missingServiceChain := &ChainConfig{
chain: &descriptors.Chain{
Name: "test",
Nodes: []descriptors.Node{
{
Services: descriptors.ServiceMap{},
},
},
},
}
_, err := missingServiceChain.GetEnv()
assert.Error(t, err)
})
// Test chain with missing endpoint
t.Run("chain with missing endpoint", func(t *testing.T) {
missingEndpointChain := &ChainConfig{
chain: &descriptors.Chain{
Name: "test",
Nodes: []descriptors.Node{
{
Services: descriptors.ServiceMap{
"el": {
Endpoints: descriptors.EndpointMap{},
},
},
},
},
},
}
_, err := missingEndpointChain.GetEnv()
assert.Error(t, err)
})
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment