Skip to content

Commit ff32710

Browse files
authored
fix(kt-devnet): skip fileserver deployment when possible (#14413)
This is a temporary measure, until we can integrate a proofs-only fileserver with the op-challenger deployment. But in the meantime, this unblocks a decent amount of idempotent kurtosis deployments, by avoiding kurtosis invalidating the execution cache needlessly.
1 parent fe295ab commit ff32710

File tree

4 files changed

+277
-21
lines changed

4 files changed

+277
-21
lines changed

Diff for: kurtosis-devnet/fileserver/main.star

+11-7
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,22 @@ def get_used_ports():
1212
return used_ports
1313

1414

15-
def run(plan, source_path):
15+
def run(plan, source_path, server_image=FILESERVER_IMAGE):
1616
service_name = "fileserver"
1717
config = get_fileserver_config(
18-
plan,
19-
service_name,
20-
source_path,
18+
plan = plan,
19+
service_name = service_name,
20+
source_path = source_path,
21+
server_image = server_image,
22+
)
23+
plan.add_service(
24+
name = service_name,
25+
config = config,
2126
)
22-
service = plan.add_service(service_name, config)
2327
return service_name
2428

2529

26-
def get_fileserver_config(plan, service_name, source_path):
30+
def get_fileserver_config(plan, service_name, source_path, server_image):
2731
files = {}
2832

2933
# Upload content to container
@@ -42,7 +46,7 @@ def get_fileserver_config(plan, service_name, source_path):
4246

4347
ports = get_used_ports()
4448
return ServiceConfig(
45-
image=FILESERVER_IMAGE,
49+
image=server_image,
4650
ports=ports,
4751
cmd=["nginx", "-g", "daemon off;"],
4852
files=files,

Diff for: kurtosis-devnet/pkg/deploy/deploy.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -240,12 +240,14 @@ func (d *Deployer) Deploy(ctx context.Context, r io.Reader) (*kurtosis.KurtosisE
240240
deployer: d.ktDeployer,
241241
}
242242

243+
ch := srv.getState(ctx)
244+
243245
buf, err := d.renderTemplate(tmpDir, srv.URL)
244246
if err != nil {
245247
return nil, fmt.Errorf("error rendering template: %w", err)
246248
}
247249

248-
if err := srv.Deploy(ctx, tmpDir); err != nil {
250+
if err := srv.Deploy(ctx, tmpDir, ch); err != nil {
249251
return nil, fmt.Errorf("error deploying fileserver: %w", err)
250252
}
251253

Diff for: kurtosis-devnet/pkg/deploy/fileserver.go

+176-2
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ package deploy
33
import (
44
"bytes"
55
"context"
6+
"crypto/sha256"
7+
"encoding/hex"
8+
"encoding/json"
69
"fmt"
10+
"log"
711
"os"
812
"path/filepath"
913
"strings"
14+
"sync"
1015

16+
ktfs "github.com/ethereum-optimism/optimism/devnet-sdk/kt/fs"
1117
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/kurtosis"
1218
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/util"
1319
)
@@ -25,17 +31,43 @@ func (f *FileServer) URL(path ...string) string {
2531
return fmt.Sprintf("http://%s/%s", FILESERVER_PACKAGE, strings.Join(path, "/"))
2632
}
2733

28-
func (f *FileServer) Deploy(ctx context.Context, sourceDir string) error {
34+
func (f *FileServer) Deploy(ctx context.Context, sourceDir string, stateCh <-chan *fileserverState) error {
35+
// Check if source directory is empty. If it is, then ie means we don't have
36+
// anything to serve, so we might as well not deploy the fileserver.
37+
entries, err := os.ReadDir(sourceDir)
38+
if err != nil {
39+
return fmt.Errorf("error reading source directory: %w", err)
40+
}
41+
if len(entries) == 0 {
42+
return nil
43+
}
44+
45+
srcHash, err := calculateDirHash(sourceDir)
46+
if err != nil {
47+
return fmt.Errorf("error calculating source directory hash: %w", err)
48+
}
2949
// Create a temp dir in the fileserver package
3050
baseDir := filepath.Join(f.baseDir, FILESERVER_PACKAGE)
3151
if err := os.MkdirAll(baseDir, 0755); err != nil {
3252
return fmt.Errorf("error creating base directory: %w", err)
3353
}
54+
55+
configHash, err := calculateDirHash(filepath.Join(baseDir, "static_files", "nginx"))
56+
if err != nil {
57+
return fmt.Errorf("error calculating base directory hash: %w", err)
58+
}
59+
60+
refState := <-stateCh
61+
if refState.contentHash == srcHash && refState.configHash == configHash {
62+
log.Println("No changes to fileserver, skipping deployment")
63+
return nil
64+
}
65+
3466
// Can't use MkdirTemp here because the directory name needs to always be the same
3567
// in order for kurtosis file artifact upload to be idempotent.
3668
// (i.e. the file upload and all its downstream dependencies can be SKIPPED on re-runs)
3769
tempDir := filepath.Join(baseDir, "upload-content")
38-
err := os.Mkdir(tempDir, 0755)
70+
err = os.Mkdir(tempDir, 0755)
3971
if err != nil {
4072
return fmt.Errorf("error creating temporary directory: %w", err)
4173
}
@@ -68,3 +100,145 @@ func (f *FileServer) Deploy(ctx context.Context, sourceDir string) error {
68100

69101
return nil
70102
}
103+
104+
type fileserverState struct {
105+
contentHash string
106+
configHash string
107+
}
108+
109+
// downloadAndHashArtifact downloads an artifact and calculates its hash
110+
func downloadAndHashArtifact(ctx context.Context, enclave, artifactName string) (string, error) {
111+
fs, err := ktfs.NewEnclaveFS(ctx, enclave)
112+
if err != nil {
113+
return "", fmt.Errorf("failed to create enclave fs: %w", err)
114+
}
115+
116+
// Create temp dir
117+
tempDir, err := os.MkdirTemp("", artifactName+"-*")
118+
if err != nil {
119+
return "", fmt.Errorf("failed to create temp dir: %w", err)
120+
}
121+
defer os.RemoveAll(tempDir)
122+
123+
// Download artifact
124+
artifact, err := fs.GetArtifact(ctx, artifactName)
125+
if err != nil {
126+
return "", fmt.Errorf("failed to get artifact: %w", err)
127+
}
128+
129+
// Ensure parent directories exist before extracting
130+
if err := os.MkdirAll(tempDir, 0755); err != nil {
131+
return "", fmt.Errorf("failed to create temp dir structure: %w", err)
132+
}
133+
134+
// Extract to temp dir
135+
if err := artifact.Download(tempDir); err != nil {
136+
return "", fmt.Errorf("failed to download artifact: %w", err)
137+
}
138+
139+
// Calculate hash
140+
hash, err := calculateDirHash(tempDir)
141+
if err != nil {
142+
return "", fmt.Errorf("failed to calculate hash: %w", err)
143+
}
144+
145+
return hash, nil
146+
}
147+
148+
func (f *FileServer) getState(ctx context.Context) <-chan *fileserverState {
149+
stateCh := make(chan *fileserverState)
150+
151+
go func(ctx context.Context) {
152+
st := &fileserverState{}
153+
var wg sync.WaitGroup
154+
155+
type artifactInfo struct {
156+
name string
157+
dest *string
158+
}
159+
160+
artifacts := []artifactInfo{
161+
{"fileserver-content", &st.contentHash},
162+
{"fileserver-nginx-conf", &st.configHash},
163+
}
164+
165+
for _, art := range artifacts {
166+
wg.Add(1)
167+
go func(art artifactInfo) {
168+
defer wg.Done()
169+
hash, err := downloadAndHashArtifact(ctx, f.enclave, art.name)
170+
if err == nil {
171+
*art.dest = hash
172+
}
173+
}(art)
174+
}
175+
176+
wg.Wait()
177+
stateCh <- st
178+
}(ctx)
179+
180+
return stateCh
181+
}
182+
183+
type entry struct {
184+
RelPath string `json:"rel_path"`
185+
Size int64 `json:"size"`
186+
Mode string `json:"mode"`
187+
Content []byte `json:"content"`
188+
}
189+
190+
// calculateDirHash returns a SHA256 hash of the directory contents
191+
// It walks through the directory, hashing file names and contents
192+
func calculateDirHash(dir string) (string, error) {
193+
hash := sha256.New()
194+
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
195+
if err != nil {
196+
return err
197+
}
198+
199+
// Get path relative to root dir
200+
relPath, err := filepath.Rel(dir, path)
201+
if err != nil {
202+
return err
203+
}
204+
205+
// Skip the root directory
206+
if relPath == "." {
207+
return nil
208+
}
209+
210+
// Add the relative path and file info to hash
211+
entry := entry{
212+
RelPath: relPath,
213+
Size: info.Size(),
214+
Mode: info.Mode().String(),
215+
}
216+
217+
// If it's a regular file, add its contents to hash
218+
if !info.IsDir() {
219+
content, err := os.ReadFile(path)
220+
if err != nil {
221+
return err
222+
}
223+
entry.Content = content
224+
}
225+
226+
jsonBytes, err := json.Marshal(entry)
227+
if err != nil {
228+
return err
229+
}
230+
_, err = hash.Write(jsonBytes)
231+
if err != nil {
232+
return err
233+
}
234+
235+
return nil
236+
})
237+
238+
if err != nil {
239+
return "", fmt.Errorf("error walking directory: %w", err)
240+
}
241+
242+
hashStr := hex.EncodeToString(hash.Sum(nil))
243+
return hashStr, nil
244+
}

Diff for: kurtosis-devnet/pkg/deploy/fileserver_test.go

+87-11
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/kurtosis"
1111
"github.com/ethereum-optimism/optimism/kurtosis-devnet/pkg/kurtosis/sources/spec"
12+
"github.com/stretchr/testify/assert"
1213
"github.com/stretchr/testify/require"
1314
)
1415

@@ -20,36 +21,111 @@ func TestDeployFileserver(t *testing.T) {
2021
require.NoError(t, err)
2122
defer os.RemoveAll(tmpDir)
2223

24+
// Create test files
25+
sourceDir := filepath.Join(tmpDir, "fileserver")
26+
require.NoError(t, os.MkdirAll(sourceDir, 0755))
27+
28+
// Create required directory structure
29+
nginxDir := filepath.Join(sourceDir, "static_files", "nginx")
30+
require.NoError(t, os.MkdirAll(nginxDir, 0755))
31+
2332
// Create a mock deployer function
2433
mockDeployerFunc := func(opts ...kurtosis.KurtosisDeployerOptions) (deployer, error) {
2534
return &mockDeployer{}, nil
2635
}
2736

2837
testCases := []struct {
29-
name string
30-
fs *FileServer
31-
shouldError bool
38+
name string
39+
setup func(t *testing.T, sourceDir, nginxDir string, state *fileserverState)
40+
state *fileserverState
41+
shouldError bool
42+
shouldDeploy bool
3243
}{
3344
{
34-
name: "successful deployment",
35-
fs: &FileServer{
36-
baseDir: tmpDir,
37-
enclave: "test-enclave",
38-
dryRun: true,
39-
deployer: mockDeployerFunc,
45+
name: "empty source directory - no deployment needed",
46+
setup: func(t *testing.T, sourceDir, nginxDir string, state *fileserverState) {
47+
// No files to create
4048
},
41-
shouldError: false,
49+
state: &fileserverState{},
50+
shouldError: false,
51+
shouldDeploy: false,
52+
},
53+
{
54+
name: "new files to deploy",
55+
setup: func(t *testing.T, sourceDir, nginxDir string, state *fileserverState) {
56+
require.NoError(t, os.WriteFile(
57+
filepath.Join(sourceDir, "test.txt"),
58+
[]byte("test content"),
59+
0644,
60+
))
61+
},
62+
state: &fileserverState{},
63+
shouldError: false,
64+
shouldDeploy: true,
65+
},
66+
{
67+
name: "no changes - deployment skipped",
68+
setup: func(t *testing.T, sourceDir, nginxDir string, state *fileserverState) {
69+
require.NoError(t, os.WriteFile(
70+
filepath.Join(sourceDir, "test.txt"),
71+
[]byte("test content"),
72+
0644,
73+
))
74+
75+
// Calculate actual hash for the test file
76+
hash, err := calculateDirHash(sourceDir)
77+
require.NoError(t, err)
78+
79+
// Calculate nginx config hash
80+
configHash, err := calculateDirHash(nginxDir)
81+
require.NoError(t, err)
82+
83+
// Update state with actual hashes
84+
state.contentHash = hash
85+
state.configHash = configHash
86+
},
87+
state: &fileserverState{},
88+
shouldError: false,
89+
shouldDeploy: false,
4290
},
4391
}
4492

4593
for _, tc := range testCases {
4694
t.Run(tc.name, func(t *testing.T) {
47-
err := tc.fs.Deploy(ctx, filepath.Join(tmpDir, "fileserver"))
95+
// Clean up and recreate source directory for each test
96+
require.NoError(t, os.RemoveAll(sourceDir))
97+
require.NoError(t, os.MkdirAll(sourceDir, 0755))
98+
99+
// Recreate nginx directory
100+
require.NoError(t, os.MkdirAll(nginxDir, 0755))
101+
102+
// Setup test files
103+
tc.setup(t, sourceDir, nginxDir, tc.state)
104+
105+
fs := &FileServer{
106+
baseDir: tmpDir,
107+
enclave: "test-enclave",
108+
dryRun: true,
109+
deployer: mockDeployerFunc,
110+
}
111+
112+
// Create state channel and send test state
113+
ch := make(chan *fileserverState, 1)
114+
ch <- tc.state
115+
close(ch)
116+
117+
err := fs.Deploy(ctx, sourceDir, ch)
48118
if tc.shouldError {
49119
require.Error(t, err)
50120
} else {
51121
require.NoError(t, err)
52122
}
123+
124+
// Verify deployment directory was created only if deployment was needed
125+
deployDir := filepath.Join(tmpDir, FILESERVER_PACKAGE)
126+
if tc.shouldDeploy {
127+
assert.DirExists(t, deployDir)
128+
}
53129
})
54130
}
55131
}

0 commit comments

Comments
 (0)