diff --git a/README.md b/README.md index 0f86dfb..9c14a79 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,11 @@ curl -sfL https://kubeorch.dev/install.sh | sh -s -- --uninstall ## Features -- **Concurrent Operations** - Fast parallel execution for cloning, pulling, and health checks +- **Concurrent Operations** - Fast parallel execution for repository cloning and dependency installation - **Safe Configuration Management** - File locking prevents corruption during concurrent access -- **Multiple Project Support** - Manage multiple KubeOrch projects seamlessly -- **Auto-detection** - Automatically determines development mode based on cloned repositories -- **Hot Reload** - All development modes support hot reload for rapid iteration +- **Reliable Project Discovery** - Resolves `.kubeorch/project.json` from the current directory or any child directory +- **Existing Checkout Support** - Adopts local UI/Core repositories without cloning or overwriting them +- **Fast Local Iteration** - UI changes hot-reload; Core runs directly on the host for quick restarts ## Commands @@ -82,6 +82,7 @@ curl -sfL https://kubeorch.dev/install.sh | sh -s -- --uninstall - `orchcli logs --tail 50` - Show last 50 lines - `orchcli init --fork-ui` - Clone UI repository - `orchcli init --fork-core` - Clone Core repository +- `orchcli init --ui-path ./ui --core-path ./core` - Use existing repositories ## Quick Start @@ -93,7 +94,7 @@ orchcli start -d # Access application # UI: http://localhost:3001 -# API: http://localhost:3000 +# API: http://localhost:3000/v1/api # View logs orchcli logs -f @@ -102,21 +103,27 @@ orchcli logs -f orchcli stop ``` +The currently published Core and UI `v0.0.3` images are pinned by digest and are +available for AMD64 only. Use source development mode on ARM64 until multi-arch +release images are published. + ### Development Mode ```bash -# Clone repositories for development +# Clone repositories, or adopt checkouts that already exist orchcli init --fork-ui --fork-core +# orchcli init --ui-path ./ui --core-path ./core -# Start PostgreSQL +# Start MongoDB in Docker orchcli start -d # Start Core (Terminal 1) -cd core && air +cd core && go run . +# Restart this process after changing Core code # Start UI (Terminal 2) cd ui && npm run dev -# Access: UI at localhost:3001, API at localhost:3000 +# Access: UI at localhost:3001, API at localhost:3000/v1/api ``` ### Frontend Development Only @@ -136,11 +143,13 @@ cd ui && npm run dev # Clone Core repository orchcli init --fork-core -# Start all services (Core with hot reload) +# Start MongoDB and the published UI image orchcli start -d -# Edit Core files locally - changes auto-reload -# Access: UI at localhost:3001, API at localhost:3000 +# Run Core on the host +cd core && go run . + +# Access: UI at localhost:3001, API at localhost:3000/v1/api ``` ## Documentation @@ -158,4 +167,4 @@ See the [contributing guide](https://github.com/KubeOrch/.github/blob/main/CONTR ## License -[Apache 2.0](LICENSE) \ No newline at end of file +[Apache 2.0](LICENSE) diff --git a/cmd/config.go b/cmd/config.go index 094db25..041210a 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,13 +2,30 @@ package cmd import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" + "strings" "github.com/gofrs/flock" ) +const ( + projectMarkerDir = ".kubeorch" + projectMarkerFilename = "project.json" + projectMarkerVersion = 1 +) + +var errProjectMarkerNotFound = errors.New("project marker not found") + +type projectMarker struct { + UIPath string `json:"ui_path,omitempty"` + CorePath string `json:"core_path,omitempty"` + Mode string `json:"mode"` + Version int `json:"version"` +} + type ProjectConfig struct { Path string `json:"path"` UIPath string `json:"ui_path,omitempty"` @@ -101,9 +118,7 @@ func SaveConfig(config *OrchConfig) error { _ = fileLock.Unlock() }() - // Write atomically - const configFileMode = 0600 - if err := os.WriteFile(configPath, data, configFileMode); err != nil { + if err := writeFileAtomically(configPath, data, configFilePerm); err != nil { return fmt.Errorf("failed to write config: %w", err) } @@ -111,88 +126,286 @@ func SaveConfig(config *OrchConfig) error { } func getCurrentProjectConfig() (*ProjectConfig, error) { - config, err := LoadConfig() - if err != nil { - return nil, err - } - cwd, err := os.Getwd() if err != nil { return nil, fmt.Errorf("failed to get current directory: %w", err) } - if project, exists := config.Projects[cwd]; exists { + project, err := loadProjectMarker(cwd) + if err == nil { return project, nil } + if !errors.Is(err, errProjectMarkerNotFound) { + return nil, err + } - if config.CurrentProject != "" { - if project, exists := config.Projects[config.CurrentProject]; exists { - return project, nil + // Older OrchCLI releases only wrote the global registry. Support those + // projects when the current directory is actually inside a registered root. + config, err := LoadConfig() + if err != nil { + return nil, err + } + + var closest *ProjectConfig + for _, registered := range config.Projects { + if registered == nil || !pathContains(registered.Path, cwd) { + continue + } + if closest == nil || len(registered.Path) > len(closest.Path) { + closest = registered + } + } + if closest != nil { + if err := validateProjectSources(closest); err != nil { + return nil, err } + return closest, nil } - return nil, fmt.Errorf("no project configured for current directory. Run 'orchcli init' first") + return nil, fmt.Errorf( + "%w: no %s found in %s or its parents; run 'orchcli init' from the project root", + errProjectMarkerNotFound, + projectMarkerFilename, + cwd, + ) } func setProjectConfig(projectPath string, uiPath, corePath string) error { - configPath, err := GetConfigPath() + projectPath, err := filepath.Abs(projectPath) if err != nil { - return err + return fmt.Errorf("failed to resolve project path: %w", err) } + projectPath = filepath.Clean(projectPath) - // Use file locking for concurrent access - lockPath := configPath + ".lock" - fileLock := flock.New(lockPath) - - // Try to acquire lock - err = fileLock.Lock() + uiPath, err = absoluteOptionalPath(uiPath) if err != nil { - return fmt.Errorf("failed to acquire config lock: %w", err) + return fmt.Errorf("failed to resolve UI path: %w", err) } - defer func() { - _ = fileLock.Unlock() - }() - - // Load current config - config, err := LoadConfig() + corePath, err = absoluteOptionalPath(corePath) if err != nil { - return err + return fmt.Errorf("failed to resolve Core path: %w", err) } - // Determine mode - var mode string + mode := projectMode(uiPath, corePath) + project := &ProjectConfig{ + Path: projectPath, + UIPath: uiPath, + CorePath: corePath, + Mode: mode, + } + return writeProjectMarker(project) +} + +func projectMode(uiPath, corePath string) string { switch { case uiPath != "" && corePath != "": - mode = "development" + return "development" case uiPath != "": - mode = "ui-dev" + return "ui-dev" case corePath != "": - mode = "core-dev" + return "core-dev" default: - mode = "production" + return "production" + } +} + +func absoluteOptionalPath(path string) (string, error) { + if path == "" { + return "", nil + } + absPath, err := filepath.Abs(path) + if err != nil { + return "", err + } + return filepath.Clean(absPath), nil +} + +func writeProjectMarker(project *ProjectConfig) error { + markerDir := filepath.Join(project.Path, projectMarkerDir) + if err := os.MkdirAll(markerDir, dirPerm); err != nil { + return fmt.Errorf("failed to create project marker directory: %w", err) + } + + marker := projectMarker{ + Version: projectMarkerVersion, + UIPath: portableProjectPath(project.Path, project.UIPath), + CorePath: portableProjectPath(project.Path, project.CorePath), + Mode: project.Mode, + } + data, err := json.MarshalIndent(marker, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal project marker: %w", err) } + data = append(data, '\n') - // Update config - config.Projects[projectPath] = &ProjectConfig{ + markerPath := filepath.Join(markerDir, projectMarkerFilename) + if err := writeFileAtomically(markerPath, data, configFilePerm); err != nil { + return fmt.Errorf("failed to write project marker %s: %w", markerPath, err) + } + return nil +} + +func writeFileAtomically(targetPath string, data []byte, perm os.FileMode) error { + return writeFileAtomicallyWithHook(targetPath, data, perm, nil) +} + +func writeFileAtomicallyWithHook( + targetPath string, + data []byte, + perm os.FileMode, + beforeRename func(string) error, +) error { + tempFile, err := os.CreateTemp(filepath.Dir(targetPath), "."+filepath.Base(targetPath)+"-*.tmp") + if err != nil { + return fmt.Errorf("failed to create temporary file: %w", err) + } + tempPath := tempFile.Name() + defer func() { + _ = tempFile.Close() + _ = os.Remove(tempPath) + }() + + if err := tempFile.Chmod(perm); err != nil { + return fmt.Errorf("failed to set temporary file permissions: %w", err) + } + if _, err := tempFile.Write(data); err != nil { + return fmt.Errorf("failed to write temporary file: %w", err) + } + if err := tempFile.Sync(); err != nil { + return fmt.Errorf("failed to sync temporary file: %w", err) + } + if err := tempFile.Close(); err != nil { + return fmt.Errorf("failed to close temporary file: %w", err) + } + if beforeRename != nil { + if err := beforeRename(tempPath); err != nil { + return fmt.Errorf("failed before replacing target file: %w", err) + } + } + if err := os.Rename(tempPath, targetPath); err != nil { + return fmt.Errorf("failed to replace target file: %w", err) + } + return nil +} + +func loadProjectMarker(startPath string) (*ProjectConfig, error) { + current, err := filepath.Abs(startPath) + if err != nil { + return nil, fmt.Errorf("failed to resolve current directory: %w", err) + } + current = filepath.Clean(current) + + for { + markerPath := filepath.Join(current, projectMarkerDir, projectMarkerFilename) + data, readErr := os.ReadFile(markerPath) + if readErr == nil { + return parseProjectMarker(current, markerPath, data) + } + if !os.IsNotExist(readErr) { + return nil, fmt.Errorf("failed to read project marker %s: %w", markerPath, readErr) + } + + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + + return nil, errProjectMarkerNotFound +} + +func parseProjectMarker(projectPath, markerPath string, data []byte) (*ProjectConfig, error) { + var marker projectMarker + if err := json.Unmarshal(data, &marker); err != nil { + return nil, fmt.Errorf("invalid project marker %s: %w; run 'orchcli init' from %s to repair it", markerPath, err, projectPath) + } + if marker.Version != projectMarkerVersion { + return nil, fmt.Errorf( + "unsupported project marker version %d in %s (expected %d); update OrchCLI or run 'orchcli init' from %s", + marker.Version, + markerPath, + projectMarkerVersion, + projectPath, + ) + } + + uiPath := resolveProjectPath(projectPath, marker.UIPath) + corePath := resolveProjectPath(projectPath, marker.CorePath) + expectedMode := projectMode(uiPath, corePath) + if marker.Mode != expectedMode { + return nil, fmt.Errorf( + "invalid project marker %s: mode %q does not match configured source paths (expected %q); "+ + "run 'orchcli init' from %s to repair it", + markerPath, + marker.Mode, + expectedMode, + projectPath, + ) + } + + project := &ProjectConfig{ Path: projectPath, UIPath: uiPath, CorePath: corePath, - Mode: mode, + Mode: marker.Mode, + } + if err := validateProjectSources(project); err != nil { + return nil, fmt.Errorf("invalid project marker %s: %w", markerPath, err) } - config.CurrentProject = projectPath + return project, nil +} - // Marshal and save - data, err := json.MarshalIndent(config, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) +func validateProjectSources(project *ProjectConfig) error { + if project.UIPath != "" && !dirExists(project.UIPath) { + return fmt.Errorf( + "configured UI checkout is missing: %s; run 'orchcli init --ui-path ' from %s to repair it", + project.UIPath, + project.Path, + ) } + if project.CorePath != "" && !dirExists(project.CorePath) { + return fmt.Errorf( + "configured Core checkout is missing: %s; run 'orchcli init --core-path ' from %s to repair it", + project.CorePath, + project.Path, + ) + } + return nil +} - const configFileMode = 0600 - if err := os.WriteFile(configPath, data, configFileMode); err != nil { - return fmt.Errorf("failed to write config: %w", err) +func portableProjectPath(projectPath, sourcePath string) string { + if sourcePath == "" { + return "" } + rel, err := filepath.Rel(projectPath, sourcePath) + if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return filepath.ToSlash(rel) + } + return filepath.Clean(sourcePath) +} - return nil +func resolveProjectPath(projectPath, sourcePath string) string { + if sourcePath == "" { + return "" + } + if !filepath.IsAbs(sourcePath) { + sourcePath = filepath.Join(projectPath, filepath.FromSlash(sourcePath)) + } + return filepath.Clean(sourcePath) +} + +func pathContains(root, candidate string) bool { + rootAbs, rootErr := filepath.Abs(root) + candidateAbs, candidateErr := filepath.Abs(candidate) + if rootErr != nil || candidateErr != nil { + return false + } + rel, err := filepath.Rel(rootAbs, candidateAbs) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) } // removeProjectConfig removes a project from the configuration @@ -237,8 +450,7 @@ func removeProjectConfig(projectPath string) error { return fmt.Errorf("failed to marshal config: %w", err) } - const configFileMode = 0600 - if err := os.WriteFile(configPath, data, configFileMode); err != nil { + if err := writeFileAtomically(configPath, data, configFilePerm); err != nil { return fmt.Errorf("failed to write config: %w", err) } diff --git a/cmd/debug.go b/cmd/debug.go index 5cd6b6f..9762489 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -21,6 +21,10 @@ func init() { } func runDebug(cmd *cobra.Command, args []string) error { + if _, err := getCurrentProjectConfig(); err != nil { + return fmt.Errorf("failed to resolve KubeOrch project: %w", err) + } + if err := validateDockerCompose(); err != nil { return err } diff --git a/cmd/docker/docker-compose.dev.yml b/cmd/docker/docker-compose.dev.yml index 17257b5..adcecbb 100644 --- a/cmd/docker/docker-compose.dev.yml +++ b/cmd/docker/docker-compose.dev.yml @@ -1,11 +1,9 @@ -version: '3.8' - # Development mode - both UI and Core run on host # Only MongoDB runs in Docker services: mongodb: - image: mongo:8.0 + image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a container_name: kubeorchestra-mongodb-dev restart: unless-stopped environment: @@ -30,4 +28,4 @@ volumes: mongodb_data: name: kubeorchestra_mongodb_data_dev mongodb_config: - name: kubeorchestra_mongodb_config_dev \ No newline at end of file + name: kubeorchestra_mongodb_config_dev diff --git a/cmd/docker/docker-compose.hybrid-core.yml b/cmd/docker/docker-compose.hybrid-core.yml index 8c6986b..dcc38d3 100644 --- a/cmd/docker/docker-compose.hybrid-core.yml +++ b/cmd/docker/docker-compose.hybrid-core.yml @@ -1,7 +1,5 @@ -version: '3.8' - # Hybrid Core mode - Core repo cloned, UI from Docker image -# All services run in Docker for simplicity +# MongoDB and UI run in Docker; Core runs on the host. networks: kubeorchestra-net: @@ -10,7 +8,7 @@ networks: services: mongodb: - image: mongo:8.0 + image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a container_name: kubeorchestra-mongodb-hybrid restart: unless-stopped networks: @@ -29,48 +27,22 @@ services: retries: 5 start_period: 10s - # Core runs in container with mounted code for hot reload - # This way backend devs don't need Go installed locally - core: - image: cosmtrek/air:latest - container_name: kubeorchestra-core-hybrid - restart: unless-stopped - networks: - - kubeorchestra-net - volumes: - - ../core:/app - - go-modules:/go/pkg/mod # Cache Go modules - working_dir: /app - ports: - - "3000:3000" - environment: - MONGO_URI: mongodb://mongodb:27017/kubeorchestra - MONGO_DB_NAME: kubeorchestra - depends_on: - mongodb: - condition: service_healthy - command: air - ui: - image: ghcr.io/kubeorch/ui:latest + image: ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3 container_name: kubeorchestra-ui-hybrid restart: unless-stopped networks: - kubeorchestra-net ports: - - "3001:3001" + - "3001:3000" environment: - # Both use internal Docker network names - NEXT_PUBLIC_API_URL: http://localhost:3000 - API_URL_INTERNAL: http://core:3000 - PORT: 3001 - depends_on: - - core + NEXT_PUBLIC_API_URL: http://localhost:3000/v1/api + +# Core runs on host: cd core && go run . (port 3000) +# Core connects to MongoDB at localhost:27017. volumes: mongodb_data: name: kubeorchestra_mongodb_data_hybrid mongodb_config: name: kubeorchestra_mongodb_config_hybrid - go-modules: - name: kubeorchestra_go_modules \ No newline at end of file diff --git a/cmd/docker/docker-compose.hybrid-ui.yml b/cmd/docker/docker-compose.hybrid-ui.yml index 3c75969..0072095 100644 --- a/cmd/docker/docker-compose.hybrid-ui.yml +++ b/cmd/docker/docker-compose.hybrid-ui.yml @@ -1,5 +1,3 @@ -version: '3.8' - # Hybrid UI mode - UI runs on host, Core from Docker image # MongoDB and Core run in Docker @@ -10,7 +8,7 @@ networks: services: mongodb: - image: mongo:8.0 + image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a container_name: kubeorchestra-mongodb-hybrid restart: unless-stopped networks: @@ -30,7 +28,7 @@ services: start_period: 10s core: - image: ghcr.io/kubeorch/core:latest + image: ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e container_name: kubeorchestra-core-hybrid restart: unless-stopped networks: @@ -38,17 +36,16 @@ services: ports: - "3000:3000" environment: - MONGO_URI: mongodb://mongodb:27017/kubeorchestra - MONGO_DB_NAME: kubeorchestra + KUBEORCH_MONGO_URI: mongodb://mongodb:27017/kubeorchestra depends_on: mongodb: condition: service_healthy # UI runs on host: cd ui && npm install && npm run dev (port 3001) -# UI connects to Core at localhost:3000 +# UI connects to Core at localhost:3000/v1/api volumes: mongodb_data: name: kubeorchestra_mongodb_data_hybrid mongodb_config: - name: kubeorchestra_mongodb_config_hybrid \ No newline at end of file + name: kubeorchestra_mongodb_config_hybrid diff --git a/cmd/docker/docker-compose.prod.yml b/cmd/docker/docker-compose.prod.yml index a396f5e..68fb700 100644 --- a/cmd/docker/docker-compose.prod.yml +++ b/cmd/docker/docker-compose.prod.yml @@ -1,5 +1,3 @@ -version: '3.8' - networks: kubeorchestra-net: driver: bridge @@ -7,7 +5,7 @@ networks: services: mongodb: - image: mongo:8.0 + image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a container_name: kubeorchestra-mongodb restart: unless-stopped networks: @@ -29,7 +27,7 @@ services: start_period: 10s core: - image: ghcr.io/kubeorch/core:latest + image: ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e container_name: kubeorchestra-core restart: unless-stopped networks: @@ -39,25 +37,23 @@ services: expose: - "3000" environment: - MONGO_URI: mongodb://mongodb:27017/kubeorchestra - MONGO_DB_NAME: kubeorchestra + KUBEORCH_MONGO_URI: mongodb://mongodb:27017/kubeorchestra depends_on: mongodb: condition: service_healthy ui: - image: ghcr.io/kubeorch/ui:latest + image: ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3 container_name: kubeorchestra-ui restart: unless-stopped networks: - kubeorchestra-net ports: - - "3001:3001" + - "3001:3000" expose: - - "3001" + - "3000" environment: - NEXT_PUBLIC_API_URL: http://localhost:3000 - API_URL_INTERNAL: http://core:3000 + NEXT_PUBLIC_API_URL: http://localhost:3000/v1/api depends_on: - core @@ -65,4 +61,4 @@ volumes: mongodb_data: name: kubeorchestra_mongodb_data mongodb_config: - name: kubeorchestra_mongodb_config \ No newline at end of file + name: kubeorchestra_mongodb_config diff --git a/cmd/exec.go b/cmd/exec.go index 79f0b3a..6d681df 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -37,6 +37,10 @@ func init() { } func runExec(cmd *cobra.Command, args []string) error { + if _, err := getCurrentProjectConfig(); err != nil { + return fmt.Errorf("failed to resolve KubeOrch project: %w", err) + } + if err := validateDockerCompose(); err != nil { return err } diff --git a/cmd/init.go b/cmd/init.go index 73027d6..6e2c850 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "os/exec" @@ -17,10 +18,12 @@ const ( ) var ( - forkUI string - forkCore string - skipDeps bool - autoInstall bool + forkUI string + forkCore string + existingUIPath string + existingCorePath string + skipDeps bool + autoInstall bool ) var initCmd = &cobra.Command{ @@ -30,6 +33,7 @@ var initCmd = &cobra.Command{ Without flags: Sets up for production testing using Docker images (no repos cloned). With --fork-ui or --fork-core: Clones repositories for development. +With --ui-path or --core-path: Uses an existing local checkout. Examples: # Production setup (uses Docker images only) @@ -45,13 +49,18 @@ Examples: orchcli init --fork-ui= # Clone only Core for backend development - orchcli init --fork-core=`, + orchcli init --fork-core= + + # Use existing checkouts without cloning them + orchcli init --ui-path ./ui --core-path ./core`, RunE: runInit, } func init() { initCmd.Flags().StringVar(&forkUI, "fork-ui", "", "Clone UI repository (use --fork-ui= for official, or --fork-ui=username/repo for fork)") initCmd.Flags().StringVar(&forkCore, "fork-core", "", "Clone Core repository (use --fork-core= for official, or --fork-core=username/repo for fork)") + initCmd.Flags().StringVar(&existingUIPath, "ui-path", "", "Use an existing UI checkout instead of cloning") + initCmd.Flags().StringVar(&existingCorePath, "core-path", "", "Use an existing Core checkout instead of cloning") initCmd.Flags().BoolVar(&skipDeps, "skip-deps", false, "Skip dependency installation") initCmd.Flags().BoolVar(&autoInstall, "auto-install", true, "Automatically install missing dependencies (npm, go)") @@ -62,63 +71,131 @@ func init() { } func runInit(cmd *cobra.Command, args []string) error { - uiSet := cmd.Flags().Changed("fork-ui") - coreSet := cmd.Flags().Changed("fork-core") + cloneUI := cmd.Flags().Changed("fork-ui") + cloneCore := cmd.Flags().Changed("fork-core") + useExistingUI := cmd.Flags().Changed("ui-path") + useExistingCore := cmd.Flags().Changed("core-path") + + if cloneUI && useExistingUI { + return fmt.Errorf("--fork-ui and --ui-path cannot be used together") + } + if cloneCore && useExistingCore { + return fmt.Errorf("--fork-core and --core-path cannot be used together") + } - if !uiSet && !coreSet { + if !cloneUI && !cloneCore && !useExistingUI && !useExistingCore { return setupProduction() } - return setupDevelopment(uiSet, coreSet) + return setupDevelopment(cloneUI, cloneCore, useExistingUI, useExistingCore) } func setupProduction() error { - fmt.Println("πŸš€ Setting up OrchCLI for production testing") - fmt.Println(" No repositories will be cloned.") - fmt.Println(" Docker images will be used for both UI and Core.") - - if err := validateDockerCompose(); err != nil { - return err - } - cwd, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get current directory: %w", err) } + existingProject, err := loadProjectMarker(cwd) + if errors.Is(err, errProjectMarkerNotFound) { + existingProject = nil + } else if err != nil { + return err + } + projectPath := cwd + if existingProject != nil { + projectPath = existingProject.Path + } + if existingProject != nil && (existingProject.UIPath != "" || existingProject.CorePath != "") { + fmt.Println("πŸ”§ Refreshing existing OrchCLI development environment") + fmt.Println(" Preserving configured UI and Core source paths.") + } else { + fmt.Println("πŸš€ Setting up OrchCLI for production testing") + fmt.Println(" No repositories will be cloned.") + fmt.Println(" Docker images will be used for both UI and Core.") + } + + if err := validateDockerCompose(); err != nil { + return err + } dirs := []string{"docker", "scripts"} for _, dir := range dirs { - if err := os.MkdirAll(dir, dirPerm); err != nil { + if err := os.MkdirAll(filepath.Join(projectPath, dir), dirPerm); err != nil { return fmt.Errorf("failed to create directory %s: %w", dir, err) } } // Write embedded docker-compose files - if err := writeEmbeddedComposeFiles(filepath.Join(cwd, "docker")); err != nil { + if err := writeEmbeddedComposeFiles(filepath.Join(projectPath, "docker")); err != nil { return fmt.Errorf("failed to write docker-compose files: %w", err) } - // Save project configuration - if err := setProjectConfig(cwd, "", ""); err != nil { - fmt.Printf("⚠️ warning: failed to save project configuration: %v\n", err) + uiPath, corePath := "", "" + if existingProject != nil { + uiPath = existingProject.UIPath + corePath = existingProject.CorePath + } + if err := setProjectConfig(projectPath, uiPath, corePath); err != nil { + return fmt.Errorf("failed to save project configuration: %w", err) + } + if uiPath != "" || corePath != "" { + fmt.Println("\nβœ… Development environment refreshed without changing its mode!") + fmt.Printf("πŸ“ Project initialized at: %s\n", projectPath) + fmt.Println("\n Run 'orchcli start' to start the configured development services") + return nil } fmt.Println("\nβœ… Production environment ready!") - fmt.Printf("πŸ“ Project initialized at: %s\n", cwd) + fmt.Printf("πŸ“ Project initialized at: %s\n", projectPath) fmt.Println("\nπŸ“ Docker images that will be used:") - fmt.Println(" - ghcr.io/kubeorch/core:latest") - fmt.Println(" - ghcr.io/kubeorch/ui:latest") - fmt.Println("\n You can specify versions with: orchcli start --version=v1.2.3") - fmt.Println(" Run 'orchcli start' to start services with latest images") + fmt.Println(" - ghcr.io/kubeorch/core:v0.0.3 (digest pinned)") + fmt.Println(" - ghcr.io/kubeorch/ui:v0.0.3 (digest pinned)") + fmt.Println("\n Run 'orchcli start' to start the pinned release images") return nil } -func setupDevelopment(cloneUI, cloneCore bool) error { +type developmentSetup struct { + projectPath string + uiPath string + corePath string + uiRepoURL string + coreRepoURL string + cloneUI bool + cloneCore bool + uiIsFork bool + coreIsFork bool +} + +func setupDevelopment(cloneUI, cloneCore, useExistingUI, useExistingCore bool) error { fmt.Println("πŸ”§ Setting up OrchCLI for development") - cwd, err := os.Getwd() + setup, err := prepareDevelopmentSetup(cloneUI, cloneCore, useExistingUI, useExistingCore) if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) + return err + } + if err := setup.cloneRepositories(); err != nil { + return err + } + if err := writeDevelopmentComposeFiles(setup.projectPath); err != nil { + return err + } + if err := setup.configureUpstreams(); err != nil { + return err + } + setup.installDependencies() + setup.generateConfigFiles() + + if err := setProjectConfig(setup.projectPath, setup.uiPath, setup.corePath); err != nil { + return fmt.Errorf("failed to save project configuration: %w", err) + } + setup.printSummary() + return nil +} + +func prepareDevelopmentSetup(cloneUI, cloneCore, useExistingUI, useExistingCore bool) (*developmentSetup, error) { + projectPath, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get current directory: %w", err) } if cloneUI && forkUI == "" { @@ -127,184 +204,224 @@ func setupDevelopment(cloneUI, cloneCore bool) error { if cloneCore && forkCore == "" { forkCore = defaultCoreRepo } - - if err := checkPrerequisites(); err != nil { - return err + if prerequisiteErr := checkPrerequisites(cloneUI || cloneCore); prerequisiteErr != nil { + return nil, prerequisiteErr + } + if validationErr := validateAndCheckDirs(cloneUI, cloneCore); validationErr != nil { + return nil, validationErr } - if err := validateAndCheckDirs(cloneUI, cloneCore); err != nil { - return err + setup := &developmentSetup{projectPath: projectPath, cloneUI: cloneUI, cloneCore: cloneCore} + if useExistingUI { + setup.uiPath, err = resolveExistingCheckout(projectPath, existingUIPath, "UI", "package.json") + if err != nil { + return nil, err + } + } + if useExistingCore { + setup.corePath, err = resolveExistingCheckout(projectPath, existingCorePath, "Core", "go.mod") + if err != nil { + return nil, err + } + } + if cloneUI { + setup.uiRepoURL, setup.uiIsFork = determineRepoURL(forkUI, defaultUIRepo) + setup.uiPath = filepath.Join(projectPath, "ui") + } + if cloneCore { + setup.coreRepoURL, setup.coreIsFork = determineRepoURL(forkCore, defaultCoreRepo) + setup.corePath = filepath.Join(projectPath, "core") } + return setup, nil +} + +func (setup *developmentSetup) hasUI() bool { + return setup.uiPath != "" +} - // Prepare tasks for concurrent execution - var cloneTasks []Task +func (setup *developmentSetup) hasCore() bool { + return setup.corePath != "" +} - if cloneUI && cloneCore { +func (setup *developmentSetup) cloneRepositories() error { + var tasks []Task + if setup.cloneUI && setup.cloneCore { fmt.Println("πŸ“¦ Cloning repositories concurrently...") } - - // UI cloning task - var uiRepoURL string - var uiIsFork bool - var uiPath string - if cloneUI { - uiRepoURL, uiIsFork = determineRepoURL(forkUI, defaultUIRepo) - uiPath = filepath.Join(cwd, "ui") - cloneTasks = append(cloneTasks, Task{ - Action: func() error { - return cloneRepo(uiRepoURL, uiPath) - }, - Progress: NewProgressBar(fmt.Sprintf("Cloning UI from %s", uiRepoURL)), + if setup.cloneUI { + tasks = append(tasks, Task{ + Action: func() error { return cloneRepo(setup.uiRepoURL, setup.uiPath) }, + Progress: NewProgressBar(fmt.Sprintf("Cloning UI from %s", setup.uiRepoURL)), Name: "Clone UI repository", }) } - - // Core cloning task - var coreRepoURL string - var coreIsFork bool - var corePath string - if cloneCore { - coreRepoURL, coreIsFork = determineRepoURL(forkCore, defaultCoreRepo) - corePath = filepath.Join(cwd, "core") - cloneTasks = append(cloneTasks, Task{ - Action: func() error { - return cloneRepo(coreRepoURL, corePath) - }, - Progress: NewProgressBar(fmt.Sprintf("Cloning Core from %s", coreRepoURL)), + if setup.cloneCore { + tasks = append(tasks, Task{ + Action: func() error { return cloneRepo(setup.coreRepoURL, setup.corePath) }, + Progress: NewProgressBar(fmt.Sprintf("Cloning Core from %s", setup.coreRepoURL)), Name: "Clone Core repository", }) } - - // Execute cloning tasks concurrently - if len(cloneTasks) > 0 { - results := RunConcurrent(cloneTasks) - if err := AggregateErrors(results); err != nil { - return err - } + if len(tasks) == 0 { + return nil } + return AggregateErrors(RunConcurrent(tasks)) +} - // Write embedded docker-compose files - dockerDir := filepath.Join(cwd, "docker") +func writeDevelopmentComposeFiles(projectPath string) error { + dockerDir := filepath.Join(projectPath, "docker") if err := os.MkdirAll(dockerDir, dirPerm); err != nil { return fmt.Errorf("failed to create docker directory: %w", err) } if err := writeEmbeddedComposeFiles(dockerDir); err != nil { return fmt.Errorf("failed to write docker-compose files: %w", err) } + return nil +} - // Setup upstreams for forks (sequential as they're quick) - if cloneUI && uiIsFork { +func (setup *developmentSetup) configureUpstreams() error { + if setup.cloneUI && setup.uiIsFork { fmt.Println("πŸ”— Setting up upstream for UI fork...") - if err := setupUpstream(uiPath, "https://github.com/"+defaultUIRepo); err != nil { + if err := setupUpstream(setup.uiPath, "https://github.com/"+defaultUIRepo); err != nil { return fmt.Errorf("failed to setup upstream for UI: %w", err) } } - - if cloneCore && coreIsFork { + if setup.cloneCore && setup.coreIsFork { fmt.Println("πŸ”— Setting up upstream for Core fork...") - if err := setupUpstream(corePath, "https://github.com/"+defaultCoreRepo); err != nil { + if err := setupUpstream(setup.corePath, "https://github.com/"+defaultCoreRepo); err != nil { return fmt.Errorf("failed to setup upstream for Core: %w", err) } } + return nil +} - // Install dependencies concurrently - if !skipDeps { - var depTasks []Task +func (setup *developmentSetup) installDependencies() { + if skipDeps { + return + } - if cloneUI { - depTasks = append(depTasks, Task{ - Action: func() error { - return installUIDependencies(uiPath) - }, - Progress: NewProgressBar("Installing UI dependencies (npm install)"), - Name: "Install UI dependencies", - }) - } + var tasks []Task + if setup.hasUI() { + tasks = append(tasks, Task{ + Action: func() error { return installUIDependencies(setup.uiPath) }, + Progress: NewProgressBar("Installing UI dependencies (npm install)"), + Name: "Install UI dependencies", + }) + } + if setup.hasCore() { + tasks = append(tasks, Task{ + Action: func() error { return installCoreDependencies(setup.corePath) }, + Progress: NewProgressBar("Downloading Core dependencies (go mod download)"), + Name: "Download Core dependencies", + }) + } + if len(tasks) == 0 { + return + } - if cloneCore { - depTasks = append(depTasks, Task{ - Action: func() error { - return installCoreDependencies(corePath) - }, - Progress: NewProgressBar("Downloading Core dependencies (go mod download)"), - Name: "Download Core dependencies", - }) - } + fmt.Println("\nπŸ“₯ Installing dependencies concurrently...") + setup.printDependencyWarnings(RunConcurrent(tasks)) +} - if len(depTasks) > 0 { - fmt.Println("\nπŸ“₯ Installing dependencies concurrently...") - results := RunConcurrent(depTasks) - - // Show warnings for failed dependencies but don't fail - for _, result := range results { - if result.Error != nil { - if result.Name == "Install UI dependencies" { - fmt.Printf("⚠️ warning: failed to install ui dependencies: %v\n", result.Error) - fmt.Printf(" you can install them manually with: cd %s && npm install\n", uiPath) - } else if result.Name == "Download Core dependencies" { - fmt.Printf("⚠️ warning: failed to download core dependencies: %v\n", result.Error) - fmt.Printf(" you can download them manually with: cd %s && go mod download\n", corePath) - } - } - } +func (setup *developmentSetup) printDependencyWarnings(results []TaskResult) { + for _, result := range results { + if result.Error == nil { + continue + } + switch result.Name { + case "Install UI dependencies": + fmt.Printf("⚠️ warning: failed to install ui dependencies: %v\n", result.Error) + fmt.Printf(" you can install them manually with: cd %s && npm install\n", setup.uiPath) + case "Download Core dependencies": + fmt.Printf("⚠️ warning: failed to download core dependencies: %v\n", result.Error) + fmt.Printf(" you can download them manually with: cd %s && go mod download\n", setup.corePath) } } +} - // Generate config files with sensible defaults - if cloneCore { - configPath := filepath.Join(corePath, "config.yaml") - if err := writeConfigYAML(configPath); err != nil { - fmt.Printf("⚠️ warning: failed to generate config.yaml: %v\n", err) - } else { - fmt.Println("βœ… Generated core/config.yaml with default values") - } +func (setup *developmentSetup) generateConfigFiles() { + if setup.hasCore() { + generateCoreConfig(setup.corePath) + } + if setup.hasUI() { + generateUIConfig(setup.uiPath) } +} - if cloneUI { - envPath := filepath.Join(uiPath, ".env.local") - if err := writeEnvLocal(envPath); err != nil { - fmt.Printf("⚠️ warning: failed to generate .env.local: %v\n", err) - } else { - fmt.Println("βœ… Generated ui/.env.local with default API URL") - } +func generateCoreConfig(corePath string) { + configPath := filepath.Join(corePath, "config.yaml") + _, statErr := os.Stat(configPath) + configExists := statErr == nil + if err := writeConfigYAML(configPath); err != nil { + fmt.Printf("⚠️ warning: failed to generate config.yaml: %v\n", err) + } else if configExists { + fmt.Println("βœ… Using existing core/config.yaml") + } else { + fmt.Println("βœ… Generated core/config.yaml with default values") } +} - // Save project configuration - if err := setProjectConfig(cwd, uiPath, corePath); err != nil { - fmt.Printf("⚠️ warning: failed to save project configuration: %v\n", err) +func generateUIConfig(uiPath string) { + envPath := filepath.Join(uiPath, ".env.local") + _, statErr := os.Stat(envPath) + envExists := statErr == nil + if err := writeEnvLocal(envPath); err != nil { + fmt.Printf("⚠️ warning: failed to generate .env.local: %v\n", err) + } else if envExists { + fmt.Println("βœ… Using existing ui/.env.local") + } else { + fmt.Println("βœ… Generated ui/.env.local with default API URL") } +} +func (setup *developmentSetup) printSummary() { fmt.Println("\nβœ… Development environment ready!") - fmt.Printf("πŸ“ Project initialized at: %s\n", cwd) + fmt.Printf("πŸ“ Project initialized at: %s\n", setup.projectPath) fmt.Println("\nπŸ“ Next steps:") switch { - case cloneUI && cloneCore: + case setup.hasUI() && setup.hasCore(): fmt.Println(" 1. Run 'orchcli start' to start both UI and Core locally") - case cloneUI: + case setup.hasUI(): fmt.Println(" 1. Run 'orchcli start' to start UI locally with Core from Docker") - case cloneCore: + case setup.hasCore(): fmt.Println(" 1. Run 'orchcli start' to start Core locally with UI from Docker") } + fmt.Println(" 2. Make your changes in the source repositories") + fmt.Println(" 3. UI changes hot-reload; restart a host Core process after Core changes") - fmt.Println(" 2. Make your changes in the cloned repositories") - fmt.Println(" 3. Changes will hot-reload automatically") - - usingForks := (forkUI != "" && forkUI != defaultUIRepo) || - (forkCore != "" && forkCore != defaultCoreRepo) - - if usingForks { + if setup.uiIsFork || setup.coreIsFork { fmt.Println("\n🍴 Fork workflow detected (External Contributor):") fmt.Println(" 1. Create a feature branch: git checkout -b feature/my-feature") fmt.Println(" 2. Push to your fork: git push origin feature/my-feature") fmt.Println(" 3. Create a pull request on GitHub") - } else if cloneUI || cloneCore { + } else if setup.hasUI() || setup.hasCore() { fmt.Println("\nπŸ‘₯ Official repo workflow (Team Member):") fmt.Println(" 1. Create a feature branch or work on main") fmt.Println(" 2. Push directly: git push origin ") } +} - return nil +func resolveExistingCheckout(projectPath, sourcePath, component, markerFile string) (string, error) { + if strings.TrimSpace(sourcePath) == "" { + return "", fmt.Errorf("--%s-path requires a directory", strings.ToLower(component)) + } + if !filepath.IsAbs(sourcePath) { + sourcePath = filepath.Join(projectPath, sourcePath) + } + absPath, err := filepath.Abs(sourcePath) + if err != nil { + return "", fmt.Errorf("failed to resolve %s checkout %q: %w", component, sourcePath, err) + } + absPath = filepath.Clean(absPath) + if !dirExists(absPath) { + return "", fmt.Errorf("%s checkout does not exist or is not a directory: %s", component, absPath) + } + info, statErr := os.Stat(filepath.Join(absPath, markerFile)) + if statErr != nil || info.IsDir() { + return "", fmt.Errorf("%s checkout at %s is missing %s", component, absPath, markerFile) + } + return absPath, nil } func determineRepoURL(repoName, defaultRepo string) (string, bool) { @@ -333,7 +450,16 @@ func validateRepoFormat(repo string) error { return nil } -func checkPrerequisites() error { +func checkPrerequisites(requireGit bool) error { + if requireGit { + if err := ensureGit(); err != nil { + return err + } + } + return validateDockerCompose() +} + +func ensureGit() error { if err := checkCommand("git", "--version"); err != nil { if autoInstall { fmt.Println("⚠️ git not found. installing git...") @@ -346,10 +472,6 @@ func checkPrerequisites() error { } } - if err := validateDockerCompose(); err != nil { - return err - } - return nil } diff --git a/cmd/logs.go b/cmd/logs.go index 2e9acdf..393d32c 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -19,7 +19,7 @@ var ( var logsCmd = &cobra.Command{ Use: "logs [service]", Short: "View logs from KubeOrch services", - Long: `View logs from running KubeOrch services. Optionally specify a service name (ui, core, postgres)`, + Long: `View logs from running KubeOrch services. Optionally specify a service name (ui, core, mongodb)`, RunE: runLogs, } @@ -27,22 +27,22 @@ func init() { logsCmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow log output") logsCmd.Flags().StringVar(&tailLines, "tail", "100", "number of lines to show from the end of logs") logsCmd.Flags().BoolVarP(×tamps, "timestamps", "t", false, "show timestamps") - logsCmd.Flags().StringVar(&service, "service", "", "specific service to show logs for (ui, core, postgres)") + logsCmd.Flags().StringVar(&service, "service", "", "specific service to show logs for (ui, core, mongodb)") rootCmd.AddCommand(logsCmd) } func runLogs(cmd *cobra.Command, args []string) error { - if err := validateDockerCompose(); err != nil { - return err - } - projectConfig, err := getCurrentProjectConfig() if err != nil { - return fmt.Errorf("no project initialized in current directory. Run 'orchcli init' first") + return fmt.Errorf("failed to resolve KubeOrch project: %w", err) + } + + if err := validateDockerCompose(); err != nil { + return err } - uiLocal := projectConfig.UIPath != "" && dirExists(projectConfig.UIPath) - coreLocal := projectConfig.CorePath != "" && dirExists(projectConfig.CorePath) + uiLocal := projectConfig.UIPath != "" + coreLocal := projectConfig.CorePath != "" composeFile := getComposeFile(uiLocal, coreLocal) composeFile = filepath.Join(projectConfig.Path, composeFile) diff --git a/cmd/restart.go b/cmd/restart.go index 74de080..1ef39ca 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -12,7 +12,7 @@ import ( var restartCmd = &cobra.Command{ Use: "restart [service]", Short: "Restart KubeOrch services", - Long: `Restart KubeOrch services. Optionally specify a service name (ui, core, postgres)`, + Long: `Restart KubeOrch services. Optionally specify a service name (ui, core, mongodb)`, RunE: runRestart, } @@ -21,17 +21,17 @@ func init() { } func runRestart(cmd *cobra.Command, args []string) error { - if err := validateDockerCompose(); err != nil { - return err - } - projectConfig, err := getCurrentProjectConfig() if err != nil { - return fmt.Errorf("no project initialized in current directory. Run 'orchcli init' first") + return fmt.Errorf("failed to resolve KubeOrch project: %w", err) + } + + if err := validateDockerCompose(); err != nil { + return err } - uiLocal := projectConfig.UIPath != "" && dirExists(projectConfig.UIPath) - coreLocal := projectConfig.CorePath != "" && dirExists(projectConfig.CorePath) + uiLocal := projectConfig.UIPath != "" + coreLocal := projectConfig.CorePath != "" fmt.Println("πŸ”„ restarting kubeorchestra services...") diff --git a/cmd/root.go b/cmd/root.go index 47f448d..888d293 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,9 +20,9 @@ var rootCmd = &cobra.Command{ It helps developers: - Clone and setup UI/Core repositories for development -- Run local development environment with hot reload +- Run UI with hot reload and Core directly on the host - Handle fork-based contributions for external developers -- Quick production environment setup with latest images`, +- Quick production environment setup with pinned images`, Version: fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, buildDate), } diff --git a/cmd/runtime_contract_test.go b/cmd/runtime_contract_test.go new file mode 100644 index 0000000..86ffe4f --- /dev/null +++ b/cmd/runtime_contract_test.go @@ -0,0 +1,311 @@ +package cmd + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const ( + mockDockerProcessEnv = "KUBEORCH_TEST_DOCKER_PROCESS" + testExecutablePerm = 0750 +) + +func TestMain(m *testing.M) { + if os.Getenv(mockDockerProcessEnv) == "1" { + os.Exit(0) + } + os.Exit(m.Run()) +} + +func useMockDocker(t *testing.T) { + t.Helper() + mockDir := t.TempDir() + mockPath := filepath.Join(mockDir, "docker") + if runtime.GOOS == "windows" { + mockPath += ".exe" + } + testExecutable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + executableData, err := os.ReadFile(testExecutable) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mockPath, executableData, testExecutablePerm); err != nil { + t.Fatal(err) + } + + t.Setenv(mockDockerProcessEnv, "1") + t.Setenv("PATH", mockDir) +} + +func TestProjectMarkerDiscoveryFromNestedDirectory(t *testing.T) { + projectPath := t.TempDir() + uiPath := filepath.Join(projectPath, "ui") + corePath := filepath.Join(projectPath, "core") + for _, path := range []string{uiPath, corePath, filepath.Join(uiPath, "app", "dashboard")} { + if err := os.MkdirAll(path, dirPerm); err != nil { + t.Fatal(err) + } + } + + project := &ProjectConfig{ + Path: projectPath, + UIPath: uiPath, + CorePath: corePath, + Mode: "development", + } + if err := writeProjectMarker(project); err != nil { + t.Fatal(err) + } + + loaded, err := loadProjectMarker(filepath.Join(uiPath, "app", "dashboard")) + if err != nil { + t.Fatal(err) + } + if loaded.Path != projectPath || loaded.UIPath != uiPath || loaded.CorePath != corePath { + t.Fatalf("unexpected project config: %#v", loaded) + } + + data, err := os.ReadFile(filepath.Join(projectPath, projectMarkerDir, projectMarkerFilename)) + if err != nil { + t.Fatal(err) + } + var marker projectMarker + if err := json.Unmarshal(data, &marker); err != nil { + t.Fatal(err) + } + if marker.UIPath != "ui" || marker.CorePath != "core" { + t.Fatalf("expected portable paths, got UI=%q Core=%q", marker.UIPath, marker.CorePath) + } +} + +func TestProjectMarkerErrorsAreActionable(t *testing.T) { + t.Run("invalid JSON", func(t *testing.T) { + projectPath := t.TempDir() + markerDir := filepath.Join(projectPath, projectMarkerDir) + if err := os.MkdirAll(markerDir, dirPerm); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(markerDir, projectMarkerFilename), []byte("{"), configFilePerm); err != nil { + t.Fatal(err) + } + _, err := loadProjectMarker(projectPath) + if err == nil || !strings.Contains(err.Error(), "invalid project marker") || !strings.Contains(err.Error(), "orchcli init") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("unsupported version", func(t *testing.T) { + projectPath := t.TempDir() + markerDir := filepath.Join(projectPath, projectMarkerDir) + if err := os.MkdirAll(markerDir, dirPerm); err != nil { + t.Fatal(err) + } + data := []byte(`{"version":99,"mode":"production"}`) + if err := os.WriteFile(filepath.Join(markerDir, projectMarkerFilename), data, configFilePerm); err != nil { + t.Fatal(err) + } + _, err := loadProjectMarker(projectPath) + if err == nil || !strings.Contains(err.Error(), "unsupported project marker version 99") { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestAtomicWritePreservesTargetBeforeRenameFailure(t *testing.T) { + targetPath := filepath.Join(t.TempDir(), projectMarkerFilename) + original := []byte(`{"version":1,"mode":"production"}`) + if err := os.WriteFile(targetPath, original, configFilePerm); err != nil { + t.Fatal(err) + } + + expectedErr := errors.New("simulated interruption") + err := writeFileAtomicallyWithHook( + targetPath, + []byte(`{"version":1,"ui_path":"ui","mode":"ui-dev"}`), + configFilePerm, + func(string) error { return expectedErr }, + ) + if !errors.Is(err, expectedErr) { + t.Fatalf("expected simulated interruption, got %v", err) + } + + actual, err := os.ReadFile(targetPath) + if err != nil { + t.Fatal(err) + } + if string(actual) != string(original) { + t.Fatalf("target changed after failed replacement: %q", actual) + } + + tempFiles, err := filepath.Glob(filepath.Join(filepath.Dir(targetPath), ".project.json-*.tmp")) + if err != nil { + t.Fatal(err) + } + if len(tempFiles) != 0 { + t.Fatalf("temporary files were not cleaned up: %v", tempFiles) + } +} + +func TestExistingCheckoutPrerequisitesDoNotRequireGit(t *testing.T) { + useMockDocker(t) + previousAutoInstall := autoInstall + autoInstall = false + defer func() { autoInstall = previousAutoInstall }() + + if err := checkPrerequisites(false); err != nil { + t.Fatalf("existing-checkout prerequisites unexpectedly required Git: %v", err) + } +} + +func TestFlaglessInitFromNestedDirectoryPreservesProjectRoot(t *testing.T) { + projectPath := t.TempDir() + uiPath := filepath.Join(projectPath, "ui") + corePath := filepath.Join(projectPath, "core") + nestedPath := filepath.Join(corePath, "handlers") + for _, path := range []string{uiPath, corePath, nestedPath} { + if err := os.MkdirAll(path, dirPerm); err != nil { + t.Fatal(err) + } + } + if err := setProjectConfig(projectPath, uiPath, corePath); err != nil { + t.Fatal(err) + } + useMockDocker(t) + previousDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if chdirErr := os.Chdir(nestedPath); chdirErr != nil { + t.Fatal(chdirErr) + } + t.Cleanup(func() { _ = os.Chdir(previousDir) }) + if setupErr := setupProduction(); setupErr != nil { + t.Fatal(setupErr) + } + + loaded, err := loadProjectMarker(projectPath) + if err != nil { + t.Fatal(err) + } + if loaded.Mode != "development" || loaded.UIPath != uiPath || loaded.CorePath != corePath { + t.Fatalf("flagless init changed the development marker: %#v", loaded) + } + if _, statErr := os.Stat(filepath.Join(nestedPath, projectMarkerDir, projectMarkerFilename)); !os.IsNotExist(statErr) { + t.Fatalf("flagless init created a nested project marker: %v", statErr) + } +} + +func TestFlaglessInitDoesNotOverwriteInvalidMarker(t *testing.T) { + projectPath := t.TempDir() + markerDir := filepath.Join(projectPath, projectMarkerDir) + if err := os.MkdirAll(markerDir, dirPerm); err != nil { + t.Fatal(err) + } + markerPath := filepath.Join(markerDir, projectMarkerFilename) + original := []byte("{invalid") + if err := os.WriteFile(markerPath, original, configFilePerm); err != nil { + t.Fatal(err) + } + useMockDocker(t) + + previousDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if chdirErr := os.Chdir(projectPath); chdirErr != nil { + t.Fatal(chdirErr) + } + t.Cleanup(func() { _ = os.Chdir(previousDir) }) + setupErr := setupProduction() + if setupErr == nil { + t.Fatal("expected invalid project marker to block initialization") + } + if !strings.Contains(setupErr.Error(), "invalid project marker") { + t.Fatalf("expected an invalid marker error, got %v", setupErr) + } + + actual, err := os.ReadFile(markerPath) + if err != nil { + t.Fatal(err) + } + if string(actual) != string(original) { + t.Fatalf("invalid marker was overwritten: %q", actual) + } +} + +func TestResolveExistingCheckout(t *testing.T) { + projectPath := t.TempDir() + uiPath := filepath.Join(projectPath, "ui") + if err := os.MkdirAll(uiPath, dirPerm); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(uiPath, "package.json"), []byte("{}"), composeFilePerm); err != nil { + t.Fatal(err) + } + + resolved, err := resolveExistingCheckout(projectPath, "ui", "UI", "package.json") + if err != nil { + t.Fatal(err) + } + if resolved != uiPath { + t.Fatalf("expected %s, got %s", uiPath, resolved) + } + + if _, err := resolveExistingCheckout(projectPath, "missing", "UI", "package.json"); err == nil { + t.Fatal("expected a missing checkout error") + } +} + +func TestEmbeddedComposeContract(t *testing.T) { + files := []string{ + "docker-compose.dev.yml", + "docker-compose.prod.yml", + "docker-compose.hybrid-ui.yml", + "docker-compose.hybrid-core.yml", + } + for _, name := range files { + t.Run(name, func(t *testing.T) { + embedded, err := embeddedComposeFiles.ReadFile("docker/" + name) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(embedded), "version: '3.8'") { + t.Fatal("obsolete Compose version declaration is present") + } + if strings.Contains(string(embedded), ":latest") { + t.Fatal("runtime image is not pinned") + } + + shipped, err := os.ReadFile(filepath.Join("..", "docker", name)) + if err != nil { + t.Fatal(err) + } + if string(embedded) != string(shipped) { + t.Fatal("embedded and repository Compose files differ") + } + }) + } + + prod, err := embeddedComposeFiles.ReadFile("docker/docker-compose.prod.yml") + if err != nil { + t.Fatal(err) + } + content := string(prod) + for _, expected := range []string{ + "KUBEORCH_MONGO_URI: mongodb://mongodb:27017/kubeorchestra", + `- "3001:3000"`, + "NEXT_PUBLIC_API_URL: http://localhost:3000/v1/api", + } { + if !strings.Contains(content, expected) { + t.Fatalf("production Compose is missing %q", expected) + } + } +} diff --git a/cmd/start.go b/cmd/start.go index b97ed6c..ef55b30 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" "github.com/spf13/cobra" ) @@ -20,8 +21,8 @@ var startCmd = &cobra.Command{ Long: `Start KubeOrch services based on your initialization: - If no repos cloned: runs from Docker images - If UI cloned: runs UI locally with hot reload, Core from image -- If Core cloned: runs Core locally with hot reload, UI from image -- If both cloned: runs both locally with hot reload`, +- If Core cloned: runs Core locally on the host, UI from image +- If both cloned: runs UI and Core locally; UI hot-reloads and Core requires restarts`, RunE: runStart, } @@ -31,17 +32,17 @@ func init() { } func runStart(cmd *cobra.Command, args []string) error { - if err := validateDockerCompose(); err != nil { - return err - } - projectConfig, err := getCurrentProjectConfig() if err != nil { - return fmt.Errorf("no project initialized in current directory. Run 'orchcli init' first") + return fmt.Errorf("failed to resolve KubeOrch project: %w", err) } - uiLocal := projectConfig.UIPath != "" && dirExists(projectConfig.UIPath) - coreLocal := projectConfig.CorePath != "" && dirExists(projectConfig.CorePath) + if err := validateDockerCompose(); err != nil { + return err + } + + uiLocal := projectConfig.UIPath != "" + coreLocal := projectConfig.CorePath != "" fmt.Println("πŸš€ starting kubeorchestra services...") @@ -106,10 +107,10 @@ func runStart(cmd *cobra.Command, args []string) error { switch { case uiLocal && coreLocal: fmt.Println("πŸ“ next steps for development:") - fmt.Printf(" 1. start core: cd %s && air\n", projectConfig.CorePath) + fmt.Printf(" 1. start core: cd %s && go run .\n", projectConfig.CorePath) fmt.Printf(" 2. start ui: cd %s && npm run dev\n", projectConfig.UIPath) fmt.Println() - fmt.Println(" core will run on http://localhost:3000") + fmt.Println(" core API will run on http://localhost:3000/v1/api") fmt.Println(" ui will run on http://localhost:3001") fmt.Println(" mongodb is at localhost:27017") case uiLocal: @@ -117,22 +118,19 @@ func runStart(cmd *cobra.Command, args []string) error { fmt.Printf(" start ui: cd %s && npm run dev\n", projectConfig.UIPath) fmt.Println() fmt.Println(" ui will run on http://localhost:3001") - fmt.Println(" core api is at http://localhost:3000 (docker)") + fmt.Println(" core api is at http://localhost:3000/v1/api (docker)") fmt.Println(" mongodb is at localhost:27017 (docker)") case coreLocal: - fmt.Println("πŸ“ backend development mode:") - fmt.Println(" βœ… core is running in docker with your code mounted") - fmt.Println(" βœ… hot reload enabled - just edit your files") + fmt.Println("πŸ“ next steps for core development:") + fmt.Printf(" start core: cd %s && go run .\n", projectConfig.CorePath) fmt.Println() - fmt.Println(" core api: http://localhost:3000 (docker with mounted code)") + fmt.Println(" core api: http://localhost:3000/v1/api (host)") fmt.Println(" ui: http://localhost:3001 (docker)") fmt.Println(" mongodb: localhost:27017 (docker)") - fmt.Println() - fmt.Println(" note: no go installation required!") default: fmt.Println("πŸ“Š all services running in docker:") fmt.Println(" ui: http://localhost:3001") - fmt.Println(" api: http://localhost:3000") + fmt.Println(" api: http://localhost:3000/v1/api") fmt.Println(" mongodb: localhost:27017") } @@ -162,7 +160,7 @@ func waitForMongoDB() error { } } - _ = exec.Command("sleep", "1").Run() + time.Sleep(time.Second) } return fmt.Errorf("mongodb did not become ready in 30 seconds") diff --git a/cmd/status.go b/cmd/status.go index a049711..58f65be 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -1,11 +1,14 @@ package cmd import ( + "context" "fmt" + "net/http" "os" "os/exec" "path/filepath" "strings" + "time" "github.com/spf13/cobra" ) @@ -22,17 +25,18 @@ func init() { } func runStatus(cmd *cobra.Command, args []string) error { - if err := validateDockerCompose(); err != nil { - return err - } - projectConfig, err := getCurrentProjectConfig() if err != nil { - return fmt.Errorf("no project initialized in current directory. Run 'orchcli init' first") + return fmt.Errorf("failed to resolve KubeOrch project: %w", err) + } + + if validationErr := validateDockerCompose(); validationErr != nil { + return validationErr } + commandContext := cmd.Context() - uiLocal := projectConfig.UIPath != "" && dirExists(projectConfig.UIPath) - coreLocal := projectConfig.CorePath != "" && dirExists(projectConfig.CorePath) + uiLocal := projectConfig.UIPath != "" + coreLocal := projectConfig.CorePath != "" composeFile := getComposeFile(uiLocal, coreLocal) composeFile = filepath.Join(projectConfig.Path, composeFile) @@ -50,7 +54,8 @@ func runStatus(cmd *cobra.Command, args []string) error { psArgs := make([]string, 0, len(dockerCompose)+additionalArgs) psArgs = append(psArgs, dockerCompose...) psArgs = append(psArgs, "-f", composeFile, "ps") - psCmd := exec.Command(psArgs[0], psArgs[1:]...) + // #nosec G204 -- the executable is selected from hardcoded Docker Compose command names. + psCmd := exec.CommandContext(commandContext, psArgs[0], psArgs[1:]...) psCmd.Dir = projectConfig.Path psOutput, err := psCmd.Output() if err != nil { @@ -61,11 +66,28 @@ func runStatus(cmd *cobra.Command, args []string) error { fmt.Println(string(psOutput)) fmt.Println("πŸ’Ύ database status:") - dbCheckCmd := exec.Command("docker", "exec", "kubeorchestra-mongodb", "mongosh", "--eval", "db.adminCommand('ping')") + dbCheckCmd := exec.CommandContext( + commandContext, + "docker", + "exec", + "kubeorchestra-mongodb", + "mongosh", + "--eval", + "db.adminCommand('ping')", + ) dbOutput, dbErr := dbCheckCmd.Output() if dbErr != nil { for _, name := range []string{"kubeorchestra-mongodb-dev", "kubeorchestra-mongodb-hybrid"} { - altCmd := exec.Command("docker", "exec", name, "mongosh", "--eval", "db.adminCommand('ping')") + // #nosec G204 -- name is selected from the hardcoded container names above. + altCmd := exec.CommandContext( + commandContext, + "docker", + "exec", + name, + "mongosh", + "--eval", + "db.adminCommand('ping')", + ) if output, err := altCmd.Output(); err == nil { dbOutput = output dbErr = nil @@ -85,10 +107,14 @@ func runStatus(cmd *cobra.Command, args []string) error { } } + fmt.Println() + fmt.Println("🩺 application status:") + printApplicationStatus(commandContext) + fmt.Println() fmt.Println("🌐 service endpoints:") fmt.Println(" ui: http://localhost:3001") - fmt.Println(" api: http://localhost:3000") + fmt.Println(" api: http://localhost:3000/v1/api") fmt.Println(" mongodb: localhost:27017") fmt.Println() @@ -99,3 +125,47 @@ func runStatus(cmd *cobra.Command, args []string) error { return nil } + +func printApplicationStatus(ctx context.Context) { + checks := []struct { + name string + url string + }{ + {name: "core", url: "http://localhost:3000/v1/"}, + {name: "ui", url: "http://localhost:3001/"}, + } + type healthResult struct { + err error + name string + statusCode int + } + results := make(chan healthResult, len(checks)) + client := &http.Client{Timeout: 2 * time.Second} + for _, check := range checks { + go func(name, url string) { + request, requestErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if requestErr != nil { + results <- healthResult{name: name, err: requestErr} + return + } + response, requestErr := client.Do(request) + if requestErr != nil { + results <- healthResult{name: name, err: requestErr} + return + } + defer response.Body.Close() + results <- healthResult{name: name, statusCode: response.StatusCode} + }(check.name, check.url) + } + for range checks { + result := <-results + switch { + case result.err != nil: + fmt.Printf(" ❌ %s is not reachable: %v\n", result.name, result.err) + case result.statusCode >= http.StatusOK && result.statusCode < http.StatusBadRequest: + fmt.Printf(" βœ… %s is healthy (HTTP %d)\n", result.name, result.statusCode) + default: + fmt.Printf(" ⚠️ %s returned HTTP %d\n", result.name, result.statusCode) + } + } +} diff --git a/cmd/stop.go b/cmd/stop.go index ae6fa40..677d82b 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -26,17 +26,17 @@ func init() { } func runStop(cmd *cobra.Command, args []string) error { - if err := validateDockerCompose(); err != nil { - return err - } - projectConfig, err := getCurrentProjectConfig() if err != nil { - return fmt.Errorf("no project initialized in current directory. Run 'orchcli init' first") + return fmt.Errorf("failed to resolve KubeOrch project: %w", err) + } + + if err := validateDockerCompose(); err != nil { + return err } - uiLocal := projectConfig.UIPath != "" && dirExists(projectConfig.UIPath) - coreLocal := projectConfig.CorePath != "" && dirExists(projectConfig.CorePath) + uiLocal := projectConfig.UIPath != "" + coreLocal := projectConfig.CorePath != "" fmt.Println("πŸ›‘ stopping kubeorchestra services...") diff --git a/cmd/testing.go b/cmd/testing.go index 797e9e3..ee75bb7 100644 --- a/cmd/testing.go +++ b/cmd/testing.go @@ -56,6 +56,8 @@ func ResetCommands() { // Re-initialize flags initCmd.Flags().StringVar(&forkUI, "fork-ui", "", "Clone UI repository") initCmd.Flags().StringVar(&forkCore, "fork-core", "", "Clone Core repository") + initCmd.Flags().StringVar(&existingUIPath, "ui-path", "", "Use an existing UI checkout") + initCmd.Flags().StringVar(&existingCorePath, "core-path", "", "Use an existing Core checkout") initCmd.Flags().BoolVar(&skipDeps, "skip-deps", false, "Skip dependency installation") initCmd.Flags().BoolVar(&autoInstall, "auto-install", true, "Automatically install missing dependencies") diff --git a/cmd/utils.go b/cmd/utils.go index fe7d095..682c6f4 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -1,13 +1,16 @@ package cmd import ( + "context" "crypto/rand" "encoding/hex" "fmt" "os" "os/exec" "path/filepath" + "runtime" "strings" + "time" ) const ( @@ -18,7 +21,9 @@ const ( // composeFilePerm allows group/world read for docker-compose files (no secrets). composeFilePerm os.FileMode = 0644 // secretKeyBytes is the number of random bytes used for JWT/encryption keys. - secretKeyBytes = 32 + secretKeyBytes = 32 + dockerStartupTimeout = time.Minute + dockerPollInterval = time.Second ) // writeEmbeddedComposeFiles extracts all docker-compose files from the @@ -271,6 +276,17 @@ func getDockerComposeCommand() []string { } func startDockerDaemon() error { + if runtime.GOOS == "windows" { + fmt.Println(" starting docker desktop...") + startCmd := exec.Command("docker", "desktop", "start") + startCmd.Stdout = os.Stdout + startCmd.Stderr = os.Stderr + if err := startCmd.Run(); err != nil { + return fmt.Errorf("failed to start docker desktop: %w", err) + } + return waitForDockerDaemon(dockerStartupTimeout) + } + if err := checkCommand("systemctl", "--version"); err == nil { fmt.Println(" starting docker with systemctl...") startCmd := exec.Command("systemctl", "start", "docker") @@ -295,16 +311,28 @@ func startDockerDaemon() error { if _, err := exec.LookPath("open"); err == nil { fmt.Println(" opening docker desktop...") if err := exec.Command("open", "-a", "Docker").Run(); err == nil { - fmt.Println(" waiting for docker to start...") - for i := 0; i < 30; i++ { - if err := checkCommand("docker", "info"); err == nil { - return nil - } - _ = exec.Command("sleep", "1").Run() - } - return fmt.Errorf("docker desktop did not start in time") + return waitForDockerDaemon(dockerStartupTimeout) } } return fmt.Errorf("unable to start docker daemon automatically") } + +func waitForDockerDaemon(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + fmt.Println(" waiting for docker to start...") + ticker := time.NewTicker(dockerPollInterval) + defer ticker.Stop() + for { + if err := exec.CommandContext(ctx, "docker", "info").Run(); err == nil { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("docker desktop did not start within %s", timeout) + case <-ticker.C: + } + } +} diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 17257b5..adcecbb 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -1,11 +1,9 @@ -version: '3.8' - # Development mode - both UI and Core run on host # Only MongoDB runs in Docker services: mongodb: - image: mongo:8.0 + image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a container_name: kubeorchestra-mongodb-dev restart: unless-stopped environment: @@ -30,4 +28,4 @@ volumes: mongodb_data: name: kubeorchestra_mongodb_data_dev mongodb_config: - name: kubeorchestra_mongodb_config_dev \ No newline at end of file + name: kubeorchestra_mongodb_config_dev diff --git a/docker/docker-compose.hybrid-core.yml b/docker/docker-compose.hybrid-core.yml index 8c6986b..dcc38d3 100644 --- a/docker/docker-compose.hybrid-core.yml +++ b/docker/docker-compose.hybrid-core.yml @@ -1,7 +1,5 @@ -version: '3.8' - # Hybrid Core mode - Core repo cloned, UI from Docker image -# All services run in Docker for simplicity +# MongoDB and UI run in Docker; Core runs on the host. networks: kubeorchestra-net: @@ -10,7 +8,7 @@ networks: services: mongodb: - image: mongo:8.0 + image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a container_name: kubeorchestra-mongodb-hybrid restart: unless-stopped networks: @@ -29,48 +27,22 @@ services: retries: 5 start_period: 10s - # Core runs in container with mounted code for hot reload - # This way backend devs don't need Go installed locally - core: - image: cosmtrek/air:latest - container_name: kubeorchestra-core-hybrid - restart: unless-stopped - networks: - - kubeorchestra-net - volumes: - - ../core:/app - - go-modules:/go/pkg/mod # Cache Go modules - working_dir: /app - ports: - - "3000:3000" - environment: - MONGO_URI: mongodb://mongodb:27017/kubeorchestra - MONGO_DB_NAME: kubeorchestra - depends_on: - mongodb: - condition: service_healthy - command: air - ui: - image: ghcr.io/kubeorch/ui:latest + image: ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3 container_name: kubeorchestra-ui-hybrid restart: unless-stopped networks: - kubeorchestra-net ports: - - "3001:3001" + - "3001:3000" environment: - # Both use internal Docker network names - NEXT_PUBLIC_API_URL: http://localhost:3000 - API_URL_INTERNAL: http://core:3000 - PORT: 3001 - depends_on: - - core + NEXT_PUBLIC_API_URL: http://localhost:3000/v1/api + +# Core runs on host: cd core && go run . (port 3000) +# Core connects to MongoDB at localhost:27017. volumes: mongodb_data: name: kubeorchestra_mongodb_data_hybrid mongodb_config: name: kubeorchestra_mongodb_config_hybrid - go-modules: - name: kubeorchestra_go_modules \ No newline at end of file diff --git a/docker/docker-compose.hybrid-ui.yml b/docker/docker-compose.hybrid-ui.yml index 3c75969..0072095 100644 --- a/docker/docker-compose.hybrid-ui.yml +++ b/docker/docker-compose.hybrid-ui.yml @@ -1,5 +1,3 @@ -version: '3.8' - # Hybrid UI mode - UI runs on host, Core from Docker image # MongoDB and Core run in Docker @@ -10,7 +8,7 @@ networks: services: mongodb: - image: mongo:8.0 + image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a container_name: kubeorchestra-mongodb-hybrid restart: unless-stopped networks: @@ -30,7 +28,7 @@ services: start_period: 10s core: - image: ghcr.io/kubeorch/core:latest + image: ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e container_name: kubeorchestra-core-hybrid restart: unless-stopped networks: @@ -38,17 +36,16 @@ services: ports: - "3000:3000" environment: - MONGO_URI: mongodb://mongodb:27017/kubeorchestra - MONGO_DB_NAME: kubeorchestra + KUBEORCH_MONGO_URI: mongodb://mongodb:27017/kubeorchestra depends_on: mongodb: condition: service_healthy # UI runs on host: cd ui && npm install && npm run dev (port 3001) -# UI connects to Core at localhost:3000 +# UI connects to Core at localhost:3000/v1/api volumes: mongodb_data: name: kubeorchestra_mongodb_data_hybrid mongodb_config: - name: kubeorchestra_mongodb_config_hybrid \ No newline at end of file + name: kubeorchestra_mongodb_config_hybrid diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index a396f5e..68fb700 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -1,5 +1,3 @@ -version: '3.8' - networks: kubeorchestra-net: driver: bridge @@ -7,7 +5,7 @@ networks: services: mongodb: - image: mongo:8.0 + image: mongo:8.0@sha256:02a0cc7939f5ed38f30f9bc714ef5f682d49baf9350c54acf302ce833087fe8a container_name: kubeorchestra-mongodb restart: unless-stopped networks: @@ -29,7 +27,7 @@ services: start_period: 10s core: - image: ghcr.io/kubeorch/core:latest + image: ghcr.io/kubeorch/core:v0.0.3@sha256:eafafc2187bda39981bc9ae2fe5f80b77d00d26550b985e8a86c184fc8d4452e container_name: kubeorchestra-core restart: unless-stopped networks: @@ -39,25 +37,23 @@ services: expose: - "3000" environment: - MONGO_URI: mongodb://mongodb:27017/kubeorchestra - MONGO_DB_NAME: kubeorchestra + KUBEORCH_MONGO_URI: mongodb://mongodb:27017/kubeorchestra depends_on: mongodb: condition: service_healthy ui: - image: ghcr.io/kubeorch/ui:latest + image: ghcr.io/kubeorch/ui:v0.0.3@sha256:7ae131ccca459c582bfa14287bd53e1c74bd79f3b3015560ab6c45d8686839f3 container_name: kubeorchestra-ui restart: unless-stopped networks: - kubeorchestra-net ports: - - "3001:3001" + - "3001:3000" expose: - - "3001" + - "3000" environment: - NEXT_PUBLIC_API_URL: http://localhost:3000 - API_URL_INTERNAL: http://core:3000 + NEXT_PUBLIC_API_URL: http://localhost:3000/v1/api depends_on: - core @@ -65,4 +61,4 @@ volumes: mongodb_data: name: kubeorchestra_mongodb_data mongodb_config: - name: kubeorchestra_mongodb_config \ No newline at end of file + name: kubeorchestra_mongodb_config diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ac734bf..1cbb3ef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,178 +1,68 @@ -# OrchCLI Architecture - -## Overview - -OrchCLI is designed to provide the optimal development experience for different types of developers working on the KubeOrchestra platform. It intelligently adapts based on what repositories are cloned locally. - -## Core Principles - -1. **Developer-Centric**: Different setups for frontend, backend, and full-stack developers -2. **Minimal Dependencies**: Only install what's necessary for your workflow -3. **Smart Defaults**: Automatically detect and configure based on cloned repos -4. **Hot Reload Everything**: All development modes support hot reload - -## Architecture Modes - -### 1. Production Mode -**When:** No repositories cloned -**Purpose:** Testing with production images - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Docker Network β”‚ -β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”β”‚ -β”‚ β”‚PostgreSQLβ”‚ β”‚ Core β”‚ β”‚ UI β”‚β”‚ -β”‚ β”‚ :5432 │◄─│ :3000 │◄─│ :3001 β”‚β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚ -β”‚ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β–² β–² β–² - β”‚ β”‚ β”‚ - localhost:5432 localhost:3000 localhost:3001 +# OrchCLI Runtime Architecture + +## Runtime Contract + +OrchCLI keeps stateful infrastructure in Docker and runs source checkouts on +the host for a fast edit/reload loop. The standard endpoints are: + +| Service | Host endpoint | Container port | +|---|---|---| +| UI | `http://localhost:3001` | `3000` | +| Core API | `http://localhost:3000/v1/api` | `3000` | +| MongoDB | `mongodb://localhost:27017/kubeorchestra` | `27017` | + +The UI browser API base URL is +`NEXT_PUBLIC_API_URL=http://localhost:3000/v1/api`. A containerized Core uses +`KUBEORCH_MONGO_URI=mongodb://mongodb:27017/kubeorchestra`; a host Core uses the +generated `core/config.yaml` and connects through `localhost:27017`. + +## Modes + +| Compose file | Mode | Docker services | Host services | +|---|---|---|---| +| `docker-compose.prod.yml` | Production | MongoDB, Core, UI | None | +| `docker-compose.dev.yml` | Full development | MongoDB | Core, UI | +| `docker-compose.hybrid-ui.yml` | UI development | MongoDB, Core | UI | +| `docker-compose.hybrid-core.yml` | Core development | MongoDB, UI | Core | + +Full source development looks like this: + +```text +Browser :3001 -> UI (npm run dev) + | + v + Core :3000 (go run .) + | + v + MongoDB :27017 (Docker) ``` -### 2. Full Development Mode -**When:** Both UI and Core repositories cloned -**Purpose:** Full-stack development +Run it with: -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€ Host Machine ────────┐ β”Œβ”€β”€β”€ Docker ───┐ -β”‚ β”‚ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚ -β”‚ β”‚ UI │─────►│ Core β”‚ β”‚ β”‚ β”‚PostgreSQLβ”‚β”‚ -β”‚ β”‚ :3001 β”‚ β”‚ :3000 │─┼──┼►│ :5432 β”‚β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚ -β”‚ npm run dev air β”‚ β”‚ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -### 3. Frontend Development Mode -**When:** Only UI repository cloned -**Purpose:** Frontend development without backend setup - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€ Host Machine ────────┐ β”Œβ”€β”€β”€β”€β”€β”€ Docker Network ──────┐ -β”‚ β”‚ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ UI │───────────┼──┼►│ Core │◄─│Postgresβ”‚ β”‚ -β”‚ β”‚ :3001 β”‚ β”‚ β”‚ β”‚ :3000 β”‚ β”‚ :5432 β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ npm run dev β”‚ β”‚ (image) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -### 4. Backend Development Mode -**When:** Only Core repository cloned -**Purpose:** Backend development without frontend setup +```bash +orchcli init --ui-path ./ui --core-path ./core +orchcli start -d -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ Docker Network ────────────────────┐ -β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚PostgreSQL│◄─│ Core │◄─│ UI β”‚ β”‚ -β”‚ β”‚ :5432 β”‚ β”‚ :3000 β”‚ β”‚ :3001 β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ (mounted volume)β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ (image) β”‚ -β”‚ β–² β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β” - β”‚ Host Machineβ”‚ - β”‚ Core code β”‚ - β”‚ (mounted) β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` +# Terminal 1 +cd core && go run . +# Restart this process after changing Core code -## Key Design Decisions - -### 1. Asymmetric Hybrid Modes - -The hybrid modes are intentionally different: - -- **Frontend Mode**: UI runs on host because frontend developers are comfortable with Node.js/npm -- **Backend Mode**: Core runs in container (with mounted code) so backend developers don't need Go installed - -This asymmetry is a feature, not a bug. It optimizes for each developer's workflow. - -### 2. Network Strategy - -- **Production**: Everything in Docker network -- **Full Dev**: Everything on localhost -- **Frontend Dev**: Mixed (UI on host, rest in Docker) -- **Backend Dev**: Everything in Docker network (simpler than host-to-container networking) - -### 3. Hot Reload Implementation - -- **UI**: Uses Next.js built-in hot reload (`npm run dev`) -- **Core**: Uses Air for Go hot reload -- **Mounted volumes**: Changes on host immediately visible in container - -## Docker Compose Files - -| File | Mode | Services | -|------|------|----------| -| `docker-compose.prod.yml` | Production | All in Docker | -| `docker-compose.dev.yml` | Full Dev | Only PostgreSQL | -| `docker-compose.hybrid-ui.yml` | Frontend Dev | PostgreSQL + Core | -| `docker-compose.hybrid-core.yml` | Backend Dev | All in Docker | - -## Port Mappings - -| Service | Internal Port | Host Port | Notes | -|---------|--------------|-----------|-------| -| PostgreSQL | 5432 | 5432 | Always in Docker | -| Core API | 3000 | 3000 | Host or Docker | -| UI | 3001 | 3001 | Host or Docker | - -## Environment Variables - -### Core Service -- `DB_HOST`: `postgres` (Docker) or `localhost` (host) -- `DB_PORT`: `5432` -- `DB_NAME`: `kubeorchestra` -- `DB_USER`: `kubeorchestra` -- `DB_PASSWORD`: `kubeorchestra` - -### UI Service -- `NEXT_PUBLIC_API_URL`: Browser-accessible API URL -- `API_URL`: Server-side API URL (for SSR) -- `PORT`: `3001` - -## Auto-Installation Flow - -```mermaid -graph TD - A[orchcli init] --> B{Check Git} - B -->|Missing| C[Install Git] - B -->|Present| D{Check Repos} - - D --> E{UI Cloned?} - E -->|Yes| F[Check Node.js] - F -->|Missing| G[Install Node.js] - - D --> H{Core Cloned?} - H -->|Yes| I[Check Go] - I -->|Missing| J[Install Go] - - D --> K[Check Docker] - K -->|Missing| L[Install Docker] - K -->|Present| M[Check Docker Compose] - M -->|Missing| N[Install Docker Compose] +# Terminal 2 +cd ui && npm run dev ``` -## Benefits of This Architecture +## Image Policy -1. **No Unnecessary Dependencies**: Backend devs don't need Node.js, frontend devs don't need Go (in hybrid mode) -2. **Familiar Workflows**: Developers work the way they're used to -3. **Fast Iteration**: Hot reload for all scenarios -4. **Simple Networking**: Avoid complex host-to-container networking where possible -5. **Flexible**: Easy to switch between modes +Generated Compose files use versioned, digest-pinned images. They do not use +floating `latest` tags. MongoDB is multi-arch. The currently published +KubeOrch Core and UI `v0.0.3` images contain AMD64 manifests only; production +and hybrid modes on ARM64 remain dependent on new multi-arch component +releases. Full source development works independently of those release images. -## Future Improvements +## Project Discovery -1. **DevContainers**: Full IDE integration with VS Code DevContainers -2. **Cloud Development**: Support for GitHub Codespaces / Gitpod -3. **Multi-tenant**: Support multiple projects simultaneously -4. **Custom Networks**: Allow custom network configurations -5. **Service Mesh**: Optional Istio/Linkerd integration for production-like development \ No newline at end of file +Every project-scoped command resolves the nearest `.kubeorch/project.json` +while walking toward the filesystem root. This makes commands work from nested +UI/Core directories and prevents an unrelated last-used project from being +selected. See [Configuration Management](CONFIGURATION.md) for the schema and +migration behavior. diff --git a/docs/CONCURRENT-OPERATIONS.md b/docs/CONCURRENT-OPERATIONS.md index 0a51d89..d0d3a60 100644 --- a/docs/CONCURRENT-OPERATIONS.md +++ b/docs/CONCURRENT-OPERATIONS.md @@ -2,7 +2,7 @@ ## Overview -OrchCLI implements concurrent task execution to improve performance when running multiple independent operations. This feature significantly reduces waiting time for operations like cloning repositories, pulling Docker images, and running health checks. +OrchCLI implements concurrent task execution to improve performance when running multiple independent operations. This feature reduces waiting time when cloning repositories and installing UI and Core dependencies. ## Architecture @@ -81,17 +81,28 @@ tasks := []Task{ }, }, } -RunConcurrentTasks(tasks) +results := RunConcurrent(tasks) +if err := AggregateErrors(results); err != nil { + return err +} ``` -### Docker Operations +### Dependency Installation -Starting services runs health checks concurrently: +During `orchcli init`, UI and Core dependencies are installed concurrently: ```go tasks := []Task{ - {Name: "Checking PostgreSQL", Action: checkPostgres}, - {Name: "Checking Core API", Action: checkCore}, - {Name: "Checking UI", Action: checkUI}, + {Name: "Install UI dependencies", Action: func() error { + return installUIDependencies(uiPath) + }}, + {Name: "Download Core dependencies", Action: func() error { + return installCoreDependencies(corePath) + }}, +} +for _, result := range RunConcurrent(tasks) { + if result.Error != nil { + fmt.Printf("warning: %s failed: %v\n", result.Name, result.Error) + } } ``` @@ -117,21 +128,27 @@ tasks := []Task{ ### Synchronization ```go -func RunConcurrentTasks(tasks []Task) error { +func RunConcurrent(tasks []Task) []TaskResult { var wg sync.WaitGroup - results := make(chan TaskResult, len(tasks)) + resultChannel := make(chan TaskResult, len(tasks)) for _, task := range tasks { wg.Add(1) go func(t Task) { defer wg.Done() err := t.Action() - results <- TaskResult{Error: err, Name: t.Name} + resultChannel <- TaskResult{Error: err, Name: t.Name} }(task) } - + wg.Wait() - // Process results... + close(resultChannel) + + results := make([]TaskResult, 0, len(tasks)) + for result := range resultChannel { + results = append(results, result) + } + return results } ``` @@ -155,9 +172,8 @@ Total time: 5s 1. **Use for Independent Operations** - Repository cloning - - Docker image pulls - - Health checks - - File downloads + - UI and Core dependency installation + - Other independent file downloads 2. **Avoid for Dependent Operations** - Database migrations @@ -194,4 +210,4 @@ go test ./tests/unit/concurrent_test.go - Task priority and queuing - Detailed progress percentages - Non-terminal output modes -- Task cancellation support \ No newline at end of file +- Task cancellation support diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3c709e3..334f8b1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,148 +1,79 @@ -# OrchCLI Configuration Management +# OrchCLI Project Configuration -## Overview +## Canonical Project Marker -OrchCLI uses a JSON-based configuration system to manage multiple projects and their settings. The configuration supports concurrent access through file locking to prevent data corruption. +`orchcli init` writes `.kubeorch/project.json` in the project root. Lifecycle +commands walk upward from the current working directory and use the nearest +marker, so `orchcli start`, `stop`, `status`, `logs`, `restart`, `exec`, and +`debug` work from the root or any directory below it. -## Configuration Structure - -The configuration is stored in `orchcli-config.json` with the following structure: +Example full-development marker: ```json { - "projects": { - "project-name": { - "path": "/path/to/project", - "ui_path": "/path/to/ui/repo", - "core_path": "/path/to/core/repo", - "mode": "development|production|hybrid-ui|hybrid-core" - } - }, - "current_project": "project-name" + "version": 1, + "ui_path": "ui", + "core_path": "core", + "mode": "development" } ``` -## Configuration Location - -The config file is stored in one of these locations (in order of preference): -1. Same directory as the OrchCLI executable -2. `~/.orchcli/` directory (fallback) - -## Features - -### File Locking - -OrchCLI implements file locking using `github.com/gofrs/flock` to ensure safe concurrent access: -- Prevents race conditions when multiple OrchCLI instances run simultaneously -- Uses a `.lock` file alongside the config file -- Automatically releases locks on completion or error +Paths inside the project are stored relative to the marker. Absolute paths are +supported when a checkout lives outside the project root. -### Project Management +## Initialization -Each project configuration includes: -- **path**: Root directory of the project -- **ui_path**: Path to the UI repository (optional) -- **core_path**: Path to the Core repository (optional) -- **mode**: Development mode based on cloned repositories +Create a production-image project: -### Development Modes - -The mode is automatically determined based on cloned repositories: - -| Cloned Repos | Mode | Description | -|--------------|------|-------------| -| None | `production` | Uses Docker images for all services | -| UI only | `hybrid-ui` | UI runs locally, backend in Docker | -| Core only | `hybrid-core` | Core mounted in Docker, UI from image | -| Both | `development` | Full local development | - -## Configuration API - -### Loading Configuration -```go -config, err := LoadConfig() -``` -- Returns empty config if file doesn't exist -- Automatically initializes empty projects map - -### Saving Configuration -```go -err := SaveConfig(config) +```bash +orchcli init ``` -- Creates config directory if needed (mode 0750) -- Uses file locking for concurrent safety -- Writes formatted JSON with 2-space indentation -### Getting/Setting Current Project -```go -// Get current project -project := GetCurrentProjectConfig() +Clone source repositories: -// Set current project -err := SetCurrentProject(projectName) +```bash +orchcli init --fork-ui --fork-core ``` -### Managing Projects -```go -// Save project configuration -err := SaveProjectConfig(projectName, projectPath, uiPath, corePath) +Adopt existing source repositories without cloning or overwriting them: -// Remove project -err := RemoveProjectConfig(projectName) - -// Get specific project -project := GetProjectConfig(projectName) +```bash +orchcli init --ui-path ./ui --core-path ./core ``` -## Directory Permissions +Use `--skip-deps` when dependencies are already installed. Re-running either +the production or existing-checkout form refreshes generated Compose files and +the marker without overwriting `core/config.yaml` or `ui/.env.local`. -OrchCLI uses secure directory permissions: -- Config directory: `0750` (rwxr-x---) -- Config file: `0644` (rw-r--r--) -- Lock file: Managed by flock library +## Development Modes -## Error Handling +| Source paths | Mode | Docker services | Host services | +|---|---|---|---| +| None | `production` | MongoDB, Core, UI | None | +| UI only | `ui-dev` | MongoDB, Core | UI | +| Core only | `core-dev` | MongoDB, UI | Core | +| UI and Core | `development` | MongoDB | UI and Core | -The configuration system provides detailed error messages for: -- Directory creation failures -- File read/write errors -- JSON parsing issues -- Lock acquisition failures -- Missing project configurations +## Legacy Registry -## Concurrent Access Safety +Versions before the project marker stored `orchcli-config.json` beside the CLI +executable. OrchCLI still reads a matching legacy entry when the current +directory is inside that registered project. It never uses the old +`current_project` value as a fallback for an unrelated directory. Re-run +`orchcli init` in a legacy project to create the canonical marker. -The file locking mechanism ensures: -1. Only one process can write to config at a time -2. Reads wait for writes to complete -3. Automatic cleanup of lock files -4. Graceful handling of stale locks +## Errors -## Best Practices - -1. **Always use the provided API functions** - Don't directly modify the config file -2. **Check for errors** - All config operations can fail and should be handled -3. **Avoid long-running operations while holding config** - Load, modify, and save quickly -4. **Use project-specific configs** - Store project settings within their respective config entries +Marker errors identify the exact file and repair command. OrchCLI rejects +invalid JSON, unsupported marker versions, mode/path mismatches, and configured +source paths that no longer exist instead of silently selecting another mode. ## Testing -The configuration system includes comprehensive tests: -- Unit tests for all config operations -- Concurrent access stress tests -- File permission validation -- Error condition handling - -Run tests with: ```bash -go test ./tests/unit/config_test.go -go test ./tests/unit/config_concurrent_test.go +go test ./... +docker compose -f cmd/docker/docker-compose.dev.yml config --quiet +docker compose -f cmd/docker/docker-compose.prod.yml config --quiet +docker compose -f cmd/docker/docker-compose.hybrid-ui.yml config --quiet +docker compose -f cmd/docker/docker-compose.hybrid-core.yml config --quiet ``` - -## Future Enhancements - -- Migration support for config schema changes -- Backup and restore functionality -- Config validation and schema enforcement -- Environment-specific configurations -- Config encryption for sensitive data \ No newline at end of file diff --git a/tests/unit/cmd_test.go b/tests/unit/cmd_test.go index 580da46..c827ae4 100644 --- a/tests/unit/cmd_test.go +++ b/tests/unit/cmd_test.go @@ -15,8 +15,8 @@ import ( type CommandTestSuite struct { suite.Suite - origDir string - tempDir string + origDir string + tempDir string origPATH string } @@ -96,11 +96,8 @@ func (s *CommandTestSuite) TestInitProductionMode() { assert.DirExists(s.T(), filepath.Join(s.tempDir, "docker")) assert.DirExists(s.T(), filepath.Join(s.tempDir, "scripts")) + assert.FileExists(s.T(), filepath.Join(s.tempDir, ".kubeorch", "project.json")) - config, err := cmd.LoadConfig() - assert.NoError(s.T(), err) - assert.NotNil(s.T(), config.Projects[s.tempDir]) - assert.Equal(s.T(), "production", config.Projects[s.tempDir].Mode) } func (s *CommandTestSuite) TestInitWithInvalidFork() { @@ -119,13 +116,20 @@ func (s *CommandTestSuite) TestStartWithMissingComposeFile() { assert.Error(s.T(), err) errMsg := err.Error() assert.True(s.T(), - strings.Contains(errMsg, "no project initialized") || + strings.Contains(errMsg, "project marker not found") || strings.Contains(errMsg, "compose file") || strings.Contains(errMsg, "docker"), "unexpected error: %s", errMsg) } func (s *CommandTestSuite) TestDebugCommand() { + config := &cmd.OrchConfig{ + Projects: map[string]*cmd.ProjectConfig{ + s.tempDir: {Path: s.tempDir, Mode: "production"}, + }, + } + assert.NoError(s.T(), cmd.SaveConfig(config)) + if runtime.GOOS == "windows" { helpers.CreateMockCommand(s.T(), s.tempDir, "docker", `echo NETWORK ID NAME DRIVER SCOPE`)