diff --git a/.xcli.example.yaml b/.xcli.example.yaml index 554c001..721afcd 100644 --- a/.xcli.example.yaml +++ b/.xcli.example.yaml @@ -1,5 +1,5 @@ -# lab-dev configuration example -# This file is generated by 'lab-dev init' and can be customized for your environment +# xcli lab configuration example +# This file is generated by 'xcli lab init' and can be customized for your environment # Repository paths (auto-discovered or manually specified) repos: @@ -9,6 +9,11 @@ repos: labBackend: ../lab-backend lab: ../lab +# Optional stable instance id. If omitted, xcli derives one from the config +# root and config path. With the wrapped config format this is lab.instance.id. +instance: + id: "" + # Operating mode: "local" or "hybrid" # - local: All services run locally including Xatu ClickHouse cluster # - hybrid: External Xatu data source, local transformations and APIs @@ -67,7 +72,7 @@ infrastructure: volumes: persist: true # Use named volumes (data survives down/up cycles) - # Set to false for clean slate every restart + # Use 'xcli lab destroy --instance ' for an intentional clean slate # Observability stack (Prometheus + Grafana) # When enabled, starts Prometheus to scrape all lab service metrics diff --git a/README.md b/README.md index 8a2c35b..9be958a 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ xcli cc # Launch web dashboard (Command Center) xcli lab status # Check status (CLI) ``` +Each checkout is a distinct lab instance. A single instance uses the familiar +default ports when they are free. Additional worktrees get isolated Docker +resources, generated configs, state, and a non-overlapping port slot. + Services: - Lab Frontend: @@ -160,11 +164,31 @@ Keyboard shortcuts: xcli lab init # Initialize configuration xcli lab check # Verify environment (repos, Docker, config) xcli lab up # Start all services (always rebuilds) -xcli lab down # Stop and remove containers/volumes -xcli lab clean # Remove all containers, volumes, and build artifacts +xcli lab down # Stop the selected instance, preserve data +xcli lab stop # Same safe stop as down +xcli lab clean # Safe alias for down xcli lab status # Show service status +xcli lab status --all # Show all known instances +xcli lab list # List persisted instances +xcli lab show # Show one instance manifest and live state +xcli lab destroy --instance # Delete one instance's data and generated state +xcli lab reset redis --instance # Intentionally clear Redis for one instance ``` +`down`, `stop`, and `clean` preserve ClickHouse, Redis, Prometheus, and Grafana +data. `destroy` and `reset redis` are the destructive paths. + +Use `--instance ` from any directory to target a specific persisted +instance: + +```bash +xcli lab --instance worktree-a stop +xcli lab --instance worktree-a destroy --yes +``` + +By default, xcli derives the instance id from the config root and config path. +You can set a stable id with `lab.instance.id` in `.xcli.yaml`. + ### Build & Rebuild ```bash @@ -197,6 +221,9 @@ xcli lab logs -f # Follow logs Services: `lab-backend`, `lab-frontend`, `cbt-mainnet`, `cbt-api-mainnet`, etc. +Service commands target the selected instance. Use `xcli lab --instance +logs lab-backend` to inspect another worktree's stack. + ### Configuration ```bash @@ -243,8 +270,11 @@ xcli lab status # View logs xcli lab logs lab-backend -f -# Complete cleanup (removes all containers, volumes, build artifacts) -xcli lab clean +# Diagnose instance paths, ports, traps, and latest rebuild failure +xcli lab diagnose + +# Remove all generated state and data for one instance +xcli lab destroy --instance ``` ### Getting Help @@ -263,19 +293,23 @@ xcli lab mode --help # Understand local vs hybrid modes `.xcli.yaml` is created by `xcli lab init`. Key settings: ```yaml -mode: local # "local" or "hybrid" - -networks: - - name: mainnet - enabled: true - portOffset: 0 - -infrastructure: - clickhouse: - xatu: - mode: local # "local" or "external" - volumes: - persist: true # Keep data between restarts +lab: + instance: + id: worktree-a # Optional stable id; otherwise xcli derives one + + mode: local # "local" or "hybrid" + + networks: + - name: mainnet + enabled: true + portOffset: 0 + + infrastructure: + clickhouse: + xatu: + mode: local # "local" or "external" + volumes: + persist: true # Keep data between restarts ``` **Modes:** diff --git a/cmd/xcli/main.go b/cmd/xcli/main.go index 63047a9..1196c6e 100644 --- a/cmd/xcli/main.go +++ b/cmd/xcli/main.go @@ -88,6 +88,7 @@ func main() { } log.SetLevel(level) + config.SetRuntimeConfigPath(configPath) // Enable log writer based on verbose flag logWriter.SetEnabled(verbose) diff --git a/go.mod b/go.mod index 2ea286e..7a2db20 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/aws/smithy-go v1.26.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/containerd/errdefs v1.0.0 github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.7.0 github.com/pterm/pterm v0.12.83 @@ -53,7 +54,6 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/containerd/console v1.0.5 // indirect - github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect diff --git a/pkg/cc/backend_lab.go b/pkg/cc/backend_lab.go index e535c7e..a1e5bd6 100644 --- a/pkg/cc/backend_lab.go +++ b/pkg/cc/backend_lab.go @@ -13,6 +13,7 @@ import ( "github.com/ethpandaops/xcli/pkg/configtui" "github.com/ethpandaops/xcli/pkg/constants" "github.com/ethpandaops/xcli/pkg/git" + "github.com/ethpandaops/xcli/pkg/instance" "github.com/ethpandaops/xcli/pkg/orchestrator" "github.com/ethpandaops/xcli/pkg/seeddata" "github.com/ethpandaops/xcli/pkg/tui" @@ -27,6 +28,7 @@ type labBackend struct { orch *orchestrator.Orchestrator labCfg *config.LabConfig cfgPath string + runtime *instance.Runtime gitChk *git.Checker } @@ -39,6 +41,7 @@ func newLabBackend( orch *orchestrator.Orchestrator, labCfg *config.LabConfig, cfgPath string, + runtime *instance.Runtime, gitChk *git.Checker, ) *labBackend { return &labBackend{ @@ -47,6 +50,7 @@ func newLabBackend( orch: orch, labCfg: labCfg, cfgPath: cfgPath, + runtime: runtime, gitChk: gitChk, } } @@ -120,16 +124,16 @@ func (b *labBackend) GetConfigSummary() any { Mode: b.labCfg.Mode, Networks: networks, Ports: portsInfo{ - LabBackend: b.labCfg.Ports.LabBackend, - LabFrontend: b.labCfg.Ports.LabFrontend, - CBTBase: b.labCfg.Ports.CBTBase, - CBTAPIBase: b.labCfg.Ports.CBTAPIBase, - CBTFrontendBase: b.labCfg.Ports.CBTFrontendBase, - ClickHouseCBT: b.labCfg.Infrastructure.ClickHouseCBTPort, - ClickHouseXatu: b.labCfg.Infrastructure.ClickHouseXatuPort, - Redis: b.labCfg.Infrastructure.RedisPort, - Prometheus: b.labCfg.Infrastructure.Observability.PrometheusPort, - Grafana: b.labCfg.Infrastructure.Observability.GrafanaPort, + LabBackend: b.portPlan().LabBackend, + LabFrontend: b.portPlan().LabFrontend, + CBTBase: b.portPlan().CBTBase, + CBTAPIBase: b.portPlan().CBTAPIBase, + CBTFrontendBase: b.portPlan().CBTFrontendBase, + ClickHouseCBT: b.portPlan().ClickHouseCBT01HTTP, + ClickHouseXatu: b.portPlan().ClickHouseXatu01HTTP, + Redis: b.portPlan().Redis, + Prometheus: b.portPlan().Prometheus, + Grafana: b.portPlan().Grafana, }, CfgPath: b.cfgPath, } @@ -179,7 +183,7 @@ func (b *labBackend) RebuildService(ctx context.Context, name string) error { // LogSource returns how to stream logs for a given service. func (b *labBackend) LogSource(name string) LogSourceInfo { - if container, ok := dockerContainerNames[name]; ok { + if container, ok := b.orch.InfrastructureManager().DockerContainerName(name); ok { return LogSourceInfo{Type: cmdDocker, Container: container} } @@ -193,13 +197,7 @@ func (b *labBackend) LogFilePath(name string) string { // GitRepos returns the lab repositories for git status checking. func (b *labBackend) GitRepos() map[string]string { - return map[string]string{ - "cbt": b.labCfg.Repos.CBT, - "xatu-cbt": b.labCfg.Repos.XatuCBT, - "cbt-api": b.labCfg.Repos.CBTAPI, - "lab-backend": b.labCfg.Repos.LabBackend, - "lab": b.labCfg.Repos.Lab, - } + return b.labCfg.Repos.Map() } // GetEditableConfig returns the lab config with passwords masked. @@ -279,11 +277,7 @@ func (b *labBackend) PutEditableConfig(data json.RawMessage) error { // GetOverrides returns the CBT overrides state. func (b *labBackend) GetOverrides() (any, error) { xatuCBTPath := b.labCfg.Repos.XatuCBT - stateDir := b.orch.StateDir() - - overridesPath := filepath.Join( - filepath.Dir(stateDir), constants.CBTOverridesFile, - ) + overridesPath := b.overridesPath() externalNames, transformNames, err := configtui.DiscoverModels(xatuCBTPath) if err != nil { @@ -350,10 +344,7 @@ func (b *labBackend) PutOverrides(data json.RawMessage) error { return fmt.Errorf("invalid request body: %w", err) } - stateDir := b.orch.StateDir() - overridesPath := filepath.Join( - filepath.Dir(stateDir), constants.CBTOverridesFile, - ) + overridesPath := b.overridesPath() existingOverrides, _, err := configtui.LoadOverrides(overridesPath) if err != nil { @@ -417,14 +408,14 @@ func (b *labBackend) PutOverrides(data json.RawMessage) error { // GetConfigFiles lists generated config files with override status. func (b *labBackend) GetConfigFiles() ([]configFileInfo, error) { - configsDir := filepath.Join(b.orch.StateDir(), "configs") + configsDir := filepath.Join(b.orch.StateDir(), constants.DirConfigs) entries, err := os.ReadDir(configsDir) if err != nil { return nil, fmt.Errorf("failed to read configs directory: %w", err) } - customDir := filepath.Join(b.orch.StateDir(), "custom-configs") + customDir := b.customConfigsDir() files := make([]configFileInfo, 0, len(entries)) for _, entry := range entries { @@ -458,7 +449,7 @@ func (b *labBackend) GetConfigFile(name string) (*configFileContent, error) { return nil, fmt.Errorf("invalid file name") } - configsDir := filepath.Join(b.orch.StateDir(), "configs") + configsDir := filepath.Join(b.orch.StateDir(), constants.DirConfigs) safePath := filepath.Join(configsDir, cleanName) content, err := os.ReadFile(safePath) @@ -471,9 +462,7 @@ func (b *labBackend) GetConfigFile(name string) (*configFileContent, error) { Content: string(content), } - customPath := filepath.Join( - b.orch.StateDir(), "custom-configs", cleanName, - ) + customPath := filepath.Join(b.customConfigsDir(), cleanName) overrideContent, overrideErr := os.ReadFile(customPath) if overrideErr == nil { @@ -496,7 +485,7 @@ func (b *labBackend) PutConfigFileOverride(name, content string) error { return fmt.Errorf("invalid YAML: %w", err) } - customDir := filepath.Join(b.orch.StateDir(), "custom-configs") + customDir := b.customConfigsDir() if err := os.MkdirAll(customDir, 0755); err != nil { return fmt.Errorf("failed to create custom-configs directory: %w", err) } @@ -522,9 +511,7 @@ func (b *labBackend) DeleteConfigFileOverride(name string) error { return fmt.Errorf("invalid file name") } - safePath := filepath.Join( - b.orch.StateDir(), "custom-configs", cleanName, - ) + safePath := filepath.Join(b.customConfigsDir(), cleanName) if err := os.Remove(safePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove override: %w", err) @@ -544,7 +531,11 @@ func (b *labBackend) Regenerate(ctx context.Context) error { // RecreateOrchestrator rebuilds the orchestrator with the current config. func (b *labBackend) RecreateOrchestrator() error { - newOrch, err := orchestrator.NewOrchestrator(b.log, b.labCfg, b.cfgPath) + if b.runtime == nil { + return fmt.Errorf("lab runtime is required to recreate orchestrator") + } + + newOrch, err := orchestrator.NewOrchestratorWithRuntime(b.log, b.runtime) if err != nil { return fmt.Errorf("failed to recreate orchestrator: %w", err) } @@ -564,7 +555,69 @@ func (b *labBackend) StateDir() string { return b.orch.StateDir() } +func (b *labBackend) customConfigsDir() string { + return filepath.Join(b.workspaceStateDir(), constants.DirCustomConfigs) +} + +func (b *labBackend) overridesPath() string { + if b.runtime != nil { + if b.runtime.Workspace != nil && b.runtime.Workspace.OverridesPath != "" { + return b.runtime.Workspace.OverridesPath + } + + if b.runtime.Manifest != nil && b.runtime.Manifest.OverridesPath != "" { + return b.runtime.Manifest.OverridesPath + } + } + + return filepath.Join(filepath.Dir(b.workspaceStateDir()), constants.CBTOverridesFile) +} + +func (b *labBackend) workspaceStateDir() string { + if b.runtime != nil { + if b.runtime.Workspace != nil && b.runtime.Workspace.StateDir != "" { + return b.runtime.Workspace.StateDir + } + + if b.runtime.Manifest != nil && b.runtime.Manifest.RootDir != "" { + return filepath.Join(b.runtime.Manifest.RootDir, ".xcli") + } + } + + stateDir := b.orch.StateDir() + + instancesDir := filepath.Dir(stateDir) + if filepath.Base(instancesDir) == "instances" { + return filepath.Dir(instancesDir) + } + + return filepath.Dir(stateDir) +} + // RedisAddr returns the Redis address for the Lab stack. func (b *labBackend) RedisAddr() string { - return fmt.Sprintf("localhost:%d", b.labCfg.Infrastructure.RedisPort) + if port := b.portPlan().Redis; port > 0 { + return fmt.Sprintf("localhost:%d", port) + } + + return "localhost:0" +} + +func (b *labBackend) portPlan() instance.PortPlan { + if b.runtime != nil { + if len(b.runtime.Ports.AllPorts()) > 0 { + return b.runtime.Ports + } + + if b.runtime.Manifest != nil && len(b.runtime.Manifest.Ports.AllPorts()) > 0 { + return b.runtime.Manifest.Ports + } + } + + plan, err := instance.BuildPortPlan(b.labCfg, 0) + if err != nil { + return instance.PortPlan{} + } + + return plan } diff --git a/pkg/cc/backend_lab_test.go b/pkg/cc/backend_lab_test.go new file mode 100644 index 0000000..3e86d2f --- /dev/null +++ b/pkg/cc/backend_lab_test.go @@ -0,0 +1,149 @@ +package cc + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/ethpandaops/xcli/pkg/config" + "github.com/ethpandaops/xcli/pkg/constants" + "github.com/ethpandaops/xcli/pkg/instance" + "github.com/ethpandaops/xcli/pkg/orchestrator" + "github.com/ethpandaops/xcli/pkg/workspace" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +func TestLabBackendRedisAddrUsesRuntimePort(t *testing.T) { + labCfg := config.DefaultLab() + labCfg.Infrastructure.RedisPort = 6380 + runtime := &instance.Runtime{ + Ports: instance.PortPlan{Redis: 7380}, + Manifest: &instance.Manifest{ + Ports: instance.PortPlan{Redis: 7380}, + }, + } + + backend := &labBackend{ + labCfg: labCfg, + runtime: runtime, + } + + require.Equal(t, "localhost:7380", backend.RedisAddr()) + + summary, ok := backend.GetConfigSummary().(configResponse) + require.True(t, ok) + require.Equal(t, 7380, summary.Ports.Redis) +} + +func TestLabBackendConfigOverrideUsesWorkspaceCustomConfigDir(t *testing.T) { + runtime := newTestLabRuntime(t, "cc-override") + orch, err := orchestrator.NewOrchestratorWithRuntime(logrus.New(), runtime) + require.NoError(t, err) + + backend := newLabBackend( + logrus.New(), + orch, + runtime.LabConfig, + runtime.Workspace.ConfigPath, + runtime, + nil, + ) + name := fmt.Sprintf(constants.ConfigFileCBTAPI, "mainnet") + content := "custom: true\n" + + require.NoError(t, backend.PutConfigFileOverride(name, content)) + + workspaceOverride := filepath.Join(runtime.Workspace.StateDir, constants.DirCustomConfigs, name) + instanceOverride := filepath.Join(runtime.Manifest.StateDir, constants.DirCustomConfigs, name) + generated := filepath.Join(runtime.Manifest.StateDir, constants.DirConfigs, name) + + require.FileExists(t, workspaceOverride) + require.NoFileExists(t, instanceOverride) + require.FileExists(t, generated) + + generatedContent, err := os.ReadFile(generated) + require.NoError(t, err) + require.Equal(t, content, string(generatedContent)) + + file, err := backend.GetConfigFile(name) + require.NoError(t, err) + require.True(t, file.HasOverride) + require.Equal(t, content, file.OverrideContent) +} + +func newTestLabRuntime(t *testing.T, instanceID string) *instance.Runtime { + t.Helper() + + rootDir := t.TempDir() + reposDir := filepath.Join(rootDir, "repos") + repoPaths := map[string]string{ + constants.RepoCBT: filepath.Join(reposDir, constants.RepoCBT), + constants.RepoXatuCBT: filepath.Join(reposDir, constants.RepoXatuCBT), + constants.RepoCBTAPI: filepath.Join(reposDir, constants.RepoCBTAPI), + constants.RepoLabBackend: filepath.Join(reposDir, constants.RepoLabBackend), + constants.RepoLab: filepath.Join(reposDir, constants.RepoLab), + } + + for _, path := range repoPaths { + require.NoError(t, os.MkdirAll(path, 0755)) + } + + require.NoError(t, os.MkdirAll(filepath.Join(repoPaths[constants.RepoXatuCBT], "models", "external"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(repoPaths[constants.RepoXatuCBT], "models", "transformations"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(repoPaths[constants.RepoXatuCBT], "models", "scripts"), 0755)) + require.NoError(t, os.WriteFile( + filepath.Join(repoPaths[constants.RepoXatuCBT], "models", "external", "fct_block.sql"), + []byte("SELECT 1"), + 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(repoPaths[constants.RepoXatuCBT], "models", "transformations", "fct_summary.sql"), + []byte("SELECT 1"), + 0600, + )) + + labCfg := config.DefaultLab() + labCfg.Repos = config.LabReposConfig{ + CBT: repoPaths[constants.RepoCBT], + XatuCBT: repoPaths[constants.RepoXatuCBT], + CBTAPI: repoPaths[constants.RepoCBTAPI], + LabBackend: repoPaths[constants.RepoLabBackend], + Lab: repoPaths[constants.RepoLab], + } + labCfg.Networks = []config.NetworkConfig{{Name: "mainnet", Enabled: true, PortOffset: 0}} + labCfg.Infrastructure.Observability.Enabled = false + + configPath := filepath.Join(rootDir, config.DefaultConfigFileName) + require.NoError(t, (&config.Config{Lab: labCfg}).Save(configPath)) + + ws := &workspace.Workspace{ + RootDir: rootDir, + ConfigPath: configPath, + OverridesPath: filepath.Join(rootDir, constants.CBTOverridesFile), + StateDir: filepath.Join(rootDir, ".xcli"), + ConfigExists: true, + } + + manifest, err := instance.NewManifest(context.Background(), ws, labCfg, instanceID) + require.NoError(t, err) + ports, err := instance.BuildPortPlan(labCfg, 0) + require.NoError(t, err) + + dockerPlan := instance.NewDockerPlan(manifest.InstanceID, manifest.ConfigPath) + manifest.Ports = ports + manifest.Docker = dockerPlan + + return &instance.Runtime{ + Workspace: ws, + LabConfig: labCfg, + Registry: instance.NewRegistry(filepath.Join(t.TempDir(), "instances")), + InstanceID: manifest.InstanceID, + Manifest: manifest, + Ports: ports, + Docker: dockerPlan, + Repos: manifest.Repos, + } +} diff --git a/pkg/cc/frontend/src/components/LabConfigEditor/LabConfigEditor.tsx b/pkg/cc/frontend/src/components/LabConfigEditor/LabConfigEditor.tsx index 368aed0..d20664a 100644 --- a/pkg/cc/frontend/src/components/LabConfigEditor/LabConfigEditor.tsx +++ b/pkg/cc/frontend/src/components/LabConfigEditor/LabConfigEditor.tsx @@ -329,7 +329,8 @@ export default function LabConfigEditor({ onToast, onNavigateDashboard, stack }:

Restart Required

- Lab config changes require a full stack restart. The stack will be torn down and rebooted. + Lab config changes require a full stack restart. The selected instance will be stopped and started + again.