From 9a25eb696a813bf9fe781a79120b6708ae00e70c Mon Sep 17 00:00:00 2001 From: Igor Ignatyev Date: Tue, 16 Jul 2024 17:24:36 +0300 Subject: [PATCH] Web - Start/stop UX, background option --- client/src/utils/app-urls-resolver.ts | 2 +- files.dev.go | 3 +- plugin.go | 77 ++++++-- process-unix.go | 37 ++++ process-win.go | 35 ++++ process.go | 53 +++++ scripts/prebuild.go | 6 +- server/api.go | 4 - server/server.go | 201 +++++++++++++++---- web-runner.go | 272 ++++++++++++++++++++++++++ 10 files changed, 624 insertions(+), 66 deletions(-) create mode 100644 process-unix.go create mode 100644 process-win.go create mode 100644 process.go create mode 100644 web-runner.go diff --git a/client/src/utils/app-urls-resolver.ts b/client/src/utils/app-urls-resolver.ts index 979222f..0e85e77 100644 --- a/client/src/utils/app-urls-resolver.ts +++ b/client/src/utils/app-urls-resolver.ts @@ -1,6 +1,6 @@ const isProductionMode = import.meta.env.MODE === 'production' const addDefaultPort = (protocol: string) => - protocol === 'https:' ? ':433' : ':80' + protocol === 'https:' ? ':443' : ':80' export function getApiUrl() { let url = import.meta.env.VITE_API_URL diff --git a/files.dev.go b/files.dev.go index 346510e..03a3789 100644 --- a/files.dev.go +++ b/files.dev.go @@ -4,8 +4,9 @@ package web import ( "embed" - "github.com/launchrctl/web/server" "io/fs" + + "github.com/launchrctl/web/server" ) //go:embed swagger-ui/* diff --git a/plugin.go b/plugin.go index b4788b2..42d87d3 100644 --- a/plugin.go +++ b/plugin.go @@ -3,14 +3,20 @@ package web import ( "fmt" + "path/filepath" "github.com/launchrctl/launchr" - "github.com/launchrctl/web/server" "github.com/spf13/cobra" ) -// APIPrefix is a default api prefix on the server. -const APIPrefix = "/api" +const ( + pluginName = "web" + + // APIPrefix is a default api prefix on the server. + APIPrefix = "/api" + + stopArg = "stop" +) func init() { launchr.RegisterPlugin(&Plugin{}) @@ -19,6 +25,7 @@ func init() { // Plugin is launchr plugin providing web ui. type Plugin struct { app launchr.App + cfg launchr.Config } // PluginInfo implements launchr.Plugin interface. @@ -28,41 +35,69 @@ func (p *Plugin) PluginInfo() launchr.PluginInfo { // OnAppInit implements launchr.Plugin interface. func (p *Plugin) OnAppInit(app launchr.App) error { + app.GetService(&p.cfg) + p.app = app return nil } +type webFlags struct { + Port int + ProxyClient string + UseSwaggerUI bool + RunInfoDir string +} + // CobraAddCommands implements launchr.CobraPlugin interface to provide web functionality. func (p *Plugin) CobraAddCommands(rootCmd *cobra.Command) error { - // Flag options. - var port string - var proxyClient string - var useSwaggerUI bool + pluginTmpDir := p.getPluginTempDir() + webPidFile := filepath.Join(pluginTmpDir, "web.pid") + + webRunFlags := webFlags{ + RunInfoDir: pluginTmpDir, + } + + var foreground bool var cmd = &cobra.Command{ - Use: "web", - Short: "Starts web server", - Aliases: []string{"ui"}, + Use: "web [stop]", + Short: "Starts web server", + Args: cobra.MatchAll(cobra.RangeArgs(0, 1), cobra.OnlyValidArgs), + ValidArgs: []string{stopArg}, + Aliases: []string{"ui"}, + Example: `web +web --foreground +web stop`, RunE: func(cmd *cobra.Command, args []string) error { // Don't show usage help on a runtime error. cmd.SilenceUsage = true - runOpts := &server.RunOptions{ - Addr: fmt.Sprintf(":%s", port), // @todo use proper addr - APIPrefix: APIPrefix, - SwaggerJSON: useSwaggerUI, - ProxyClient: proxyClient, - // @todo use embed fs for client or provide path ? + // If 'stop' arg passed, try to interrupt process and remove PID file. + if len(args) > 0 && args[0] == stopArg { + return stopWeb(webPidFile, webRunFlags.RunInfoDir) + } + + if ok, url := isWebRunning(webPidFile, webRunFlags.RunInfoDir); ok { + return fmt.Errorf("another server is already running at the URL: %s. please stop the existing server before starting a new one", url) } - prepareRunOption(p, runOpts) + if foreground { + //@TODO refactor to pass only plugin.app instead of full plugin. + return runWeb(cmd.Context(), p, &webRunFlags) + } - return server.Run(cmd.Context(), p.app, runOpts) + return runBackgroundWeb(cmd, p, &webRunFlags, webPidFile) }, } - cmd.Flags().StringVarP(&port, "port", "p", "8080", `Web server port`) - cmd.Flags().BoolVarP(&useSwaggerUI, "swagger-ui", "", false, `Serve swagger.json on /api/swagger.json and Swagger UI on /api/swagger-ui`) - cmd.Flags().StringVarP(&proxyClient, "proxy-client", "", "", `Proxies to client web server, useful in local development`) + + cmd.Flags().IntVarP(&webRunFlags.Port, "port", "p", 8080, `Web server port`) + cmd.Flags().BoolVarP(&webRunFlags.UseSwaggerUI, "swagger-ui", "", false, `Serve swagger.json on /api/swagger.json and Swagger UI on /api/swagger-ui`) + cmd.Flags().StringVarP(&webRunFlags.ProxyClient, "proxy-client", "", "", `Proxies to client web server, useful in local development`) + cmd.Flags().BoolVarP(&foreground, "foreground", "", false, `Run server as foreground process`) // Command flags. rootCmd.AddCommand(cmd) return nil } + +func (p *Plugin) getPluginTempDir() string { + return p.cfg.Path(pluginName) +} diff --git a/process-unix.go b/process-unix.go new file mode 100644 index 0000000..3e9b252 --- /dev/null +++ b/process-unix.go @@ -0,0 +1,37 @@ +//go:build !windows +// +build !windows + +package web + +import ( + "os" + "os/exec" + "syscall" + + "github.com/launchrctl/launchr/pkg/log" +) + +func setSysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } +} + +func isProcessRunning(pid int) bool { + process, err := os.FindProcess(pid) + if err != nil { + log.Debug("Failed to find process: %s\n", err) + return false + } + + err = process.Signal(syscall.Signal(0)) + if err == nil { + return true + } + + if err.Error() == "os: process already finished" { + return false + } + + return true +} diff --git a/process-win.go b/process-win.go new file mode 100644 index 0000000..e8c724b --- /dev/null +++ b/process-win.go @@ -0,0 +1,35 @@ +//go:build windows +// +build windows + +package web + +import ( + "os/exec" + + "golang.org/x/sys/windows" +) + +func setSysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &windows.SysProcAttr{ + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP, + } +} + +func isProcessRunning(pid uint32) bool { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return false + } + defer windows.CloseHandle(h) + + var code uint32 + err = windows.GetExitCodeProcess(h, &code) + if err != nil { + return false + } + if code == windows.STILL_ACTIVE { + return true + } + + return false +} diff --git a/process.go b/process.go new file mode 100644 index 0000000..b7aa32c --- /dev/null +++ b/process.go @@ -0,0 +1,53 @@ +package web + +import ( + "fmt" + "os" + "path/filepath" + "strconv" +) + +func pidFileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func readPidFile(path string) (int, error) { + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return 0, fmt.Errorf("error reading PID file: %w", err) + } + + pid, err := strconv.Atoi(string(data)) + if err != nil { + return 0, fmt.Errorf("error converting PID from file: %w", err) + } + + return pid, nil +} + +func killProcess(pid int) error { + process, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("error finding process: %w", err) + } + + if err = process.Kill(); err != nil { + return fmt.Errorf("error killing process: %w", err) + } + + return nil +} + +func interruptProcess(pid int) error { + process, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("error finding process: %w", err) + } + + if err = process.Signal(os.Interrupt); err != nil { + return fmt.Errorf("error interrupting process: %w", err) + } + + return nil +} diff --git a/scripts/prebuild.go b/scripts/prebuild.go index 82a7a64..71923dc 100644 --- a/scripts/prebuild.go +++ b/scripts/prebuild.go @@ -32,10 +32,10 @@ func main() { } release := os.Args[1] - folderPath := os.Args[2] + dirPath := os.Args[2] - archivePath := filepath.Clean(filepath.Join(folderPath, "dist.tar.gz")) - resultPath := filepath.Clean(filepath.Join(folderPath, ".")) + archivePath := filepath.Clean(filepath.Join(dirPath, "dist.tar.gz")) + resultPath := filepath.Clean(filepath.Join(dirPath, ".")) fmt.Println("Trying to download dist archive...") diff --git a/server/api.go b/server/api.go index c4a4c59..0327bc9 100644 --- a/server/api.go +++ b/server/api.go @@ -55,7 +55,6 @@ func (l *launchrServer) GetCustomisationConfig(w http.ResponseWriter, _ *http.Re var launchrConfig *launchrWebConfig err := l.cfg.Get("web", &launchrConfig) if err != nil { - log.Debug(err.Error()) sendError(w, http.StatusInternalServerError, "error getting config") return } @@ -72,7 +71,6 @@ func (l *launchrServer) GetCustomisationConfig(w http.ResponseWriter, _ *http.Re gvFile, err := parseVarsFile(launchrConfig.VarsFile) if err != nil { - log.Debug(err.Error()) sendError(w, http.StatusInternalServerError, "error getting group vars file") return } @@ -117,7 +115,6 @@ func (l *launchrServer) GetRunningActionStreams(w http.ResponseWriter, _ *http.R } sd, err := fStreams.GetStreamData(params) if err != nil { - log.Debug(err.Error()) sendError(w, http.StatusInternalServerError, "Error reading streams") } @@ -231,7 +228,6 @@ func (l *launchrServer) RunAction(w http.ResponseWriter, r *http.Request, id str // Can we fetch directly json? streams, err := createFileStreams(runID) if err != nil { - log.Debug(err.Error()) sendError(w, http.StatusInternalServerError, "Error preparing streams") } diff --git a/server/server.go b/server/server.go index 8bc1abe..b9e9a43 100644 --- a/server/server.go +++ b/server/server.go @@ -6,18 +6,21 @@ package server import ( "context" "encoding/json" + "errors" "fmt" "io/fs" - "log" "net/http" "net/http/httputil" "net/url" "os" "os/exec" + "os/signal" "path" + "path/filepath" "runtime" "sort" "strings" + "syscall" "time" "github.com/getkin/kin-openapi/openapi3" @@ -25,9 +28,11 @@ import ( "github.com/go-chi/cors" "github.com/go-chi/render" "github.com/gorilla/websocket" - middleware "github.com/oapi-codegen/nethttp-middleware" - "github.com/launchrctl/launchr" + "github.com/launchrctl/launchr/pkg/cli" + "github.com/launchrctl/launchr/pkg/log" + middleware "github.com/oapi-codegen/nethttp-middleware" + "gopkg.in/yaml.v3" ) // RunOptions is a set of options for running openapi http server. @@ -43,14 +48,25 @@ type RunOptions struct { // Client server. ClientFS fs.FS ProxyClient string + RunInfoDir string +} + +// RunInfo is structure that stores current running server metadata. +type RunInfo struct { + // BaseURL stores server accessible URL. + BaseURL string `yaml:"BaseURL"` } -const asyncTickerTime = 2 +const ( + asyncTickerTime = 2 -const swaggerUIPath = "/swagger-ui" -const swaggerJSONPath = "/swagger.json" + swaggerUIPath = "/swagger-ui" + swaggerJSONPath = "/swagger.json" -const statusRunning string = "running" + statusRunning string = "running" + + runInfoName = "server-info.yaml" +) // Run starts http server. func Run(ctx context.Context, app launchr.App, opts *RunOptions) error { @@ -100,7 +116,10 @@ func Run(ctx context.Context, app launchr.App, opts *RunOptions) error { r.Post("/api/shutdown", func(w http.ResponseWriter, r *http.Request) { cancel() - w.Write([]byte("Server is shutting down...")) + _, err = w.Write([]byte("Server is shutting down...")) + if err != nil { + log.Warn(err.Error()) + } }) // Register router in openapi and start the server. @@ -115,29 +134,138 @@ func Run(ctx context.Context, app launchr.App, opts *RunOptions) error { // @todo add special prefix for web run containers. baseURL := "http://localhost:" + strings.Split(s.Addr, ":")[1] store.baseURL = baseURL - fmt.Println("Starting server on " + baseURL) if opts.SwaggerJSON { fmt.Println("Swagger UI: " + baseURL + opts.APIPrefix + swaggerUIPath) fmt.Println("swagger.json: " + baseURL + opts.APIPrefix + swaggerJSONPath) } - // Open the browser after printing the start messages + runInfo := RunInfo{BaseURL: baseURL} + + defer onShutdown(opts.RunInfoDir) + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + go func() { - time.Sleep(2 * time.Second) // Small delay to ensure the server is fully started - if err := openBrowser(baseURL); err != nil { - fmt.Printf("Failed to open browser: %v\n", err) - } + popInBrowser(baseURL) + }() + + go func() { + <-signals + log.Info("terminating...\n") + cancel() }() go func() { <-ctx.Done() fmt.Println("Shutting down the server...") - if err := s.Shutdown(context.Background()); err != nil { - fmt.Printf("Error shutting down the server: %v\n", err) + if err = s.Shutdown(context.Background()); err != nil { + fmt.Printf("Error shutting down the server: %v\n", err) } }() - return s.ListenAndServe() + err = storeRunInfo(runInfo, opts.RunInfoDir) + if err != nil { + return err + } + + if err = s.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + return err + } + + return nil +} + +func onShutdown(dir string) { + err := os.RemoveAll(dir) + if err != nil { + log.Warn(err.Error()) + } +} + +// GetRunInfo lookups server run info metadata and tries to get it from storage. +func GetRunInfo(storePath string) (*RunInfo, error) { + riPath := fmt.Sprintf("%s/%s", storePath, runInfoName) + + _, err := os.Stat(riPath) + if os.IsNotExist(err) { + return nil, nil + } + + data, err := os.ReadFile(filepath.Clean(riPath)) + if err != nil { + return nil, fmt.Errorf("error reading plugin storage path: %w", err) + } + + var ri RunInfo + err = yaml.Unmarshal(data, &ri) + if err != nil { + return nil, fmt.Errorf("error unmarshalling yaml: %w", err) + } + + return &ri, nil +} + +func storeRunInfo(runInfo RunInfo, storePath string) error { + ri, err := yaml.Marshal(&runInfo) + if err != nil { + return err + } + + err = os.MkdirAll(storePath, 0750) + if err != nil { + return err + } + err = os.WriteFile(fmt.Sprintf("%s/%s", storePath, runInfoName), ri, os.FileMode(0640)) + if err != nil { + return err + } + + return nil +} + +// CheckHealth helper to check if server is available by request. +func CheckHealth(url string) (bool, error) { + client := &http.Client{} + req, err := http.NewRequest(http.MethodHead, url, nil) + if err != nil { + return false, err + } + resp, err := client.Do(req) + if err != nil { + return false, err + } + _ = resp.Body.Close() + + return resp.StatusCode == http.StatusOK, nil +} + +func popInBrowser(url string) bool { + client := &http.Client{} + for { + req, err := http.NewRequest(http.MethodHead, url, nil) + if err != nil { + log.Warn(err.Error()) + return false + } + resp, err := client.Do(req) + if err != nil { + log.Info("The server isn't ready yet, please standby...") + time.Sleep(time.Second) + continue + } + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + cli.Println("The web server can be reached through the following URL: %s.", url) + if err = openBrowser(url); err != nil { + log.Debug("Failed to open browser: %v\n", err) + } + } + break + } + + return true } func spaHandler(opts *RunOptions) http.HandlerFunc { @@ -199,25 +327,26 @@ func wsHandler(l *launchrServer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ws, err := upgrader.Upgrade(w, r, nil) if err != nil { - log.Fatal(err) + log.Fatal(err.Error()) } defer ws.Close() + var message []byte for { - _, message, err := ws.ReadMessage() + _, message, err = ws.ReadMessage() if err != nil { - log.Println(err) + log.Info(err.Error()) break } var msg messageType - if err := json.Unmarshal(message, &msg); err != nil { - log.Printf("Error unmarshaling message: %v", err) + if err = json.Unmarshal(message, &msg); err != nil { + log.Debug("Error unmarshalling message: %v", err) continue } - log.Printf("Received command: %s", msg.Message) - log.Printf("Received params: %v", msg.Action) + log.Info("Received command: %s", msg.Message) + log.Info("Received params: %v", msg.Action) switch msg.Message { case "get-processes": @@ -225,7 +354,7 @@ func wsHandler(l *launchrServer) http.HandlerFunc { case "get-process": go getStreams(msg, ws, l) default: - log.Printf("Unknown command: %s", msg.Message) + log.Info("Unknown command: %s", msg.Message) } } } @@ -260,13 +389,13 @@ func getProcesses(msg messageType, ws *websocket.Conn, l *launchrServer) { finalResponse, err := json.Marshal(responseMessage) if err != nil { - log.Printf("Error marshaling final response: %v", err) + log.Debug("Error marshaling final response: %v", err) return } l.wsMutex.Lock() if writeErr := ws.WriteMessage(websocket.TextMessage, finalResponse); writeErr != nil { - log.Println(writeErr) + log.Debug(writeErr.Error()) } l.wsMutex.Unlock() @@ -285,14 +414,14 @@ func getProcesses(msg messageType, ws *websocket.Conn, l *launchrServer) { finalCompleteResponse, err := json.Marshal(completeMessage) if err != nil { - log.Printf("Error marshaling final response: %v", err) + log.Debug("Error marshaling final response: %v", err) return } if !anyProccessRunning { l.wsMutex.Lock() - if err := ws.WriteMessage(websocket.TextMessage, finalCompleteResponse); err != nil { - log.Println(err) + if err = ws.WriteMessage(websocket.TextMessage, finalCompleteResponse); err != nil { + log.Debug(err.Error()) } l.wsMutex.Unlock() break @@ -336,13 +465,13 @@ func getStreams(msg messageType, ws *websocket.Conn, l *launchrServer) { finalResponse, err := json.Marshal(responseMessage) if err != nil { - log.Printf("Error marshaling response: %v", err) + log.Debug("Error marshaling response: %v", err) return } l.wsMutex.Lock() - if err := ws.WriteMessage(websocket.TextMessage, finalResponse); err != nil { - log.Println(err) + if err = ws.WriteMessage(websocket.TextMessage, finalResponse); err != nil { + log.Debug(err.Error()) } l.wsMutex.Unlock() } @@ -357,13 +486,13 @@ func getStreams(msg messageType, ws *websocket.Conn, l *launchrServer) { finalResponse, err := json.Marshal(finalMessage) if err != nil { - log.Printf("Error marshaling final message: %v", err) + log.Debug("Error marshaling final message: %v", err) return } l.wsMutex.Lock() - if err := ws.WriteMessage(websocket.TextMessage, finalResponse); err != nil { - log.Println(err) + if err = ws.WriteMessage(websocket.TextMessage, finalResponse); err != nil { + log.Debug(err.Error()) } l.wsMutex.Unlock() } diff --git a/web-runner.go b/web-runner.go new file mode 100644 index 0000000..28192b5 --- /dev/null +++ b/web-runner.go @@ -0,0 +1,272 @@ +package web + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/launchrctl/launchr/pkg/cli" + "github.com/launchrctl/launchr/pkg/log" + "github.com/spf13/cobra" + + "github.com/launchrctl/web/server" +) + +const ( + launchrBackgroundEnvVar = "LAUNCHR_BACKGROUND" +) + +func isBackGroundEnv() bool { + return len(os.Getenv(launchrBackgroundEnvVar)) == 1 +} + +func runWeb(ctx context.Context, p *Plugin, webOpts *webFlags) error { + var err error + + port := webOpts.Port + if !isAvailablePort(port) { + log.Info("The port %d you are trying to use for the web server is not available.", port) + port, err = getAvailablePort(port) + if err != nil { + return err + } + } + + serverOpts := &server.RunOptions{ + Addr: fmt.Sprintf(":%d", port), // @todo use proper addr + APIPrefix: APIPrefix, + SwaggerJSON: webOpts.UseSwaggerUI, + ProxyClient: webOpts.ProxyClient, + RunInfoDir: webOpts.RunInfoDir, + // @todo use embed fs for client or provide path ? + } + + // @todo to consider renaming and removing access to plugin and overall global assets. + prepareRunOption(p, serverOpts) + + return server.Run(ctx, p.app, serverOpts) +} + +func runBackgroundWeb(cmd *cobra.Command, p *Plugin, flags *webFlags, pidFile string) error { + if isBackGroundEnv() { + // @TODO rework logs, to replace with global launchr logging. + err := redirectOutputs(flags.RunInfoDir) + if err != nil { + return err + } + + return runWeb(cmd.Context(), p, flags) + } + + pid, err := runBackgroundCmd(cmd, pidFile) + if err != nil { + return err + } + + // Wait until background server is up. + // Check if run info created and server is reachable. + // Print server URL in CLI. + // Kill process in case of timeout + timeout := time.After(10 * time.Second) + ticker := time.NewTicker(1 * time.Second) + + defer ticker.Stop() + + for { + select { + case <-timeout: + // Kill existing process + _ = killProcess(pid) + + // Cleanup temp dir + err = os.RemoveAll(flags.RunInfoDir) + if err != nil { + log.Debug(err.Error()) + } + + return errors.New("couldn't start background process") + case <-ticker.C: + runInfo, _ := server.GetRunInfo(flags.RunInfoDir) + if runInfo == nil { + continue + } + + cli.Println("Web running in background with PID:%d", pid) + cli.Println(runInfo.BaseURL) + + return nil + } + } +} + +func runBackgroundCmd(cmd *cobra.Command, pidFile string) (int, error) { + err := os.MkdirAll(filepath.Dir(pidFile), 0750) + if err != nil { + return 0, fmt.Errorf("not possible to create tmp directory for %s", pidFile) + } + + // Prepare the command to restart itself in the background + args := append([]string{cmd.Name()}, os.Args[2:]...) + + command := exec.Command(os.Args[0], args...) //nolint G204 + command.Env = append(os.Environ(), fmt.Sprintf("%s=1", launchrBackgroundEnvVar)) + + // Set platform-specific process ID + setSysProcAttr(command) + + err = command.Start() + if err != nil { + cmd.Println("Failed to start in background:", err) + return 0, err + } + + err = os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", command.Process.Pid)), os.FileMode(0644)) + if err != nil { + return 0, fmt.Errorf("failed to write PID file: %w", err) + } + + return command.Process.Pid, nil +} + +func redirectOutputs(dir string) error { + err := os.MkdirAll(dir, 0750) + if err != nil { + return fmt.Errorf("can't create plugin temporary directory") + } + + outLog, err := os.Create(fmt.Sprintf("%s/out.log", dir)) + if err != nil { + return err + } + + errLog, err := os.Create(fmt.Sprintf("%s/error.log", dir)) + if err != nil { + return err + } + + os.Stdout = outLog + os.Stderr = errLog + + return nil +} + +func isWebRunning(pidFile string, runInfoDir string) (bool, string) { + url := "" + if isBackGroundEnv() { + return false, url + } + + serverRunInfo, err := server.GetRunInfo(runInfoDir) + if err != nil { + log.Debug(err.Error()) + return false, url + } + + if serverRunInfo != nil { + url = serverRunInfo.BaseURL + } + + if pidFileExists(pidFile) { + pid, errPid := readPidFile(pidFile) + if errPid == nil { + if isProcessRunning(pid) { + return true, url + } + } + } + + if url == "" { + return false, url + } + + isHealthy, err := server.CheckHealth(serverRunInfo.BaseURL) + if err != nil { + log.Debug(err.Error()) + } + + return isHealthy, url +} + +func stopWeb(pidFile, runInfoDir string) error { + onSuccess := "The web server has been successfully shut down." + + if pidFileExists(pidFile) { + pid, err := readPidFile(pidFile) + if err != nil { + return err + } + + if isProcessRunning(pid) { + err = interruptProcess(pid) + if err != nil { + return err + } + + cli.Println(onSuccess) + return nil + } + } + + serverRunInfo, err := server.GetRunInfo(runInfoDir) + if err != nil { + return err + } + + if serverRunInfo == nil { + cli.Println("At present, there is no active server that can be stopped.") + return nil + } + + if serverRunInfo.BaseURL == "" { + panic("An instance of 'run-info' with an empty URL has been detected. Please remove it.") + } + + isHealthy, err := server.CheckHealth(serverRunInfo.BaseURL) + if err != nil { + return err + } + + if isHealthy { + return fmt.Errorf("A foreground server is currently running at the address '%s'. Please stop it via the user interface or terminate the process", serverRunInfo.BaseURL) + } + + cli.Println(onSuccess) + return nil +} + +func getAvailablePort(port int) (int, error) { + // Quick check if port available and return if yes. + if isAvailablePort(port) { + return port, nil + } + + maxPort := 65535 + newPort := 49152 + + // Check available port from pool. + for !isAvailablePort(newPort) && newPort < maxPort { + log.Debug("port %d is not available", newPort) + newPort++ + } + + if newPort >= maxPort && !isAvailablePort(newPort) { + panic("port limit exceeded") + } + + return newPort, nil +} + +func isAvailablePort(port int) bool { + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return false + } + + _ = listener.Close() + return true +}