Skip to content

Commit bc2d088

Browse files
chore: create a tool that automates tip sync kpi measurement (#6599)
## Overview Fixes #6600 --------- Co-authored-by: CHAMI Rachid <chamirachid1@gmail.com>
1 parent 41e1693 commit bc2d088

File tree

2 files changed

+386
-0
lines changed

2 files changed

+386
-0
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# measure-tip-sync-speed
2+
3+
Measures Celestia Mocha testnet sync-to-tip speed by spinning up a full node on Digital Ocean.
4+
5+
## Prerequisites
6+
7+
1. **Digital Ocean API token**
8+
9+
```bash
10+
export DIGITALOCEAN_TOKEN='your-token'
11+
```
12+
13+
2. **SSH key uploaded to Digital Ocean**
14+
15+
Upload your public key at: <https://cloud.digitalocean.com/account/security>
16+
17+
3. **Install the tool**
18+
19+
```bash
20+
go install ./tools/measure-tip-sync-speed
21+
```
22+
23+
## Usage
24+
25+
```bash
26+
# Required: specify your SSH private key
27+
go run ./tools/measure-tip-sync-speed -k ~/.ssh/id_ed25519
28+
29+
# Multiple iterations + cooldown
30+
go run ./tools/measure-tip-sync-speed -k ~/.ssh/id_ed25519 -n 20 -c 30
31+
32+
# Test specific branch
33+
go run ./tools/measure-tip-sync-speed -k ~/.ssh/id_ed25519 -b my-branch
34+
```
35+
36+
## Flags
37+
38+
| Flag | Description |
39+
| ---- | ----------- |
40+
| `-k, --ssh-key-path` | SSH private key path **(required)** |
41+
| `-n, --iterations` | Number of sync iterations (default: 1) |
42+
| `-c, --cooldown` | Seconds between iterations (default: 30) |
43+
| `-b, --branch` | Git branch to test |
44+
| `-s, --skip-build` | Skip building celestia-appd |
45+
| `--no-cleanup` | Keep droplet alive |
46+
47+
## What It Does
48+
49+
1. Matches your SSH key with Digital Ocean
50+
2. Creates Ubuntu droplet (8 vCPU, 16GB RAM, NYC)
51+
3. Installs dependencies and builds celestia-appd
52+
4. Runs `scripts/mocha-measure-tip-sync.sh`
53+
5. Cleans up droplet
54+
55+
Takes ~5-10 minutes depending on the number of iterations.
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"os"
8+
"strings"
9+
"time"
10+
11+
"github.com/digitalocean/godo"
12+
"github.com/spf13/cobra"
13+
"golang.org/x/crypto/ssh"
14+
"golang.org/x/term"
15+
)
16+
17+
const (
18+
dropletSize = "c2-16vcpu-32gb"
19+
region = "nyc3"
20+
repoURL = "https://github.com/celestiaorg/celestia-app.git"
21+
goVersion = "1.25.7"
22+
)
23+
24+
func main() {
25+
var (
26+
sshKeyPath string
27+
iterations int
28+
cooldown int
29+
branch string
30+
noCleanup bool
31+
skipBuild bool
32+
)
33+
34+
cmd := &cobra.Command{
35+
Use: "measure-tip-sync-speed",
36+
Short: "Measure Celestia Mocha testnet sync-to-tip speed",
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
return run(cmd.Context(), sshKeyPath, iterations, cooldown, branch, noCleanup, skipBuild)
39+
},
40+
}
41+
42+
cmd.Flags().StringVarP(&sshKeyPath, "ssh-key-path", "k", "", "SSH private key path (required)")
43+
cmd.Flags().IntVarP(&iterations, "iterations", "n", 1, "Number of sync iterations")
44+
cmd.Flags().IntVarP(&cooldown, "cooldown", "c", 30, "Cooldown seconds between iterations")
45+
cmd.Flags().StringVarP(&branch, "branch", "b", "", "Git branch to test")
46+
cmd.Flags().BoolVarP(&skipBuild, "skip-build", "s", false, "Skip building celestia-appd")
47+
cmd.Flags().BoolVar(&noCleanup, "no-cleanup", false, "Keep droplet alive")
48+
49+
if err := cmd.MarkFlagRequired("ssh-key-path"); err != nil {
50+
log.Fatal(err)
51+
}
52+
53+
if err := cmd.Execute(); err != nil {
54+
log.Fatal(err)
55+
}
56+
}
57+
58+
// run executes the main workflow. creates a DO droplet, runs the sync measurement, and cleans up.
59+
func run(ctx context.Context, sshKeyPath string, iterations, cooldown int, branch string, noCleanup, skipBuild bool) error {
60+
token := os.Getenv("DIGITALOCEAN_TOKEN")
61+
if token == "" {
62+
return fmt.Errorf("DIGITALOCEAN_TOKEN not set")
63+
}
64+
65+
client := godo.NewFromToken(token)
66+
67+
// Read and match SSH key
68+
pubKey, err := os.ReadFile(sshKeyPath + ".pub")
69+
if err != nil {
70+
return fmt.Errorf("reading public key: %w", err)
71+
}
72+
73+
doKey, err := findDOKey(ctx, client, string(pubKey))
74+
if err != nil {
75+
return err
76+
}
77+
78+
fmt.Printf("Using SSH key: %s (DO: %s)\n\n", sshKeyPath, doKey.Name)
79+
80+
// Create droplet with SSH key name
81+
dropletName := fmt.Sprintf("mocha-tip-sync-%s", doKey.Name)
82+
fmt.Printf("Creating droplet %s (%s, %s)...\n", dropletName, dropletSize, region)
83+
84+
droplet, err := createDroplet(ctx, client, dropletName, doKey)
85+
if err != nil {
86+
return err
87+
}
88+
89+
ip := getPublicIP(droplet)
90+
fmt.Printf("Droplet created: %s (ID: %d)\n\n", ip, droplet.ID)
91+
92+
if noCleanup {
93+
defer fmt.Printf("\nDroplet kept: ssh root@%s\n", ip)
94+
} else {
95+
defer func() {
96+
fmt.Println("\nCleaning up...")
97+
if _, err := client.Droplets.Delete(ctx, droplet.ID); err != nil {
98+
fmt.Printf("Failed to delete droplet: %v\n", err)
99+
} else {
100+
fmt.Println("Droplet deleted")
101+
}
102+
}()
103+
}
104+
105+
fmt.Println("Waiting for SSH...")
106+
sshClient, err := waitForSSH(ip, sshKeyPath, 5*time.Minute)
107+
if err != nil {
108+
return err
109+
}
110+
defer sshClient.Close()
111+
fmt.Print("SSH connected\n\n")
112+
113+
if !skipBuild {
114+
fmt.Println("Setting up environment...")
115+
if err := execSSH(sshClient, setupScript(branch)); err != nil {
116+
return fmt.Errorf("setup failed: %w", err)
117+
}
118+
fmt.Println()
119+
}
120+
121+
// Run measurement
122+
fmt.Printf("Running measurement (iterations=%d, cooldown=%ds)...\n\n", iterations, cooldown)
123+
measureCmd := fmt.Sprintf(`
124+
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin:$HOME/celestia-app/build
125+
cd $HOME/celestia-app
126+
./scripts/mocha-measure-tip-sync.sh -n %d -c %d
127+
`, iterations, cooldown)
128+
129+
if err := execSSH(sshClient, measureCmd); err != nil {
130+
return fmt.Errorf("measurement failed: %w", err)
131+
}
132+
133+
fmt.Println("\nMeasurement complete!")
134+
return nil
135+
}
136+
137+
// findDOKey finds a Digital Ocean SSH key that matches the provided public key.
138+
func findDOKey(ctx context.Context, client *godo.Client, pubKey string) (godo.Key, error) {
139+
opt := &godo.ListOptions{PerPage: 200}
140+
for {
141+
keys, resp, err := client.Keys.List(ctx, opt)
142+
if err != nil {
143+
return godo.Key{}, err
144+
}
145+
146+
for _, key := range keys {
147+
if keysMatch(pubKey, key.PublicKey) {
148+
return key, nil
149+
}
150+
}
151+
152+
if resp.Links == nil || resp.Links.IsLastPage() {
153+
break
154+
}
155+
page, _ := resp.Links.CurrentPage()
156+
opt.Page = page + 1
157+
}
158+
159+
return godo.Key{}, fmt.Errorf("no matching SSH key in Digital Ocean. Upload your public key at https://cloud.digitalocean.com/account/security")
160+
}
161+
162+
// keysMatch compares two SSH public keys by normalizing them (ignoring comments).
163+
func keysMatch(key1, key2 string) bool {
164+
normalize := func(k string) string {
165+
parts := strings.Fields(k)
166+
if len(parts) >= 2 {
167+
return parts[0] + " " + parts[1]
168+
}
169+
return k
170+
}
171+
return normalize(strings.TrimSpace(key1)) == normalize(strings.TrimSpace(key2))
172+
}
173+
174+
// createDroplet creates a new Digital Ocean droplet and waits for it to become active.
175+
func createDroplet(ctx context.Context, client *godo.Client, name string, sshKey godo.Key) (*godo.Droplet, error) {
176+
req := &godo.DropletCreateRequest{
177+
Name: name,
178+
Region: region,
179+
Size: dropletSize,
180+
Image: godo.DropletCreateImage{Slug: "ubuntu-22-04-x64"},
181+
SSHKeys: []godo.DropletCreateSSHKey{
182+
{ID: sshKey.ID, Fingerprint: sshKey.Fingerprint},
183+
},
184+
Tags: []string{"celestia-sync-speed", sshKey.Name},
185+
}
186+
187+
droplet, _, err := client.Droplets.Create(ctx, req)
188+
if err != nil {
189+
return nil, err
190+
}
191+
192+
// Wait for IP
193+
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
194+
defer cancel()
195+
196+
ticker := time.NewTicker(5 * time.Second)
197+
defer ticker.Stop()
198+
199+
for {
200+
select {
201+
case <-ctx.Done():
202+
return nil, fmt.Errorf("timeout waiting for droplet")
203+
case <-ticker.C:
204+
d, _, err := client.Droplets.Get(ctx, droplet.ID)
205+
if err != nil {
206+
return nil, err
207+
}
208+
if d.Status == "active" && getPublicIP(d) != "" {
209+
return d, nil
210+
}
211+
}
212+
}
213+
}
214+
215+
// getPublicIP extracts the public IPv4 address from a droplet.
216+
func getPublicIP(d *godo.Droplet) string {
217+
for _, net := range d.Networks.V4 {
218+
if net.Type == "public" {
219+
return net.IPAddress
220+
}
221+
}
222+
return ""
223+
}
224+
225+
// waitForSSH polls the host until SSH is available or timeout is reached.
226+
func waitForSSH(host, keyPath string, timeout time.Duration) (*ssh.Client, error) {
227+
key, err := os.ReadFile(keyPath)
228+
if err != nil {
229+
return nil, err
230+
}
231+
232+
signer, err := ssh.ParsePrivateKey(key)
233+
if err != nil {
234+
return nil, err
235+
}
236+
237+
config := &ssh.ClientConfig{
238+
User: "root",
239+
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
240+
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
241+
Timeout: 5 * time.Second,
242+
}
243+
244+
deadline := time.Now().Add(timeout)
245+
for time.Now().Before(deadline) {
246+
client, err := ssh.Dial("tcp", host+":22", config)
247+
if err == nil {
248+
if session, err := client.NewSession(); err == nil {
249+
session.Close()
250+
return client, nil
251+
}
252+
client.Close()
253+
}
254+
time.Sleep(5 * time.Second)
255+
}
256+
257+
return nil, fmt.Errorf("SSH timeout after %v", timeout)
258+
}
259+
260+
// execSSH executes a command on the SSH client and streams output to stdout/stderr.
261+
func execSSH(client *ssh.Client, command string) error {
262+
session, err := client.NewSession()
263+
if err != nil {
264+
return err
265+
}
266+
defer session.Close()
267+
268+
session.Stdout = os.Stdout
269+
session.Stderr = os.Stderr
270+
271+
if term.IsTerminal(int(os.Stdin.Fd())) {
272+
width, height, _ := term.GetSize(int(os.Stdin.Fd()))
273+
modes := ssh.TerminalModes{
274+
ssh.ECHO: 0,
275+
ssh.TTY_OP_ISPEED: 14400,
276+
ssh.TTY_OP_OSPEED: 14400,
277+
}
278+
_ = session.RequestPty("xterm-256color", height, width, modes)
279+
}
280+
281+
return session.Run(command)
282+
}
283+
284+
// setupScript generates a bash script that installs dependencies and builds celestia-app.
285+
func setupScript(branch string) string {
286+
branchCmd := ""
287+
if branch != "" {
288+
branchCmd = fmt.Sprintf("git checkout %s", branch)
289+
}
290+
291+
return fmt.Sprintf(`#!/bin/bash
292+
set -e
293+
294+
echo "Updating package lists..."
295+
export DEBIAN_FRONTEND=noninteractive
296+
apt-get update -qq > /dev/null || { echo "ERROR: apt-get update failed"; exit 1; }
297+
298+
echo "Installing dependencies (curl, wget, git, build-essential, jq)..."
299+
apt-get install -y -qq curl wget git build-essential jq > /dev/null || { echo "ERROR: apt-get install failed"; exit 1; }
300+
301+
echo "Downloading Go %s..."
302+
cd /tmp
303+
wget -q https://go.dev/dl/go%s.linux-amd64.tar.gz || { echo "ERROR: Go download failed"; exit 1; }
304+
305+
echo "Installing Go %s..."
306+
rm -rf /usr/local/go
307+
tar -C /usr/local -xzf go%s.linux-amd64.tar.gz || { echo "ERROR: Go extraction failed"; exit 1; }
308+
export PATH=$PATH:/usr/local/go/bin
309+
export GOPATH=$HOME/go
310+
export PATH=$PATH:$GOPATH/bin
311+
312+
echo "Cloning celestia-app from %s..."
313+
cd $HOME
314+
rm -rf celestia-app
315+
git clone -q %s celestia-app || { echo "ERROR: git clone failed"; exit 1; }
316+
cd celestia-app
317+
%s
318+
319+
echo "Building celestia-appd (this may take a few minutes)..."
320+
make build 2>&1 | grep -E "Error|error|FAIL|fatal" || true
321+
if [ ! -f "./build/celestia-appd" ]; then
322+
echo "ERROR: build failed - celestia-appd binary not found"
323+
make build
324+
exit 1
325+
fi
326+
327+
export PATH=$PATH:$(pwd)/build
328+
echo "export PATH=\$PATH:$(pwd)/build" >> ~/.bashrc
329+
echo "Setup complete!"
330+
`, goVersion, goVersion, goVersion, goVersion, repoURL, repoURL, branchCmd)
331+
}

0 commit comments

Comments
 (0)