From 8007e3ac9a3c229530b77497343bf3cf1daac4c9 Mon Sep 17 00:00:00 2001 From: Harry Dhillon Date: Fri, 17 Oct 2025 23:07:23 -0600 Subject: [PATCH] feat: Add `ork restart` command to restart services and enhance networking support - Introduce `restart` command to stop and recreate services with updated configurations. - Add `GetNetworkID` method to a Docker client for network management. - Improve formatting for command descriptions and examples across CLI commands. Signed-off-by: Harry Dhillon --- internal/cli/down.go | 18 +-- internal/cli/logs.go | 18 +-- internal/cli/ps.go | 14 ++- internal/cli/restart.go | 249 +++++++++++++++++++++++++++++++++++++ internal/cli/root.go | 5 +- internal/cli/up.go | 18 +-- internal/docker/network.go | 7 ++ 7 files changed, 297 insertions(+), 32 deletions(-) create mode 100644 internal/cli/restart.go diff --git a/internal/cli/down.go b/internal/cli/down.go index d9ddebf..8c1d720 100644 --- a/internal/cli/down.go +++ b/internal/cli/down.go @@ -18,14 +18,16 @@ import ( var downCmd = &cobra.Command{ Use: "down [service...]", Short: "Stop services", - Long: `Stop one or more services managed by Ork. - - If no services are specified, stops all services for the current project. - By default, stopped containers are removed to keep your system clean.`, - Example: ` ork down Stop all services in current project - ork down redis Stop specific service - ork down redis postgres Stop multiple services - ork down --keep Stop but keep containers for debugging`, + Long: ` +Stop one or more services managed by Ork. + +If no services are specified, stops all services for the current project. +By default, stopped containers are removed to keep your system clean.`, + Example: ` +ork down Stop all services in current project +ork down redis Stop specific service +ork down redis postgres Stop multiple services +ork down --keep Stop but keep containers for debugging`, Run: func(cmd *cobra.Command, args []string) { // Get flags diff --git a/internal/cli/logs.go b/internal/cli/logs.go index fae594b..fbd6fb7 100644 --- a/internal/cli/logs.go +++ b/internal/cli/logs.go @@ -17,14 +17,16 @@ import ( var logsCmd = &cobra.Command{ Use: "logs ", Short: "View logs from a service", - Long: `View and stream logs from a running service container. - - By default, shows all available logs. Use --tail to limit output, - and --follow to stream logs continuously (like tail -f).`, - Example: ` ork logs api Show all logs for api service - ork logs api --follow Stream logs continuously - ork logs api --tail 100 Show last 100 lines - ork logs api --timestamps Show timestamps in output`, + Long: ` +View and stream logs from a running service container. + +By default, shows all available logs. Use --tail to limit output, +and --follow to stream logs continuously (like tail -f).`, + Example: ` +ork logs api Show all logs for api service +ork logs api --follow Stream logs continuously +ork logs api --tail 100 Show last 100 lines +ork logs api --timestamps Show timestamps in output`, Args: cobra.ExactArgs(1), // Require exactly one service name Run: func(cmd *cobra.Command, args []string) { diff --git a/internal/cli/ps.go b/internal/cli/ps.go index 8a7622d..d37d36b 100644 --- a/internal/cli/ps.go +++ b/internal/cli/ps.go @@ -19,12 +19,14 @@ import ( var psCmd = &cobra.Command{ Use: "ps", Short: "List running services", - Long: `List all services managed by Ork for the current project. - - Shows container status, ports, and other information for all services - defined in your ork.yml configuration file.`, - Example: ` ork ps List all services in current project - ork ps --all Include stopped containers`, + Long: ` +List all services managed by Ork for the current project. + +Shows container status, ports, and other information for all services +defined in your ork.yml configuration file.`, + Example: ` +ork ps List all services in current project +ork ps --all Include stopped containers`, Run: func(cmd *cobra.Command, args []string) { // Get flags diff --git a/internal/cli/restart.go b/internal/cli/restart.go new file mode 100644 index 0000000..a77f162 --- /dev/null +++ b/internal/cli/restart.go @@ -0,0 +1,249 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/ork-cli/ork/internal/config" + "github.com/ork-cli/ork/internal/docker" + "github.com/ork-cli/ork/internal/service" + "github.com/ork-cli/ork/internal/ui" + "github.com/ork-cli/ork/pkg/utils" + "github.com/spf13/cobra" +) + +// ============================================================================ +// Cobra Command Definition +// ============================================================================ + +var restartCmd = &cobra.Command{ + Use: "restart [service...]", + Short: "Restart one or more services", + Long: ` +Restart one or more services by stopping and recreating them. + +This command always re-reads ork.yml and recreates containers with the latest +configuration, picking up changes to: + - Environment variables + - Port mappings + - Docker image + - Commands and entrypoints + - Build configuration (with --force-rebuild) + +Only the specified services are restarted - dependencies are not affected.`, + Example: ` +ork restart api Restart API service +ork restart api frontend Restart multiple services +ork restart api --force-rebuild Rebuild image from source before restarting`, + + Args: cobra.MinimumNArgs(1), // Require at least one service name + Run: func(cmd *cobra.Command, args []string) { + // Get flags + forceRebuild, _ := cmd.Flags().GetBool("force-rebuild") + + if err := runRestart(args, forceRebuild); err != nil { + handleRestartError(err) + return + } + }, +} + +func init() { + // Register the 'restart' command with the root command + rootCmd.AddCommand(restartCmd) + + // Add flags + restartCmd.Flags().Bool("force-rebuild", false, "Force rebuild image even if no changes detected") +} + +// ============================================================================ +// Main Orchestrator +// ============================================================================ + +// runRestart orchestrates the service restart process +func runRestart(serviceNames []string, forceRebuild bool) error { + // Load and validate configuration (fresh read to detect changes) + cfg, err := loadAndValidateConfig() + if err != nil { + return err + } + + // Verify requested services exist + if err := validateServiceNames(serviceNames, cfg); err != nil { + return err + } + + // Create a Docker client + dockerClient, err := createDockerClient() + if err != nil { + return err + } + defer func() { + if closeErr := dockerClient.Close(); closeErr != nil { + ui.Warning(fmt.Sprintf("Failed to close Docker client: %v", closeErr)) + } + }() + + // Get the network ID for the project + ctx := context.Background() + networkID, err := getProjectNetworkID(ctx, dockerClient, cfg.Project) + if err != nil { + // If the network doesn't exist, we'll need to create it when restarting + ui.Warning(fmt.Sprintf("Project network not found, will create during restart: %v", err)) + networkID = "" + } + + // Show restart summary + ui.EmptyLine() + ui.Info(fmt.Sprintf("Project: %s (v%s)", ui.Bold(cfg.Project), cfg.Version)) + ui.Info(fmt.Sprintf("Restarting: %s", ui.Highlight(fmt.Sprintf("%v", serviceNames)))) + ui.EmptyLine() + + // Restart each service + for _, serviceName := range serviceNames { + if err := restartService(ctx, cfg, serviceName, dockerClient, networkID, forceRebuild); err != nil { + return err + } + } + + ui.EmptyLine() + ui.SuccessBox(fmt.Sprintf("Successfully restarted %d service(s)! %s", len(serviceNames), ui.SymbolRocket)) + return nil +} + +// ============================================================================ +// Private Helpers - Service Restart Logic +// ============================================================================ + +// restartService restarts a single service with smart config change detection +func restartService(ctx context.Context, cfg *config.Config, serviceName string, client *docker.Client, networkID string, forceRebuild bool) error { + newServiceCfg := cfg.Services[serviceName] + + // Get the current running container (if any) + containers, err := client.List(ctx, cfg.Project) + if err != nil { + return utils.DockerError( + "restart.list", + "Failed to list containers", + "Try running 'ork doctor' to diagnose issues", + err, + ) + } + + var currentContainer *docker.ContainerInfo + for _, container := range containers { + if container.Labels["ork.service"] == serviceName { + currentContainer = &container + break + } + } + + // If the service is not running, just start it + if currentContainer == nil { + ui.Info(fmt.Sprintf("%s is not running, starting it...", ui.Bold(serviceName))) + return startSingleService(ctx, cfg, serviceName, client, networkID) + } + + // Determine if we need to rebuild the image + needsRebuild := forceRebuild || newServiceCfg.Build != nil + + // Stop the current container + spinner := ui.ShowSpinner(fmt.Sprintf("Stopping %s", ui.Bold(serviceName))) + if err := client.StopAndRemove(ctx, currentContainer.ID); err != nil { + spinner.Error(fmt.Sprintf("Failed to stop %s", serviceName)) + return utils.DockerError( + "restart.stop", + fmt.Sprintf("Failed to stop service %s", serviceName), + "Check if the container is stuck or Docker is unresponsive", + err, + ) + } + spinner.Success(fmt.Sprintf("Stopped %s", ui.Bold(serviceName))) + + // TODO: Handle rebuild if needsRebuild is true (Phase 5 - build from source) + if needsRebuild { + ui.Warning("Build from source not yet implemented, will use image instead") + } + + // Create and start the new container + return startSingleService(ctx, cfg, serviceName, client, networkID) +} + +// startSingleService starts a single service (helper for restart) +func startSingleService(ctx context.Context, cfg *config.Config, serviceName string, client *docker.Client, networkID string) error { + // If we don't have a network ID, create the network + if networkID == "" { + spinner := ui.ShowSpinner("Creating project network...") + var err error + networkID, err = client.CreateNetwork(ctx, cfg.Project) + if err != nil { + spinner.Error("Failed to create network") + return utils.NetworkError( + "restart.network", + "Failed to create project network", + "Check if Docker is running and you have permissions", + err, + ) + } + spinner.Success(fmt.Sprintf("Created network: ork-%s-network", cfg.Project)) + } + + // Create a service instance + svc := service.New(serviceName, cfg.Project, cfg.Services[serviceName]) + + // Start the service + spinner := ui.ShowSpinner(fmt.Sprintf("Starting %s", ui.Bold(serviceName))) + if err := svc.Start(ctx, client, networkID); err != nil { + spinner.Error(fmt.Sprintf("Failed to start %s", serviceName)) + return utils.ServiceError( + "restart.start", + fmt.Sprintf("Failed to start service %s", serviceName), + "Check logs with 'ork logs "+serviceName+"' for details", + err, + ) + } + + containerID := svc.GetContainerID() + if len(containerID) > 12 { + containerID = containerID[:12] + } + spinner.Success(fmt.Sprintf("Started %s %s", ui.Bold(serviceName), ui.Dim(containerID))) + + return nil +} + +// ============================================================================ +// Private Helpers - Network Operations +// ============================================================================ + +// getProjectNetworkID gets the network ID for a project +func getProjectNetworkID(ctx context.Context, client *docker.Client, projectName string) (string, error) { + return client.GetNetworkID(ctx, projectName) +} + +// handleRestartError formats and displays errors with hints +func handleRestartError(err error) { + if orkErr, ok := err.(*utils.OrkError); ok { + // Display structured error with hints + ui.Error(orkErr.Message) + if orkErr.Hint != "" { + ui.Hint(orkErr.Hint) + } + if len(orkErr.Details) > 0 { + ui.EmptyLine() + for _, detail := range orkErr.Details { + ui.List(detail) + } + } + if len(orkErr.Suggestions) > 0 { + ui.EmptyLine() + ui.Info("Did you mean:") + for _, suggestion := range orkErr.Suggestions { + ui.ListItem(ui.SymbolArrow, ui.Highlight(suggestion)) + } + } + } else { + // Fallback for non-Ork errors + ui.Error(fmt.Sprintf("Error: %v", err)) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 2db92ef..b25f87f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -38,9 +38,10 @@ func buildVersionString() string { var rootCmd = &cobra.Command{ Use: "ork", Short: "Ork - Microservices orchestration made easy", - Long: `Ork is a modern microservices orchestration tool that makes Docker Compose not suck. + Long: ` +Ork is a modern microservices orchestration tool that makes Docker Compose not suck. - Run services from anywhere, intelligently manage dependencies, and enjoy beautiful CLI output.`, +Run services from anywhere, intelligently manage dependencies, and enjoy beautiful CLI output.`, Version: version, } diff --git a/internal/cli/up.go b/internal/cli/up.go index 3ac6ae0..5bfb77d 100644 --- a/internal/cli/up.go +++ b/internal/cli/up.go @@ -19,14 +19,16 @@ import ( var upCmd = &cobra.Command{ Use: "up [service...]", Short: "Start services and their dependencies", - Long: `Start one or more services along with their dependencies. - - Ork automatically resolves and starts all required dependencies in the correct order. - For example, if 'frontend' depends on 'api', and 'api' depends on 'postgres', - running 'ork up frontend' will start all three services.`, - Example: ` ork up frontend Start frontend (and its dependencies) - ork up frontend api Start multiple services - ork up --local frontend Build and run from local source`, + Long: ` +Start one or more services along with their dependencies. + +Ork automatically resolves and starts all required dependencies in the correct order. +For example, if 'frontend' depends on 'api', and 'api' depends on 'postgres', +running 'ork up frontend' will start all three services.`, + Example: ` +ork up frontend Start frontend (and its dependencies) +ork up frontend api Start multiple services +ork up --local frontend Build and run from local source`, Args: cobra.MinimumNArgs(1), // Require at least one service name Run: func(cmd *cobra.Command, args []string) { diff --git a/internal/docker/network.go b/internal/docker/network.go index b60e680..d426dae 100644 --- a/internal/docker/network.go +++ b/internal/docker/network.go @@ -54,6 +54,13 @@ func (c *Client) CreateNetwork(ctx context.Context, projectName string) (string, return response.ID, nil } +// GetNetworkID retrieves the network ID for a project +// Returns the network ID if it exists, empty string and error if not found +func (c *Client) GetNetworkID(ctx context.Context, projectName string) (string, error) { + networkName := buildNetworkName(projectName) + return c.findNetworkByName(ctx, networkName) +} + // DeleteNetwork removes a Docker network func (c *Client) DeleteNetwork(ctx context.Context, projectName string) error { networkName := buildNetworkName(projectName)