diff --git a/.include b/.include index 9255b7a..ec0c892 100644 --- a/.include +++ b/.include @@ -11,5 +11,7 @@ package.json package-lock.json lstV2 lang +scripts/services.ps1 drizzle.config.ts -tsconfig.json \ No newline at end of file +tsconfig.json +.includeCleanup diff --git a/.includeCleanup b/.includeCleanup new file mode 100644 index 0000000..d992416 --- /dev/null +++ b/.includeCleanup @@ -0,0 +1,7 @@ +dist +tmp/ +frontend/dist +lstDocs/build +oldbuild.log +lstV2/dist +lstV2/frontend/dist \ No newline at end of file diff --git a/controller/main.go b/controller/main.go index 5252615..a4616e8 100644 --- a/controller/main.go +++ b/controller/main.go @@ -10,10 +10,10 @@ import ( "github.com/gin-gonic/gin" socketio "github.com/googollee/go-socket.io" "github.com/joho/godotenv" - router "lst.net/internal/route_handler" ) func main() { + err := godotenv.Load("../.env") if err != nil { fmt.Println("Warning: .env file not found") @@ -29,10 +29,11 @@ func main() { if os.Getenv("NODE_ENV") != "production" { basePath = "/lst/api/controller" } - r := router.Setup(basePath) // returns *gin.Engine server := socketio.NewServer(nil) + r := Setup(basePath, server) // returns *gin.Engine + server.OnConnect("/", func(s socketio.Conn) error { fmt.Println("✅ Client connected:", s.ID()) return nil diff --git a/controller/internal/route_handler/router.go b/controller/router.go similarity index 74% rename from controller/internal/route_handler/router.go rename to controller/router.go index 062176d..2a62b73 100644 --- a/controller/internal/route_handler/router.go +++ b/controller/router.go @@ -1,20 +1,15 @@ -package router +package main import ( "fmt" "net/http" "os" - "time" "github.com/gin-gonic/gin" + socketio "github.com/googollee/go-socket.io" ) -type UpdatePayload struct { - Action string `json:"action"` - Target string `json:"target"` -} - -func Setup(basePath string) *gin.Engine { +func Setup(basePath string, server *socketio.Server) *gin.Engine { r := gin.Default() @@ -44,12 +39,18 @@ func Setup(basePath string) *gin.Engine { return } - steps := []string{"🚀 Starting...", "📂 Copying...", "🔧 Migrating...", "✅ Done!"} - for _, step := range steps { - fmt.Fprintf(c.Writer, "event: log\ndata: %s\n\n", step) - flusher.Flush() // 🔑 actually push chunk - time.Sleep(1 * time.Second) // simulate work + // Start the update and get the channel + updates := UpdateApp(server) + + for msg := range updates { + //fmt.Fprintf(c.Writer, "event: log\ndata: %s\n\n", msg) + fmt.Fprintf(c.Writer, "%s\n\n", msg) + flusher.Flush() } + + fmt.Fprintf(c.Writer, "Update process finished\n\n") + flusher.Flush() + }) r.Any(basePath+"/", func(c *gin.Context) { errorApiLoc(c) }) diff --git a/controller/update.go b/controller/update.go new file mode 100644 index 0000000..40d9bd4 --- /dev/null +++ b/controller/update.go @@ -0,0 +1,305 @@ +package main + +import ( + "archive/zip" + "bufio" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + socketio "github.com/googollee/go-socket.io" +) + +func UpdateApp(server *socketio.Server) <-chan string { + updates := make(chan string) + rootDir := filepath.Join("..") + + entries, err := os.ReadDir(rootDir) + if err != nil { + //log.Fatal("failed to read root dir: %v", err) + + server.BroadcastToRoom("/", "update", "updateLogs", fmt.Sprintf("failed to read root dir: %v", err)) + } + + var zips []string + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".zip" { + zips = append(zips, filepath.Join(rootDir, e.Name())) + } + } + + go func() { + defer close(updates) + + switch len(zips) { + case 0: + msg := "No zip files found in root directory" + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + case 1: + version, err := extractVersion(filepath.Base(zips[0])) + if err != nil { + msg := fmt.Sprintf("could not parse version: %v", err) + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + } + msg := "Stopping the services" + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + // 1. Stop services + pool + stopService("LSTV2") + stopService("lst_app") + stopAppPool("LogisticsSupportTool") + time.Sleep(2 * time.Second) + + msg = "Checking cleanup of files" + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + + if version%10 == 0 { + msg = fmt.Sprintf("Release %d is a cleanup milestone — cleaning root", version) + updates <- msg + + server.BroadcastToRoom("/", "update", "updateLogs", msg) + if err := cleanupRoot(rootDir); err != nil { + msg = fmt.Sprintf("cleanup failed: %v", err) + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + } + } + + msg = "Unzipping New Build" + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + if err := Unzip(zips[0], rootDir); err != nil { + msg = fmt.Sprintf("unzip failed: %v", err) + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + } + + msg = "Running App Update" + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + if err := runNPMInstall(rootDir); err != nil { + server.BroadcastToRoom("/", "update", "updateLogs", fmt.Sprintf("npm install failed: %v", err)) + } + + msg = "Running DB Migrations" + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + if err := runNPMMigrate(rootDir); err != nil { + msg = fmt.Sprintf("npm migrate failed: %v", err) + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + } + + msg = "Starting Services back up" + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + startService("lst_app") + startService("LSTV2") + startAppPool("LogisticsSupportTool") + + if err := os.Remove(zips[0]); err != nil { + msg = fmt.Sprintf("warning: failed to delete zip %s: %v", zips[0], err) + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + } else { + msg = fmt.Sprintf("Deleted zip %s", zips[0]) + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + } + + msg = "Deployment finished successfully" + updates <- msg + + server.BroadcastToRoom("/", "update", "updateLogs", msg) + default: + msg := fmt.Sprintf("Error: too many zip files in root: %v\n", zips) + updates <- msg + server.BroadcastToRoom("/", "update", "updateLogs", msg) + + } + }() + + return updates +} + +func Unzip(srcZip, destDir string) error { + + r, err := zip.OpenReader(srcZip) + if err != nil { + return fmt.Errorf("opening zip: %w", err) + } + defer r.Close() + + for _, f := range r.File { + // Build the destination path for each file + fpath := filepath.Join(destDir, f.Name) + + // Check for ZipSlip (directory traversal) + if !strings.HasPrefix(fpath, filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", fpath) + } + + if f.FileInfo().IsDir() { + // Create folder + if err := os.MkdirAll(fpath, os.ModePerm); err != nil { + return err + } + continue + } + + // Make sure target folder exists + if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { + return err + } + + // Open file in archive + rc, err := f.Open() + if err != nil { + return err + } + + // Create destination file + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + rc.Close() + return err + } + + // Copy file contents + _, err = io.Copy(outFile, rc) + + // Close files + outFile.Close() + rc.Close() + + if err != nil { + return err + } + } + + return nil +} + +// stopService runs "sc stop " +func stopService(name string) error { + cmd := exec.Command("sc", "stop", name) + output, err := cmd.CombinedOutput() + if err != nil { + return err + } + log.Printf("Stopped service %s: %s", name, output) + return nil +} + +// stopAppPool runs appcmd to stop an IIS app pool +func stopAppPool(name string) error { + cmd := exec.Command(`C:\Windows\System32\inetsrv\appcmd.exe`, "stop", "apppool", "/apppool.name:"+name) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to stop app pool %s: %v, output: %s", name, err, string(output)) + } + log.Printf("Stopped app pool %s: %s", name, string(output)) + return nil +} + +func cleanupRoot(rootDir string) error { + // Default cleanup targets + targets := []string{ + "dist", + filepath.Join("frontend", "dist"), + filepath.Join("lstDocs", "build"), + } + + // Check if .includeCleanup file exists + cleanupFile := filepath.Join(rootDir, ".includeCleanup") + if _, err := os.Stat(cleanupFile); err == nil { + f, err := os.Open(cleanupFile) + if err != nil { + return err + } + defer f.Close() + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + targets = append(targets, line) + } + if sc.Err() != nil { + return sc.Err() + } + } + + // Delete them (remove dirs/files under rootDir) + for _, t := range targets { + p := filepath.Join(rootDir, t) + if _, err := os.Stat(p); err == nil { + log.Printf("Removing %s", p) + if err := os.RemoveAll(p); err != nil { + return err + } + } + } + return nil +} + +func extractVersion(filename string) (int, error) { + re := regexp.MustCompile(`release-(\d+)\.zip`) + m := re.FindStringSubmatch(filename) + if m == nil { + return 0, fmt.Errorf("filename does not match release pattern: %s", filename) + } + return strconv.Atoi(m[1]) +} + +func runNPMInstall(rootDir string) error { + frontendDir := filepath.Join(rootDir, "frontend") // adapt if needed + cmd := exec.Command("npm", "install") + cmd.Dir = frontendDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + log.Println("Running npm install in", frontendDir) + return cmd.Run() +} + +func runNPMMigrate(rootDir string) error { + frontendDir := filepath.Join(rootDir, "frontend") // same dir + cmd := exec.Command("npm", "run", "db:migrate") + cmd.Dir = frontendDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + log.Println("Running npm run db:migrate in", frontendDir) + return cmd.Run() +} + +func startService(name string) error { + cmd := exec.Command("sc", "start", name) + output, err := cmd.CombinedOutput() + if err != nil { + return err + } + log.Printf("Started service %s: %s", name, output) + return nil +} + +func startAppPool(name string) error { + cmd := exec.Command(`C:\Windows\System32\inetsrv\appcmd.exe`, "start", "apppool", "/apppool.name:"+name) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to stop app pool %s: %v, output: %s", name, err, string(output)) + } + log.Printf("Stopped app pool %s: %s", name, string(output)) + return nil +} diff --git a/controller/update_channel.go b/controller/update_channel.go index 39e48e5..d3cc824 100644 --- a/controller/update_channel.go +++ b/controller/update_channel.go @@ -11,6 +11,7 @@ import ( "os" "strings" + "github.com/gin-gonic/gin" socketio "github.com/googollee/go-socket.io" ) @@ -31,8 +32,6 @@ func registerUpdateChannel(server *socketio.Server) { server.OnEvent("/", "update", func(s socketio.Conn, payload UpdatePayload) { switch strings.ToLower(payload.Action) { case "copy": - server.BroadcastToRoom("/", "update", "updateLogs", - fmt.Sprintf("🚀 Copying latest build to %v", payload.Target)) copyLatestBuild(server, payload.Target) case "update": @@ -59,6 +58,7 @@ func updateServer(server *socketio.Server, target string) { if strings.Contains(host, "VMS") || strings.Contains(host, "vms") { server.BroadcastToRoom("/", "update", "updateLogs", "Your are about to check for a new build and then update the server.") + go UpdateApp(server) return } @@ -80,16 +80,16 @@ func copyLatestBuild(server *socketio.Server, target string) { func triggerRemoteUpdate(server *socketio.Server, remoteURL string, payload UpdatePayload) { - basePath := "/api/controller" - if os.Getenv("NODE_ENV") != "production" { - basePath = "/lst/api/controller" - } + // basePath := "/api/controller" + // if os.Getenv("NODE_ENV") != "production" { + // basePath = "/lst/api/controller" + // } // send POST request with JSON, expect SSE / streaming text back body, _ := json.Marshal(payload) - url := fmt.Sprintf("https://%v.alpla.net%v/update", remoteURL, basePath) - //url := fmt.Sprintf("http://localhost:8080%v/update", basePath) + //url := fmt.Sprintf("https://%v.alpla.net%v/update", remoteURL, basePath) + url := fmt.Sprintf("http://%v:8080/api/controller/update", remoteURL) fmt.Println(url) resp, err := http.Post(url, "application/json", bytes.NewBuffer(body)) if err != nil { @@ -116,3 +116,16 @@ func triggerRemoteUpdate(server *socketio.Server, remoteURL string, payload Upda } } } + +func sendUpdate(c *gin.Context, flusher http.Flusher, server *socketio.Server, msg string) { + // SSE stream + if c != nil && flusher != nil { + fmt.Fprintf(c.Writer, "event: log\ndata: %s\n\n", msg) + flusher.Flush() + } + + // Socket.IO + if server != nil { + server.BroadcastToRoom("/", "update", "updateLogs", msg) + } +}