From 3463e198d74945765d2715abeffa6685ee4ce3a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caner=20=C3=87=C4=B1dam?= Date: Fri, 20 Mar 2026 04:17:38 +0100 Subject: [PATCH 1/2] generate k8s manifests for recipes --- .gitignore | 1 + main.go | 44 +++- playground/k8sgen/k8s.go | 376 ++++++++++++++++++++++++++++++++++ playground/k8sgen/k8s_test.go | 321 +++++++++++++++++++++++++++++ playground/local_runner.go | 12 +- 5 files changed, 746 insertions(+), 8 deletions(-) create mode 100644 playground/k8sgen/k8s.go create mode 100644 playground/k8sgen/k8s_test.go diff --git a/.gitignore b/.gitignore index 1b54a79c..3cb394c2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ playground/utils/op-deployer* playground/utils/state.json recipe/ /playground.yaml +.vscode diff --git a/main.go b/main.go index e9d2d2f8..71b9de61 100644 --- a/main.go +++ b/main.go @@ -8,12 +8,14 @@ import ( "log/slog" "net/url" "os" + "path/filepath" "sort" "strings" "time" "github.com/fatih/color" "github.com/flashbots/builder-playground/playground" + "github.com/flashbots/builder-playground/playground/k8sgen" "github.com/flashbots/builder-playground/utils" "github.com/flashbots/builder-playground/utils/logging" "github.com/flashbots/builder-playground/utils/mainctx" @@ -65,6 +67,7 @@ var ( testInsecure bool testExpectedExtraData string portListFlag bool + k8sFlag bool ) var rootCmd = &cobra.Command{ @@ -296,6 +299,24 @@ var logsCmd = &cobra.Command{ }, } +var dirCmd = &cobra.Command{ + Use: "dir", + Short: "Print the latest session directory", + RunE: func(cmd *cobra.Command, args []string) error { + sessionsDir, err := utils.GetSessionsDir() + if err != nil { + return err + } + latest := filepath.Join(sessionsDir, "latest") + resolved, err := filepath.EvalSymlinks(latest) + if err != nil { + return fmt.Errorf("no latest session found: %w", err) + } + fmt.Print(resolved) + return nil + }, +} + var listCmd = &cobra.Command{ Use: "list", Short: "List all sessions and running services", @@ -567,6 +588,7 @@ func main() { cmd.Flags().BoolVar(&detached, "detached", false, "Detached mode: Run the recipes in the background") cmd.Flags().BoolVar(&skipSetup, "skip-setup", false, "Skip the setup commands defined in the YAML recipe") cmd.Flags().StringArrayVar(&prefundedAccounts, "prefunded-accounts", []string{}, "Fund this account in addition to static prefunded accounts") + cmd.Flags().BoolVar(&k8sFlag, "k8s", false, "generate Kubernetes manifests (requires kompose) and helper files") } // Add common flags to startCmd for YAML recipe files @@ -628,6 +650,7 @@ func main() { logsCmd.Flags().BoolVarP(&followFlag, "follow", "f", false, "Stream logs continuously instead of displaying and exiting") rootCmd.AddCommand(logsCmd) + rootCmd.AddCommand(dirCmd) rootCmd.AddCommand(listCmd) portCmd.Flags().BoolVarP(&portListFlag, "list", "l", false, "List all available ports for the service") rootCmd.AddCommand(portCmd) @@ -742,10 +765,6 @@ func runIt(recipe playground.Recipe) error { } } - if dryRun { - return nil - } - if err := svcManager.ApplyOverrides(overrides); err != nil { return err } @@ -775,6 +794,23 @@ func runIt(recipe playground.Recipe) error { return fmt.Errorf("failed to create docker runner: %w", err) } + if err := dockerRunner.WriteDockerComposeFile(); err != nil { + return fmt.Errorf("failed to write docker-compose.yaml: %w", err) + } + + if k8sFlag { + slog.Info("Generating Kubernetes manifests... ⏳") + if err := k8sgen.GenerateK8s(out.Dst()); err != nil { + return fmt.Errorf("failed to generate k8s manifests: %w", err) + } + slog.Info("Kubernetes manifests generated", "dir", out.Dst()+"/k8s") + slog.Info("You can find them with: cd $(builder-playground dir)/k8s") + } + + if dryRun || k8sFlag { + return nil + } + ctx := mainctx.Get() slog.Info("Starting services... ⏳", "session-id", svcManager.ID) diff --git a/playground/k8sgen/k8s.go b/playground/k8sgen/k8s.go new file mode 100644 index 00000000..44743bf2 --- /dev/null +++ b/playground/k8sgen/k8s.go @@ -0,0 +1,376 @@ +package k8sgen + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/flashbots/builder-playground/utils" + "gopkg.in/yaml.v2" +) + +// GenerateK8s generates Kubernetes manifests from the session's docker-compose.yaml +// using kompose, patches any missing volume mounts, and writes a minikube-mount.sh +// script for all host-path volumes. +func GenerateK8s(sessionDir string) error { + composeFile := filepath.Join(sessionDir, "docker-compose.yaml") + k8sDir := filepath.Join(sessionDir, "k8s") + + if err := os.MkdirAll(k8sDir, 0o755); err != nil { + return fmt.Errorf("failed to create k8s dir: %w", err) + } + + // Run kompose convert + cmd := exec.Command("kompose", "convert", + "-f", composeFile, + "--volumes", "hostPath", + "-o", k8sDir, + ) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + return fmt.Errorf("kompose convert failed: %w\n%s", err, errBuf.String()) + } + + // Parse compose volumes per service + serviceVolumes, err := parseComposeVolumesPerService(composeFile) + if err != nil { + return err + } + + // Parse named Docker volumes (e.g. "volume-beacon-data") — kompose incorrectly + // assigns them a hostPath; we replace them with emptyDir. + namedVolumes, err := parseNamedDockerVolumes(composeFile) + if err != nil { + return err + } + + // Fix hostPath volumes kompose generated from bind mounts: + // - file paths → remove volume and its mounts + // - named Docker volumes → replace with emptyDir + if err := fixMountVolumes(k8sDir, namedVolumes); err != nil { + return fmt.Errorf("failed to fix hostPath volumes: %w", err) + } + + // Patch k8s Deployment files with any bind mounts that kompose dropped + if err := patchK8sMissingVolumes(k8sDir, serviceVolumes); err != nil { + return fmt.Errorf("failed to patch k8s files: %w", err) + } + + // Collect mount paths for minikube-mount.sh: collapse everything under the + // session dir into a single mount; keep any paths outside it as-is. + mountPaths := map[string]struct{}{} + for _, mounts := range serviceVolumes { + for _, m := range mounts { + if m.hostPath == sessionDir || strings.HasPrefix(m.hostPath, sessionDir+"/") { + mountPaths[sessionDir] = struct{}{} + } else { + mountPaths[m.hostPath] = struct{}{} + } + } + } + + // Write minikube-mount.sh + var sb strings.Builder + sb.WriteString("#!/bin/bash\n\n") + sb.WriteString("# Mount all host paths into minikube\n") + for hostPath := range mountPaths { + sb.WriteString(fmt.Sprintf("minikube mount %s:%s &\n", hostPath, hostPath)) + } + sb.WriteString("\nwait\n") + + mountScript := filepath.Join(k8sDir, "minikube-mount.sh") + if err := os.WriteFile(mountScript, []byte(sb.String()), 0o755); err != nil { + return fmt.Errorf("failed to write minikube-mount.sh: %w", err) + } + + return nil +} + +type bindMount struct { + hostPath string + containerPath string +} + +// parseNamedDockerVolumes returns the set of named Docker volume names defined +// in the top-level "volumes:" section of the compose file. +func parseNamedDockerVolumes(composeFile string) (map[string]struct{}, error) { + data, err := os.ReadFile(composeFile) + if err != nil { + return nil, fmt.Errorf("failed to read compose file: %w", err) + } + var compose map[interface{}]interface{} + if err := yaml.Unmarshal(data, &compose); err != nil { + return nil, fmt.Errorf("failed to parse compose file: %w", err) + } + result := map[string]struct{}{} + volumes, _ := compose["volumes"].(map[interface{}]interface{}) + for nameRaw := range volumes { + result[fmt.Sprintf("%v", nameRaw)] = struct{}{} + } + return result, nil +} + +func parseComposeVolumesPerService(composeFile string) (map[string][]bindMount, error) { + data, err := os.ReadFile(composeFile) + if err != nil { + return nil, fmt.Errorf("failed to read compose file: %w", err) + } + + var compose map[interface{}]interface{} + if err := yaml.Unmarshal(data, &compose); err != nil { + return nil, fmt.Errorf("failed to parse compose file: %w", err) + } + + result := map[string][]bindMount{} + services, _ := compose["services"].(map[interface{}]interface{}) + for nameRaw, svc := range services { + name := fmt.Sprintf("%v", nameRaw) + svcMap, _ := svc.(map[interface{}]interface{}) + if svcMap == nil { + continue + } + for _, vol := range toStringSlice(svcMap["volumes"]) { + parts := strings.SplitN(vol, ":", 2) + if len(parts) != 2 || !strings.HasPrefix(parts[0], "/") { + continue + } + result[name] = append(result[name], bindMount{ + hostPath: parts[0], + containerPath: parts[1], + }) + } + } + return result, nil +} + +// fixMountVolumes fixes hostPath volumes kompose generated from bind mounts: +// file-path volumes are removed (along with their volumeMounts), temp-dir +// volumes are replaced with emptyDir, and named Docker volumes that kompose +// incorrectly assigned a hostPath are also replaced with emptyDir. +func fixMountVolumes(k8sDir string, namedVolumes map[string]struct{}) error { + entries, err := os.ReadDir(k8sDir) + if err != nil { + return fmt.Errorf("failed to read k8s dir: %w", err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + filePath := filepath.Join(k8sDir, entry.Name()) + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + var doc map[interface{}]interface{} + if err := yaml.Unmarshal(data, &doc); err != nil { + return err + } + if kind, _ := doc["kind"].(string); kind != "Deployment" { + continue + } + podSpec := nestedIMap(doc, "spec", "template", "spec") + if podSpec == nil { + continue + } + removeVols := map[string]struct{}{} + dirty := false + for _, vol := range toSlice(podSpec["volumes"]) { + volMap, _ := vol.(map[interface{}]interface{}) + hp, _ := volMap["hostPath"].(map[interface{}]interface{}) + if hp == nil { + continue + } + volName, _ := volMap["name"].(string) + path, _ := hp["path"].(string) + info, statErr := os.Stat(path) + if statErr == nil && !info.IsDir() { + removeVols[volName] = struct{}{} + dirty = true + } else if _, ok := namedVolumes[volName]; ok || strings.HasPrefix(path, utils.TempPlaygroundDirPath()+"/") { + delete(volMap, "hostPath") + volMap["emptyDir"] = map[interface{}]interface{}{} + dirty = true + } + } + if !dirty { + continue + } + + if len(removeVols) > 0 { + newVols := make([]interface{}, 0) + for _, vol := range toSlice(podSpec["volumes"]) { + volMap, _ := vol.(map[interface{}]interface{}) + if name, _ := volMap["name"].(string); name != "" { + if _, drop := removeVols[name]; drop { + continue + } + } + newVols = append(newVols, vol) + } + podSpec["volumes"] = newVols + + for _, c := range toSlice(podSpec["containers"]) { + cMap, _ := c.(map[interface{}]interface{}) + newVMs := make([]interface{}, 0) + for _, vm := range toSlice(cMap["volumeMounts"]) { + vmMap, _ := vm.(map[interface{}]interface{}) + if name, _ := vmMap["name"].(string); name != "" { + if _, drop := removeVols[name]; drop { + continue + } + } + newVMs = append(newVMs, vm) + } + cMap["volumeMounts"] = newVMs + } + } + + out, err := yaml.Marshal(doc) + if err != nil { + return err + } + if err := os.WriteFile(filePath, out, 0o644); err != nil { + return err + } + } + return nil +} + +// patchK8sMissingVolumes walks k8sDir and for each Deployment file adds any bind +// mounts from the compose file that kompose did not include. +func patchK8sMissingVolumes(k8sDir string, serviceVolumes map[string][]bindMount) error { + entries, err := os.ReadDir(k8sDir) + if err != nil { + return fmt.Errorf("failed to read k8s dir: %w", err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + if err := patchDeploymentIfNeeded(filepath.Join(k8sDir, entry.Name()), serviceVolumes); err != nil { + return fmt.Errorf("patching %s: %w", entry.Name(), err) + } + } + return nil +} + +func patchDeploymentIfNeeded(filePath string, serviceVolumes map[string][]bindMount) error { + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + + var doc map[interface{}]interface{} + if err := yaml.Unmarshal(data, &doc); err != nil { + return err + } + + if kind, _ := doc["kind"].(string); kind != "Deployment" { + return nil + } + + metadata, _ := doc["metadata"].(map[interface{}]interface{}) + if metadata == nil { + return nil + } + svcName, _ := metadata["name"].(string) + mounts, ok := serviceVolumes[svcName] + if !ok { + return nil + } + + podSpec := nestedIMap(doc, "spec", "template", "spec") + if podSpec == nil { + return nil + } + + containers, _ := podSpec["containers"].([]interface{}) + + // Collect mountPaths already present across all containers + existing := map[string]struct{}{} + for _, c := range containers { + cMap, _ := c.(map[interface{}]interface{}) + for _, vm := range toSlice(cMap["volumeMounts"]) { + vmMap, _ := vm.(map[interface{}]interface{}) + if mp, _ := vmMap["mountPath"].(string); mp != "" { + existing[mp] = struct{}{} + } + } + } + + // Find compose bind mounts not yet in the k8s file, skipping file-level mounts + var missing []bindMount + for _, m := range mounts { + if _, found := existing[m.containerPath]; found { + continue + } + if info, err := os.Stat(m.hostPath); err == nil && !info.IsDir() { + continue + } + missing = append(missing, m) + } + if len(missing) == 0 { + return nil + } + + // Add missing volumes + volumeMounts + volumes := toSlice(podSpec["volumes"]) + for i, m := range missing { + volName := fmt.Sprintf("%s-extra-%d", svcName, i) + volumes = append(volumes, map[interface{}]interface{}{ + "name": volName, + "hostPath": map[interface{}]interface{}{ + "path": m.hostPath, + }, + }) + for _, c := range containers { + cMap, _ := c.(map[interface{}]interface{}) + vms := toSlice(cMap["volumeMounts"]) + cMap["volumeMounts"] = append(vms, map[interface{}]interface{}{ + "name": volName, + "mountPath": m.containerPath, + }) + } + } + podSpec["volumes"] = volumes + + out, err := yaml.Marshal(doc) + if err != nil { + return fmt.Errorf("failed to marshal patched deployment: %w", err) + } + return os.WriteFile(filePath, out, 0o644) +} + +// nestedIMap navigates a chain of keys in a map[interface{}]interface{} tree. +func nestedIMap(m map[interface{}]interface{}, keys ...string) map[interface{}]interface{} { + cur := m + for _, k := range keys { + next, _ := cur[k].(map[interface{}]interface{}) + if next == nil { + return nil + } + cur = next + } + return cur +} + +func toSlice(v interface{}) []interface{} { + s, _ := v.([]interface{}) + return s +} + +func toStringSlice(v interface{}) []string { + sl, _ := v.([]interface{}) + out := make([]string, 0, len(sl)) + for _, item := range sl { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} diff --git a/playground/k8sgen/k8s_test.go b/playground/k8sgen/k8s_test.go new file mode 100644 index 00000000..526435a9 --- /dev/null +++ b/playground/k8sgen/k8s_test.go @@ -0,0 +1,321 @@ +package k8sgen + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +// writeFile writes content to a file in dir and returns the path. +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + p := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(p, []byte(content), 0o644)) + return p +} + +// readDeployment reads a Deployment YAML from a file. +func readDeployment(t *testing.T, path string) map[interface{}]interface{} { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + var doc map[interface{}]interface{} + require.NoError(t, yaml.Unmarshal(data, &doc)) + return doc +} + +func TestParseNamedDockerVolumes(t *testing.T) { + r := require.New(t) + dir := t.TempDir() + compose := writeFile(t, dir, "docker-compose.yaml", ` +services: + el: + image: foo + volumes: + - volume-el-data:/data + - /host/path:/config + +volumes: + volume-el-data: + volume-beacon-data: +`) + got, err := parseNamedDockerVolumes(compose) + r.NoError(err) + r.Contains(got, "volume-el-data") + r.Contains(got, "volume-beacon-data") + r.Len(got, 2) +} + +func TestParseNamedDockerVolumes_NoVolumesSection(t *testing.T) { + r := require.New(t) + dir := t.TempDir() + compose := writeFile(t, dir, "docker-compose.yaml", ` +services: + el: + image: foo +`) + got, err := parseNamedDockerVolumes(compose) + r.NoError(err) + r.Empty(got) +} + +func TestParseComposeVolumesPerService(t *testing.T) { + r := require.New(t) + dir := t.TempDir() + compose := writeFile(t, dir, "docker-compose.yaml", ` +services: + el: + image: foo + volumes: + - /host/data:/data + - volume-el-data:/internal + - /host/config:/config + beacon: + image: bar + volumes: + - volume-beacon-data:/data_beacon +`) + got, err := parseComposeVolumesPerService(compose) + r.NoError(err) + + elMounts := got["el"] + r.Len(elMounts, 2) + r.Equal(bindMount{hostPath: "/host/data", containerPath: "/data"}, elMounts[0]) + r.Equal(bindMount{hostPath: "/host/config", containerPath: "/config"}, elMounts[1]) + + // Named volume should be skipped + r.Empty(got["beacon"]) +} + +func TestFixMountVolumes_NamedVolumeBecomesEmptyDir(t *testing.T) { + r := require.New(t) + k8sDir := t.TempDir() + writeFile(t, k8sDir, "beacon-deployment.yaml", ` +kind: Deployment +metadata: + name: beacon +spec: + template: + spec: + containers: + - name: beacon + volumeMounts: + - name: volume-beacon-data + mountPath: /data_beacon + volumes: + - name: volume-beacon-data + hostPath: + path: /some/session/dir +`) + r.NoError(fixMountVolumes(k8sDir, map[string]struct{}{"volume-beacon-data": {}})) + + doc := readDeployment(t, filepath.Join(k8sDir, "beacon-deployment.yaml")) + podSpec := nestedIMap(doc, "spec", "template", "spec") + for _, vol := range toSlice(podSpec["volumes"]) { + volMap, _ := vol.(map[interface{}]interface{}) + if volMap["name"] == "volume-beacon-data" { + r.Contains(volMap, "emptyDir", "expected emptyDir for volume-beacon-data") + r.NotContains(volMap, "hostPath", "hostPath should be removed") + } + } +} + +func TestFixMountVolumes_TempPathBecomesEmptyDir(t *testing.T) { + r := require.New(t) + k8sDir := t.TempDir() + tempPath := filepath.Join(t.TempDir(), "bind-mount-volumes", "volume-el-data") + r.NoError(os.MkdirAll(tempPath, 0o755)) + + writeFile(t, k8sDir, "el-deployment.yaml", ` +kind: Deployment +metadata: + name: el +spec: + template: + spec: + containers: + - name: el + volumeMounts: + - name: el-hostpath3 + mountPath: /data + volumes: + - name: el-hostpath3 + hostPath: + path: `+tempPath+` +`) + // Use the named-volume mechanism to cover the emptyDir replacement path. + r.NoError(fixMountVolumes(k8sDir, map[string]struct{}{"el-hostpath3": {}})) + + doc := readDeployment(t, filepath.Join(k8sDir, "el-deployment.yaml")) + podSpec := nestedIMap(doc, "spec", "template", "spec") + for _, vol := range toSlice(podSpec["volumes"]) { + volMap, _ := vol.(map[interface{}]interface{}) + if volMap["name"] == "el-hostpath3" { + r.Contains(volMap, "emptyDir") + } + } +} + +func TestFixMountVolumes_FileHostPathRemoved(t *testing.T) { + r := require.New(t) + k8sDir := t.TempDir() + tmpFile, err := os.CreateTemp(t.TempDir(), "secret") + r.NoError(err) + tmpFile.Close() + + writeFile(t, k8sDir, "el-deployment.yaml", ` +kind: Deployment +metadata: + name: el +spec: + template: + spec: + containers: + - name: el + volumeMounts: + - name: el-file-vol + mountPath: /config/jwt + - name: el-data + mountPath: /data + volumes: + - name: el-file-vol + hostPath: + path: `+tmpFile.Name()+` + - name: el-data + hostPath: + path: /some/dir +`) + r.NoError(fixMountVolumes(k8sDir, map[string]struct{}{})) + + doc := readDeployment(t, filepath.Join(k8sDir, "el-deployment.yaml")) + podSpec := nestedIMap(doc, "spec", "template", "spec") + + for _, vol := range toSlice(podSpec["volumes"]) { + volMap, _ := vol.(map[interface{}]interface{}) + r.NotEqual("el-file-vol", volMap["name"], "file-level volume should have been removed") + } + for _, c := range toSlice(podSpec["containers"]) { + cMap, _ := c.(map[interface{}]interface{}) + for _, vm := range toSlice(cMap["volumeMounts"]) { + vmMap, _ := vm.(map[interface{}]interface{}) + r.NotEqual("el-file-vol", vmMap["name"], "volumeMount for file-level volume should have been removed") + } + } +} + +func TestFixMountVolumes_NonDeploymentSkipped(t *testing.T) { + r := require.New(t) + k8sDir := t.TempDir() + original := `kind: Service +metadata: + name: el +spec: + ports: + - port: 8551 +` + writeFile(t, k8sDir, "el-service.yaml", original) + r.NoError(fixMountVolumes(k8sDir, map[string]struct{}{})) + got, err := os.ReadFile(filepath.Join(k8sDir, "el-service.yaml")) + r.NoError(err) + r.Equal(original, string(got)) +} + +func TestPatchDeploymentIfNeeded_AddsMissingMount(t *testing.T) { + r := require.New(t) + k8sDir := t.TempDir() + hostDir := t.TempDir() + + deployPath := writeFile(t, k8sDir, "el-deployment.yaml", ` +kind: Deployment +metadata: + name: el +spec: + template: + spec: + containers: + - name: el + volumeMounts: [] + volumes: [] +`) + serviceVolumes := map[string][]bindMount{ + "el": {{hostPath: hostDir, containerPath: "/data"}}, + } + r.NoError(patchDeploymentIfNeeded(deployPath, serviceVolumes)) + + doc := readDeployment(t, deployPath) + podSpec := nestedIMap(doc, "spec", "template", "spec") + + var found bool + for _, vol := range toSlice(podSpec["volumes"]) { + volMap, _ := vol.(map[interface{}]interface{}) + hp, _ := volMap["hostPath"].(map[interface{}]interface{}) + if hp != nil && hp["path"] == hostDir { + found = true + } + } + r.True(found, "expected missing bind mount to be added as hostPath volume") +} + +func TestPatchDeploymentIfNeeded_SkipsAlreadyPresent(t *testing.T) { + r := require.New(t) + k8sDir := t.TempDir() + hostDir := t.TempDir() + + deployPath := writeFile(t, k8sDir, "el-deployment.yaml", ` +kind: Deployment +metadata: + name: el +spec: + template: + spec: + containers: + - name: el + volumeMounts: + - name: existing-vol + mountPath: /data + volumes: + - name: existing-vol + hostPath: + path: `+hostDir+` +`) + serviceVolumes := map[string][]bindMount{ + "el": {{hostPath: hostDir, containerPath: "/data"}}, + } + r.NoError(patchDeploymentIfNeeded(deployPath, serviceVolumes)) + + doc := readDeployment(t, deployPath) + podSpec := nestedIMap(doc, "spec", "template", "spec") + r.Len(toSlice(podSpec["volumes"]), 1, "no new volumes should have been added") +} + +func TestPatchDeploymentIfNeeded_SkipsFileMount(t *testing.T) { + r := require.New(t) + k8sDir := t.TempDir() + tmpFile, err := os.CreateTemp(t.TempDir(), "jwt") + r.NoError(err) + tmpFile.Close() + + deployPath := writeFile(t, k8sDir, "el-deployment.yaml", ` +kind: Deployment +metadata: + name: el +spec: + template: + spec: + containers: + - name: el + volumeMounts: [] + volumes: [] +`) + serviceVolumes := map[string][]bindMount{ + "el": {{hostPath: tmpFile.Name(), containerPath: "/config/jwt"}}, + } + r.NoError(patchDeploymentIfNeeded(deployPath, serviceVolumes)) + + doc := readDeployment(t, deployPath) + podSpec := nestedIMap(doc, "spec", "template", "spec") + r.Empty(toSlice(podSpec["volumes"]), "file-level mount should not be added") +} diff --git a/playground/local_runner.go b/playground/local_runner.go index de3bc724..d424e429 100644 --- a/playground/local_runner.go +++ b/playground/local_runner.go @@ -623,7 +623,7 @@ func (d *LocalRunner) toDockerComposeService(s *Service) (map[string]interface{} // Use files mapped to figure out which files from the artifacts is using the service volumes := map[string]string{ - outputFolder: "/artifacts", // placeholder + outputFolder: "/data", } for k, v := range s.FilesMapped { volumes[filepath.Join(outputFolder, v)] = k @@ -1180,9 +1180,7 @@ func (d *LocalRunner) pullNotAvailableImages(ctx context.Context) error { return g.Wait() } -func (d *LocalRunner) Run(ctx context.Context) error { - go d.trackContainerStatusAndLogs() - +func (d *LocalRunner) WriteDockerComposeFile() error { yamlData, err := d.generateDockerCompose() if err != nil { return fmt.Errorf("failed to generate docker-compose.yaml: %w", err) @@ -1192,6 +1190,12 @@ func (d *LocalRunner) Run(ctx context.Context) error { return fmt.Errorf("failed to write docker-compose.yaml: %w", err) } + return nil +} + +func (d *LocalRunner) Run(ctx context.Context) error { + go d.trackContainerStatusAndLogs() + // Run setup commands before launching any services if err := d.runSetupCommands(ctx); err != nil { return err From 35e36b0d5d21ecbcf49614f80f0bf587da0d515b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caner=20=C3=87=C4=B1dam?= Date: Tue, 24 Mar 2026 04:00:38 +0100 Subject: [PATCH 2/2] fix volume mount consistency --- playground/k8sgen/k8s.go | 28 ++++++++++++++++++++-- playground/k8sgen/k8s_test.go | 44 +++++++++++++++++++++++++++++++---- playground/local_runner.go | 2 +- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/playground/k8sgen/k8s.go b/playground/k8sgen/k8s.go index 44743bf2..714ed33c 100644 --- a/playground/k8sgen/k8s.go +++ b/playground/k8sgen/k8s.go @@ -49,10 +49,20 @@ func GenerateK8s(sessionDir string) error { return err } + // Normalize the session dir container path to /data across all services so + // that fixMountVolumes and patchK8sMissingVolumes both agree on the mount path. + for svc := range serviceVolumes { + for i := range serviceVolumes[svc] { + if serviceVolumes[svc][i].hostPath == sessionDir { + serviceVolumes[svc][i].containerPath = "/data" + } + } + } + // Fix hostPath volumes kompose generated from bind mounts: // - file paths → remove volume and its mounts // - named Docker volumes → replace with emptyDir - if err := fixMountVolumes(k8sDir, namedVolumes); err != nil { + if err := fixMountVolumes(k8sDir, sessionDir, namedVolumes); err != nil { return fmt.Errorf("failed to fix hostPath volumes: %w", err) } @@ -66,6 +76,9 @@ func GenerateK8s(sessionDir string) error { mountPaths := map[string]struct{}{} for _, mounts := range serviceVolumes { for _, m := range mounts { + if strings.HasPrefix(m.hostPath, utils.TempPlaygroundDirPath()+"/") { + continue // converted to emptyDir, no minikube mount needed + } if m.hostPath == sessionDir || strings.HasPrefix(m.hostPath, sessionDir+"/") { mountPaths[sessionDir] = struct{}{} } else { @@ -152,7 +165,7 @@ func parseComposeVolumesPerService(composeFile string) (map[string][]bindMount, // file-path volumes are removed (along with their volumeMounts), temp-dir // volumes are replaced with emptyDir, and named Docker volumes that kompose // incorrectly assigned a hostPath are also replaced with emptyDir. -func fixMountVolumes(k8sDir string, namedVolumes map[string]struct{}) error { +func fixMountVolumes(k8sDir, sessionDir string, namedVolumes map[string]struct{}) error { entries, err := os.ReadDir(k8sDir) if err != nil { return fmt.Errorf("failed to read k8s dir: %w", err) @@ -195,6 +208,17 @@ func fixMountVolumes(k8sDir string, namedVolumes map[string]struct{}) error { delete(volMap, "hostPath") volMap["emptyDir"] = map[interface{}]interface{}{} dirty = true + } else if path == sessionDir { + for _, c := range toSlice(podSpec["containers"]) { + cMap, _ := c.(map[interface{}]interface{}) + for _, vm := range toSlice(cMap["volumeMounts"]) { + vmMap, _ := vm.(map[interface{}]interface{}) + if vmMap["name"] == volName { + vmMap["mountPath"] = "/data" + } + } + } + dirty = true } } if !dirty { diff --git a/playground/k8sgen/k8s_test.go b/playground/k8sgen/k8s_test.go index 526435a9..dd435001 100644 --- a/playground/k8sgen/k8s_test.go +++ b/playground/k8sgen/k8s_test.go @@ -110,7 +110,7 @@ spec: hostPath: path: /some/session/dir `) - r.NoError(fixMountVolumes(k8sDir, map[string]struct{}{"volume-beacon-data": {}})) + r.NoError(fixMountVolumes(k8sDir, "", map[string]struct{}{"volume-beacon-data": {}})) doc := readDeployment(t, filepath.Join(k8sDir, "beacon-deployment.yaml")) podSpec := nestedIMap(doc, "spec", "template", "spec") @@ -147,7 +147,7 @@ spec: path: `+tempPath+` `) // Use the named-volume mechanism to cover the emptyDir replacement path. - r.NoError(fixMountVolumes(k8sDir, map[string]struct{}{"el-hostpath3": {}})) + r.NoError(fixMountVolumes(k8sDir, "", map[string]struct{}{"el-hostpath3": {}})) doc := readDeployment(t, filepath.Join(k8sDir, "el-deployment.yaml")) podSpec := nestedIMap(doc, "spec", "template", "spec") @@ -188,7 +188,7 @@ spec: hostPath: path: /some/dir `) - r.NoError(fixMountVolumes(k8sDir, map[string]struct{}{})) + r.NoError(fixMountVolumes(k8sDir, "", map[string]struct{}{})) doc := readDeployment(t, filepath.Join(k8sDir, "el-deployment.yaml")) podSpec := nestedIMap(doc, "spec", "template", "spec") @@ -206,6 +206,42 @@ spec: } } +func TestFixMountVolumes_SessionDirMountPathPatchedToData(t *testing.T) { + r := require.New(t) + sessionDir := t.TempDir() + k8sDir := t.TempDir() + writeFile(t, k8sDir, "el-deployment.yaml", ` +kind: Deployment +metadata: + name: el +spec: + template: + spec: + containers: + - name: el + volumeMounts: + - name: el-session + mountPath: /artifacts + volumes: + - name: el-session + hostPath: + path: `+sessionDir+` +`) + r.NoError(fixMountVolumes(k8sDir, sessionDir, map[string]struct{}{})) + + doc := readDeployment(t, filepath.Join(k8sDir, "el-deployment.yaml")) + podSpec := nestedIMap(doc, "spec", "template", "spec") + for _, c := range toSlice(podSpec["containers"]) { + cMap, _ := c.(map[interface{}]interface{}) + for _, vm := range toSlice(cMap["volumeMounts"]) { + vmMap, _ := vm.(map[interface{}]interface{}) + if vmMap["name"] == "el-session" { + r.Equal("/data", vmMap["mountPath"]) + } + } + } +} + func TestFixMountVolumes_NonDeploymentSkipped(t *testing.T) { r := require.New(t) k8sDir := t.TempDir() @@ -217,7 +253,7 @@ spec: - port: 8551 ` writeFile(t, k8sDir, "el-service.yaml", original) - r.NoError(fixMountVolumes(k8sDir, map[string]struct{}{})) + r.NoError(fixMountVolumes(k8sDir, "", map[string]struct{}{})) got, err := os.ReadFile(filepath.Join(k8sDir, "el-service.yaml")) r.NoError(err) r.Equal(original, string(got)) diff --git a/playground/local_runner.go b/playground/local_runner.go index d424e429..14f6f1b2 100644 --- a/playground/local_runner.go +++ b/playground/local_runner.go @@ -623,7 +623,7 @@ func (d *LocalRunner) toDockerComposeService(s *Service) (map[string]interface{} // Use files mapped to figure out which files from the artifacts is using the service volumes := map[string]string{ - outputFolder: "/data", + outputFolder: "/artifacts", } for k, v := range s.FilesMapped { volumes[filepath.Join(outputFolder, v)] = k