diff --git a/.gitignore b/.gitignore index c9d31a5..b7893aa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .direnv .cache .vscode +vssm +test/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e763193 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# VSSM (VintageStory Server Manager) +*A server manager for VintageStory* + +## IMPORTANT INFORMATION +This project is currently in development, and thus everything is subject to change + +## About +VSSM allows you to create and manage multiple VS servers + +## Installation and Usage +There are no binaries just yet, so you have to download the source files. + +Setup is easy from source, all you need is: +- Linux +- Go +- .NET 10 Runtime + +**If you are using NixOS, a flake.nix is provided, just run `nix develop` for a full development enviroment** + +- Once you have the source code downloaded or cloned, you can run `go build` to generate an executable. +- Run `./vssm daemon` to start the daemon - *(For now, theres no background service created by default, but you can run it in the background with `vssm daemon &` and kill the process with `pkill vssm`)* +- Run `./vssm` with no arguments to show usage + +## Configuration +**For now, the configuration file is stored in `~/.config/vssm/config.json`, but this will be configurable later.** + +## Web Interface +Alongside this project, I am developing a seperate web-based dashboard that can be used to manage you servers with a very simple UI. You can get it [here](https://git.bellsworne.tech/chrisbell/vssm_web). + +## Roadmap + +- [ ] Configuration + - [x] Custom configuration path (passed to the daemon with a flag `--config`) + - [x] Daemon instance remembers config path (*so you don't have to pass `--config` everytime you run an ipc command*) + - [ ] Daemon listen port (kind of implemented, could be cleaner) + - [x] Declarative Server configuration (Create servers in config) + - [x] All configuration options + +- [ ] Server management + - [x] Create servers + - [x] Automatic server binary downloading by version + - [x] Delete servers + - [x] Start/Stop servers + - [x] Server configuration + - [x] Download correct configuration template for version (see availible templates [here](https://git.bellsworne.tech/chrisbell/vssm_config_templates)) + - [x] Version + - [x] Server Name + - [x] Port + - [x] Full declarative configuration support in main app config + - [x] Multiple servers support + - [x] List servers and their status + - [x] Send commands to servers + - [ ] Automated server backups + +- [ ] Binary releases + +- [ ] Automatically set up background service + +- [ ] Other + - [ ] First class NixOS support + - [x] Patch downloaded server binaries for NixOS + - [ ] Official nix package or flake + - [ ] All configuration options diff --git a/config.go b/config.go index 9a927b6..6c28f85 100644 --- a/config.go +++ b/config.go @@ -9,6 +9,7 @@ import ( type AppConfig struct { Storage struct { + AppDataDir string `json:"app_data_dir"` InstallDir string `json:"install_dir"` InstancesDir string `json:"instances_dir"` BackupDir string `json:"backup_dir"` @@ -16,13 +17,15 @@ type AppConfig struct { } `json:"storage"` Daemon struct { - UseNixOs bool `json:"use_nixos"` - } + ListenAddress string `json:"listen_address"` + Port int `json:"port"` + } `json:"daemon"` + + Instances map[string]InstanceMetadata `json:"instances"` } -func DefaultConfig() *AppConfig { - home, _ := os.UserHomeDir() - basePath := filepath.Join(home, ".local", "share", "vs-manager") +func CreateConfigWithDefaults(configPath string) *AppConfig { + basePath := filepath.Join(filepath.Dir(configPath), "vssm_data") cfg := &AppConfig{} cfg.Storage.InstallDir = filepath.Join(basePath, "installs") @@ -30,12 +33,17 @@ func DefaultConfig() *AppConfig { cfg.Storage.BackupDir = filepath.Join(basePath, "backups") cfg.Storage.ConfigTemplatesDir = filepath.Join(basePath, "config_templates") + cfg.Daemon.ListenAddress = "127.0.0.1" + cfg.Daemon.Port = 65000 + + cfg.Instances = make(map[string]InstanceMetadata) + return cfg } func LoadOrCreateConfig(configPath string) (*AppConfig, error) { if _, err := os.Stat(configPath); os.IsNotExist(err) { - cfg := DefaultConfig() + cfg := CreateConfigWithDefaults(configPath) if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { return nil, fmt.Errorf("Failed to create config directory: %w", err) diff --git a/daemon.go b/daemon.go new file mode 100644 index 0000000..b8059a5 --- /dev/null +++ b/daemon.go @@ -0,0 +1,465 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "sync" + "syscall" + "time" +) + +type CommandPayload struct { + Command string `json:"command"` +} + +type InstanceStatusResponse struct { + Name string `json:"name"` + Version string `json:"version"` + Port int `json:"port"` + Status string `json:"status"` +} + +type DaemonServer struct { + sync.RWMutex + cfg *AppConfig + configPath string + procManager *ProcessManager +} + +func StartDaemon(cfg *AppConfig, configPath string) error { + ds := &DaemonServer{ + cfg: cfg, + configPath: configPath, + procManager: NewProcessManager(), + } + + mux := http.NewServeMux() + mux.HandleFunc("/instances/create", ds.handleCreate) + mux.HandleFunc("/instances/start", ds.handleStart) + mux.HandleFunc("/instances/stop", ds.handleStop) + mux.HandleFunc("/instances/command", ds.handleCommand) + mux.HandleFunc("/instances/list", ds.handleList) + mux.HandleFunc("/instances/logs", ds.handleGetLogs) + mux.HandleFunc("/instances/delete", ds.handleDelete) + + corsWrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + mux.ServeHTTP(w, r) + }) + + listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) + + server := &http.Server{ + Addr: listenAddress, + Handler: corsWrappedHandler, + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + go func() { + fmt.Printf("Engine daemon actively listening on http://%s\n", listenAddress) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + fmt.Printf("Daemon runtime failure: %v\n", err) + } + }() + + <-sigChan + fmt.Println("\n[Daemon] Shutdown signal caught! Initializing graceful teardown sequence...") + + _ = server.Close() + ds.shutdownAllRunningServers() + + fmt.Println("[Daemon] All threads gracefully shut down. Exiting supervisor cleanly.") + return nil +} + +func (ds *DaemonServer) reloadConfig() error { + cfg, err := LoadOrCreateConfig(ds.configPath) + if err != nil { + fmt.Printf("Could not reload configuration file: %v\n", err) + return err + } + + ds.Lock() + defer ds.Unlock() + ds.cfg = cfg + return nil +} + +func (ds *DaemonServer) shutdownAllRunningServers() { + ds.procManager.Lock() + + var activeNames []string + for name := range ds.procManager.ActiveInstances { + activeNames = append(activeNames, name) + } + ds.procManager.Unlock() + + if len(activeNames) == 0 { + fmt.Println("[Daemon] No active server instances to tear down.") + return + } + + fmt.Printf("[Daemon] Flushing stop instructions to %d running instance(s)...\n", len(activeNames)) + + var wg sync.WaitGroup + for _, name := range activeNames { + wg.Add(1) + go func(instanceName string) { + defer wg.Done() + + fmt.Printf("[Daemon] Sending graceful /stop to instance '%s'...\n", instanceName) + err := ds.procManager.SendCommand(instanceName, "/stop") + if err != nil { + fmt.Printf("[Daemon Error] Could not send stop to %s: %v\n", instanceName, err) + return + } + + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + timeout := time.After(15 * time.Second) + + for { + select { + case <-ticker.C: + ds.procManager.RLock() + _, running := ds.procManager.ActiveInstances[instanceName] + ds.procManager.RUnlock() + + if !running { + fmt.Printf("[Daemon] Instance '%s' has successfully exited.\n", instanceName) + return + } + case <-timeout: + fmt.Printf("[Daemon Warning] Instance '%s' timed out while trying to stop safely.\n", instanceName) + return + } + } + }(name) + } + + wg.Wait() +} + +func (ds *DaemonServer) handleCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + version := r.URL.Query().Get("version") + port := r.URL.Query().Get("port") + if name == "" || version == "" || port == "" { + http.Error(w, "Missing name, version, or port parameters", http.StatusBadRequest) + return + } + + converted_port, err := strconv.Atoi(port) + if err != nil { + http.Error(w, "Could not convert provided port to a valid port number", http.StatusBadRequest) + return + } + + if ds.reloadConfig() != nil { + http.Error(w, "Could not reload config", http.StatusInternalServerError) + return + } + + ds.Lock() + defer ds.Unlock() + + if _, exists := ds.cfg.Instances[name]; exists { + http.Error(w, fmt.Sprintf("Instance '%s' already exists in configuration", name), http.StatusConflict) + return + } + + for name, options := range ds.cfg.Instances { + if options.Port == converted_port { + http.Error(w, fmt.Sprintf("Port already occupied by instance '%s'", name), http.StatusConflict) + return + } + } + + metadata := InstanceMetadata{ + Version: version, + ServerName: name, + Port: converted_port, + } + + err = DownloadAndExtractServer(version, ds.cfg.Storage.InstallDir) + if err != nil { + http.Error(w, fmt.Sprintf("Installation failed: %v", err), http.StatusInternalServerError) + return + } + + err = CreateNewInstance(name, version, metadata, ds.cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Instance provisioning failed: %v", err), http.StatusInternalServerError) + return + } + + ds.cfg.Instances[name] = metadata + + data, err := json.MarshalIndent(ds.cfg, "", " ") + if err != nil { + http.Error(w, fmt.Sprintf("Failed processing profile adjustments: %v", err), http.StatusInternalServerError) + return + } + + if err := os.WriteFile(ds.configPath, data, 0644); err != nil { + http.Error(w, fmt.Sprintf("Failed saving configuration adjustments to disk: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, "Successfully created and stored profile for instance %s", name) +} + +func (ds *DaemonServer) handleStart(w http.ResponseWriter, r *http.Request) { + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Missing name parameter", http.StatusBadRequest) + return + } + + if ds.reloadConfig() != nil { + http.Error(w, "Could not reload config", http.StatusInternalServerError) + return + } + + ds.RLock() + defer ds.RUnlock() + + options, exists := ds.cfg.Instances[name] + if !exists { + http.Error(w, fmt.Sprintf("Instance '%s' does not exist. Run 'create' first", name), http.StatusNotFound) + return + } + + instanceConfigPath := filepath.Join(ds.cfg.Storage.InstancesDir, name, "serverconfig.json") + err := SyncInstanceConfig(options.Version, instanceConfigPath, options, ds.cfg) + if err != nil { + http.Error(w, "Failed to sync config: "+err.Error(), http.StatusInternalServerError) + return + } + + err = ds.procManager.StartInstance(name, options.Version, options, ds.cfg) + if err != nil { + http.Error(w, fmt.Sprintf("Process startup failed: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "Successfully started instance %s", name) +} + +func (ds *DaemonServer) handleStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Missing name parameter", http.StatusBadRequest) + return + } + + err := ds.procManager.SendCommand(name, "/stop") + if err != nil { + http.Error(w, fmt.Sprintf("Failed to dispatch stop command: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "Termination signal routed to instance %s", name) +} + +func (ds *DaemonServer) handleCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Missing name parameter", http.StatusBadRequest) + return + } + + var payload CommandPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Malformed JSON body", http.StatusBadRequest) + return + } + + err := ds.procManager.SendCommand(name, payload.Command) + if err != nil { + http.Error(w, fmt.Sprintf("Command delivery failed: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "Command delivered successfully to %s", name) +} + +func (ds *DaemonServer) handleList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if ds.reloadConfig() != nil { + http.Error(w, "Could not reload config", http.StatusInternalServerError) + return + } + + ds.RLock() + defer ds.RUnlock() + + ds.procManager.RLock() + defer ds.procManager.RUnlock() + + var responseList []InstanceStatusResponse + + for name, options := range ds.cfg.Instances { + status := "STOPPED" + if _, running := ds.procManager.ActiveInstances[name]; running { + status = "RUNNING" + } + + responseList = append(responseList, InstanceStatusResponse{ + Name: name, + Version: options.Version, + Port: options.Port, + Status: status, + }) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(responseList) +} + +func (ds *DaemonServer) handleGetLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Missing name parameter", http.StatusBadRequest) + return + } + + ds.procManager.RLock() + buf, exists := ds.procManager.LogBuffers[name] + ds.procManager.RUnlock() + + if !exists || buf == nil { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("[]")) + return + } + + logLines := buf.GetSnapshot() + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(logLines); err != nil { + http.Error(w, fmt.Sprintf("Failed encoding log matrix: %v", err), http.StatusInternalServerError) + } +} + +func (ds *DaemonServer) handleDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Missing name parameter", http.StatusBadRequest) + return + } + + purge := false + purgeParam := r.URL.Query().Get("purge") + if purgeParam != "" { + result, err := strconv.ParseBool(purgeParam) + if err != nil { + http.Error(w, "Invalid value for 'purge' parameter", http.StatusBadRequest) + return + } + purge = result + } + + if ds.reloadConfig() != nil { + http.Error(w, "Could not reload config", http.StatusInternalServerError) + return + } + + ds.Lock() + defer ds.Unlock() + + ds.procManager.RLock() + defer ds.procManager.RUnlock() + + _, exists := ds.cfg.Instances[name] + if !exists { + http.Error(w, fmt.Sprintf("Cannot delete instance '%s'; Does not exist.", name), http.StatusBadRequest) + return + } + + if _, running := ds.procManager.ActiveInstances[name]; running { + http.Error(w, fmt.Sprintf("Cannot delete instance '%s'; It is currently running.", name), http.StatusBadRequest) + return + } + + delete(ds.cfg.Instances, name) + + data, err := json.MarshalIndent(ds.cfg, "", " ") + if err != nil { + http.Error(w, fmt.Sprintf("Failed processing profile adjustments: %v", err), http.StatusInternalServerError) + return + } + + if err := os.WriteFile(ds.configPath, data, 0644); err != nil { + http.Error(w, fmt.Sprintf("Failed saving configuration adjustments to disk: %v", err), http.StatusInternalServerError) + return + } + + if purge { + path := filepath.Join(ds.cfg.Storage.InstancesDir, name) + err := os.RemoveAll(path) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to delete instance from disk: %v", err), http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "Successfully deleted instance %s", name) + +} diff --git a/go.mod b/go.mod index 047dd0b..3bbbc00 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module vs-manager +module vssm go 1.26.3 diff --git a/instance.go b/instance.go index 6746000..5563e73 100644 --- a/instance.go +++ b/instance.go @@ -3,7 +3,6 @@ package main import ( "fmt" "os" - "os/exec" "path/filepath" ) @@ -14,17 +13,7 @@ const ( StateRunning InstanceState = "RUNNING" ) -type ManagedInstance struct { - Name string `json:"name"` - Version string `json:"version"` - Port int `json:"port"` - Status InstanceState `json:"status"` - - Cmd *exec.Cmd `json:"-"` - Stdin interface{} `json:"-"` -} - -func CreateNewInstance(name string, version string, options VsServerConfigOptions, cfg *AppConfig) error { +func CreateNewInstance(name string, version string, meta InstanceMetadata, cfg *AppConfig) error { instanceDir := filepath.Join(cfg.Storage.InstancesDir, name) dirs := []string{ @@ -40,7 +29,7 @@ func CreateNewInstance(name string, version string, options VsServerConfigOption } instanceConfigPath := filepath.Join(instanceDir, "serverconfig.json") - if err := PrepareInstanceConfig(version, instanceConfigPath, options, cfg); err != nil { + if err := PrepareInstanceConfig(version, instanceConfigPath, meta, cfg); err != nil { return fmt.Errorf("Failed provisioning server baseline configuration: %w", err) } diff --git a/instance_config.go b/instance_config.go index faa9b61..126015b 100644 --- a/instance_config.go +++ b/instance_config.go @@ -4,77 +4,56 @@ import ( "encoding/json" "fmt" "io" + "net/http" "os" "path/filepath" "strings" ) -type VsServerConfigOptions struct { - ServerName string `json:"ServerName"` - Port int `json:"Port"` - IpAddress string `json:"IpAddress"` - MaxClients int `json:"MaxClients"` - Password string `json:"Password"` - DefaultRole string `json:"DefaultRole"` - GuestRole string `json:"GuestRole"` - PreApprovedRole string `json:"PreApprovedRole"` +type InstanceMetadata struct { + Version string `json:"Version"` + ServerName string `json:"ServerName"` + Port int `json:"Port"` + Config map[string]interface{} `json:"config"` } -// func WriteInstanceConfig(instanceDir string, settings VsServerConfig) error { -// configPath := filepath.Join(instanceDir, "serverconfig.json") +func PrepareInstanceConfig(templateVersion string, instanceConfigPath string, meta InstanceMetadata, cfg *AppConfig) error { + return SyncInstanceConfig(templateVersion, instanceConfigPath, meta, cfg) +} -// if settings.IpAddress == "" { -// settings.IpAddress = "0.0.0.0" -// } -// if settings.MaxClients == 0 { -// settings.MaxClients = 16 -// } - -// data, err := json.MarshalIndent(settings, "", " ") -// if err != nil { -// return fmt.Errorf("failed to marshal server configuration: %w", err) -// } - -// err = os.WriteFile(configPath, data, 0644) -// if err != nil { -// return fmt.Errorf("failed writing serverconfig.json to instance: %w", err) -// } - -// return nil -// } - -func PrepareInstanceConfig(templateVersion string, instanceConfigPath string, config VsServerConfigOptions, cfg *AppConfig) error { +func SyncInstanceConfig(templateVersion string, instanceConfigPath string, meta InstanceMetadata, cfg *AppConfig) error { templatePath := filepath.Join(cfg.Storage.ConfigTemplatesDir, templateVersion, "serverconfig.json") - if _, err := os.Stat(instanceConfigPath); err == nil { - return nil + if err := ensureTemplateExists(templateVersion, templatePath, cfg); err != nil { + return fmt.Errorf("failed ensuring configuration template availability: %w", err) } - if err := os.MkdirAll(filepath.Dir(instanceConfigPath), 0755); err != nil { - return fmt.Errorf("failed creating instance directory tree: %w", err) - } + if _, err := os.Stat(instanceConfigPath); os.IsNotExist(err) { + if err := os.MkdirAll(filepath.Dir(instanceConfigPath), 0755); err != nil { + return fmt.Errorf("failed creating instance directory tree: %w", err) + } - source, err := os.Open(templatePath) - if err != nil { - return fmt.Errorf("failed opening baseline template file: %w", err) - } - defer source.Close() + source, err := os.Open(templatePath) + if err != nil { + return fmt.Errorf("failed opening baseline template file: %w", err) + } + defer source.Close() - destination, err := os.Create(instanceConfigPath) - if err != nil { - return fmt.Errorf("failed creating target instance configuration: %w", err) - } - defer destination.Close() + destination, err := os.Create(instanceConfigPath) + if err != nil { + return fmt.Errorf("failed creating target instance configuration: %w", err) + } - if _, err := io.Copy(destination, source); err != nil { - return fmt.Errorf("failed cloning configuration template payload: %w", err) + _, err = io.Copy(destination, source) + destination.Close() + if err != nil { + return fmt.Errorf("failed cloning configuration template payload: %w", err) + } } - destination.Close() - data, err := os.ReadFile(instanceConfigPath) if err != nil { - return fmt.Errorf("failed reading cloned configuration data: %w", err) + return fmt.Errorf("failed reading configuration data: %w", err) } var rawConfig map[string]interface{} @@ -82,18 +61,14 @@ func PrepareInstanceConfig(templateVersion string, instanceConfigPath string, co return fmt.Errorf("failed parsing configuration JSON payload: %w", err) } - rawConfig["ServerName"] = config.ServerName - rawConfig["Port"] = config.Port - rawConfig["MaxClients"] = config.MaxClients - - if config.Password != "" { - rawConfig["Password"] = config.Password - } else { - rawConfig["Password"] = nil + for key, value := range meta.Config { + rawConfig[key] = value } - instanceDir := filepath.Dir(instanceConfigPath) + rawConfig["ServerName"] = meta.ServerName + rawConfig["Port"] = meta.Port + instanceDir := filepath.Dir(instanceConfigPath) if worldConfig, ok := rawConfig["WorldConfig"].(map[string]interface{}); ok { worldConfig["SaveFileLocation"] = filepath.Join(instanceDir, "Saves", "default.vcdbs") } @@ -111,9 +86,45 @@ func PrepareInstanceConfig(templateVersion string, instanceConfigPath string, co return fmt.Errorf("failed marshaling updated configuration adjustments: %w", err) } - if err := os.WriteFile(instanceConfigPath, updatedData, 0644); err != nil { - return fmt.Errorf("failed committing updated configuration to disk: %w", err) + return os.WriteFile(instanceConfigPath, updatedData, 0644) +} + +func ensureTemplateExists(version string, targetPath string, cfg *AppConfig) error { + if _, err := os.Stat(targetPath); err == nil { + return nil } + fmt.Printf("Template for version %s not found locally. Fetching remote layout from git server...\n", version) + + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return fmt.Errorf("failed to create local template cache directory: %w", err) + } + + remoteURL := fmt.Sprintf("https://git.bellsworne.tech/chrisbell/vssm_config_templates/raw/branch/main/%s/serverconfig.json", version) + + resp, err := http.Get(remoteURL) + if err != nil { + return fmt.Errorf("network error attempting to pull config template: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("version template '%s' does not exist in the remote git repository asset tree", version) + } else if resp.StatusCode != http.StatusOK { + return fmt.Errorf("remote git mirror returned unexpected HTTP status: %s", resp.Status) + } + + out, err := os.Create(targetPath) + if err != nil { + return fmt.Errorf("failed creating cache file hook on system storage: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + return fmt.Errorf("failed stream-writing network payload cache frame to disk: %w", err) + } + + fmt.Printf("Successfully cached configuration layout template for version %s\n", version) return nil } diff --git a/main.go b/main.go index 92779a4..a069b8e 100644 --- a/main.go +++ b/main.go @@ -1,65 +1,210 @@ package main import ( + "bytes" + "encoding/json" + "flag" "fmt" + "io" "log" + "net/http" + "net/url" "os" - "os/signal" "path/filepath" - "syscall" + "strconv" + "text/tabwriter" ) func main() { - fmt.Println("Initializing VS server manager...") - home, err := os.UserHomeDir() if err != nil { - log.Fatalf("Could not locate user home dir") + log.Fatalf("Could not locate user home dir: %v", err) } - configPath := filepath.Join(home, ".config", "vs-manager", "config.json") + defaultConfigPath := filepath.Join(home, ".config", "vssm", "config.json") - cfg, err := LoadOrCreateConfig(configPath) + configFlag := flag.String("config", defaultConfigPath, "Explicit path targeting a custom vssm config.json profile") + + flag.Usage = printUsage + flag.Parse() + + args := flag.Args() + if len(args) < 1 { + printUsage() + return + } + + configExplicitlySet := false + flag.Visit(func(f *flag.Flag) { + if f.Name == "config" { + configExplicitlySet = true + } + }) + + if !configExplicitlySet { + if envConfig := os.Getenv("VSSM_CONFIG_PATH"); envConfig != "" { + configFlag = &envConfig + } + } + + absConfigPath, err := filepath.Abs(*configFlag) + if err != nil { + log.Fatalf("Failed to resolve absolute configuration path target: %v", err) + } + + if configExplicitlySet { + if err := os.Setenv("VSSM_CONFIG_PATH", absConfigPath); err != nil { + log.Fatalf("Warning: could not set VSSM_CONFIG_PATH: %v", err) + } + } + + cfg, err := LoadOrCreateConfig(absConfigPath) if err != nil { log.Fatalf("Initialization failed: %v", err) } - instanceName := "sample_server" - targetVersion := "1.22.3" + listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) + fmt.Printf("Got listen address from config: %s\n", listenAddress) - options := VsServerConfigOptions{ - ServerName: "Sample Server", - Port: 4000, - MaxClients: 100, - Password: "", + subCommand := args[0] + + switch subCommand { + + case "daemon": + fmt.Printf("Initializing VS server manager background supervisor [Config: %s]...\n", absConfigPath) + if err := StartDaemon(cfg, absConfigPath); err != nil { + log.Fatalf("Daemon runtime fatal error: %v", err) + } + + case "create": + if len(args) < 4 { + log.Fatalf("Usage: vssm create ") + } + name := args[1] + version := args[2] + port := args[3] + sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/create?name=%s&version=%s&port=%s", url.QueryEscape(name), url.QueryEscape(version), url.QueryEscape(port)), nil) + + case "start": + if len(args) < 2 { + log.Fatalf("Usage: vssm start ") + } + name := args[1] + sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/start?name=%s", url.QueryEscape(name)), nil) + + case "stop": + if len(args) < 2 { + log.Fatalf("Usage: vssm stop ") + } + name := args[1] + sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/stop?name=%s", url.QueryEscape(name)), nil) + + case "cmd": + if len(args) < 3 { + log.Fatalf("Usage: vssm cmd \"\"") + } + name := args[1] + serverCmd := args[2] + + payload := CommandPayload{Command: serverCmd} + body, _ := json.Marshal(payload) + sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/command?name=%s", url.QueryEscape(name)), bytes.NewBuffer(body)) + + case "list", "status": + fetchAndPrintStatus(cfg) + + case "show-config": + fmt.Printf("%v", cfg.Storage.AppDataDir) + + case "delete": + if len(args) < 2 { + log.Fatalf("Usage: vssm delete ") + } + + name := args[1] + + deleteCmd := flag.NewFlagSet("delete", flag.ExitOnError) + purge := deleteCmd.Bool("purge", false, "Delete instance files from disk") + deleteCmd.Parse(args[2:]) + + sendIPCRequest(cfg, "POST", fmt.Sprintf("/instances/delete?name=%s&purge=%s", url.QueryEscape(name), url.QueryEscape(strconv.FormatBool(*purge))), nil) + + default: + printUsage() } - - err = DownloadAndExtractServer(targetVersion, cfg.Storage.InstallDir) - if err != nil { - log.Fatalf("Downloader module encountered an error: %v", err) - } - - err = CreateNewInstance(instanceName, targetVersion, options, cfg) - if err != nil { - log.Fatalf("Initialization abort: %v", err) - } - - procManager := NewProcessManager() - - fmt.Printf("\n⚡ Launching Instance: %s (v%s)\n", instanceName, targetVersion) - err = procManager.StartInstance(instanceName, targetVersion, options, cfg) - if err != nil { - log.Fatalf("Process startup failed: %v", err) - } - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - fmt.Println("\nServer running. Press Ctrl+C to cleanly shut down...") - <-sigChan - - fmt.Println("\n\nTriggering graceful server termination sequence...") - _ = procManager.SendCommand(instanceName, "stop") - - fmt.Println("Manager exited successfully.") +} + +func sendIPCRequest(cfg *AppConfig, method string, path string, body io.Reader) { + listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) + targetUrl := fmt.Sprintf("http://%s%s", listenAddress, path) + fmt.Printf("Listen Addr: %s; Target URL: %s\n", listenAddress, targetUrl) + req, err := http.NewRequest(method, targetUrl, body) + if err != nil { + log.Fatalf("Failed to construct IPC frame: %v", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Fatalf("IPC connection failed. Is the vs-manager daemon running? Error: %v", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + log.Fatalf("Error from daemon: %s", string(respBody)) + } + + fmt.Println(string(respBody)) +} + +func fetchAndPrintStatus(cfg *AppConfig) { + listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) + targetUrl := fmt.Sprintf("http://%s/instances/list", listenAddress) + resp, err := http.Get(targetUrl) + if err != nil { + log.Fatalf("IPC connection failed. Is the vs-manager daemon running? Error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + log.Fatalf("Error from daemon: %s", string(respBody)) + } + + var instances []InstanceStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&instances); err != nil { + log.Fatalf("Failed to decode daemon status matrix: %v", err) + } + + if len(instances) == 0 { + fmt.Println("No instances configured yet. Use 'create' to provision one.") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "INSTANCE NAME\tVERSION\tPORT\tSTATUS") + fmt.Fprintln(w, "-------------\t-------\t----\t------") + + for _, inst := range instances { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", inst.Name, inst.Version, inst.Port, inst.Status) + } + w.Flush() +} + +func printUsage() { + fmt.Println("VintageStory Server Manager") + fmt.Println("\nGlobal Options:") + fmt.Println(" --config Explicitly target a non-default config structure file location") + fmt.Println("\nUsage Commands:") + fmt.Println(" vssm daemon Starts the background process supervisor") + fmt.Println(" vssm create Provisions baseline configuration and stores instance profile") + fmt.Println(" vssm start Launches an existing server instance using stored profile") + fmt.Println(" vssm stop Gracefully shuts down a server instance") + fmt.Println(" vssm cmd \"\" Dispatches a terminal console command down the pipe") + fmt.Println(" vssm list Displays the operational matrix of all instances") } diff --git a/process.go b/process.go index 20f5f98..46645aa 100644 --- a/process.go +++ b/process.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" ) @@ -14,16 +15,24 @@ type ProcessManager struct { sync.RWMutex ActiveInstances map[string]*exec.Cmd StdinPipes map[string]io.WriteCloser + LogBuffers map[string]*InstanceLogBuffer +} + +type InstanceLogBuffer struct { + sync.RWMutex + Lines []string + MaxLines int } func NewProcessManager() *ProcessManager { return &ProcessManager{ ActiveInstances: make(map[string]*exec.Cmd), StdinPipes: make(map[string]io.WriteCloser), + LogBuffers: map[string]*InstanceLogBuffer{}, } } -func (pm *ProcessManager) StartInstance(name string, version string, options VsServerConfigOptions, config *AppConfig) error { +func (pm *ProcessManager) StartInstance(name string, version string, meta InstanceMetadata, config *AppConfig) error { pm.Lock() defer pm.Unlock() @@ -65,6 +74,7 @@ func (pm *ProcessManager) StartInstance(name string, version string, options VsS pm.ActiveInstances[name] = cmd pm.StdinPipes[name] = stdinPipe + pm.LogBuffers[name] = NewInstanceLogBuffer(200) go pm.streamLogs(name, stdoutPipe) go pm.watchProcessExit(name, cmd) @@ -78,9 +88,17 @@ func (pm *ProcessManager) streamLogs(name string, stdout io.ReadCloser) { scanner := bufio.NewScanner(stdout) for scanner.Scan() { - //stream straight to the manager console terminal, - // but later dispatch payloads to our web UI or a log file - fmt.Printf("[%s]: %s\n", name, scanner.Text()) + line := scanner.Text() + + fmt.Printf("[%s]: %s\n", name, line) + + pm.RLock() + buf, exists := pm.LogBuffers[name] + pm.RUnlock() + + if exists { + buf.Append(line) + } } } @@ -107,6 +125,10 @@ func (pm *ProcessManager) SendCommand(name string, command string) error { return fmt.Errorf("Cannot send command, instance with name '%s' is not running", name) } + if command != "" && !strings.HasPrefix(command, "/") { + command = "/" + command + } + _, err := io.WriteString(pipe, command+"\n") if err != nil { return fmt.Errorf("failed writing to server process stdin conduit: %w", err) @@ -114,3 +136,31 @@ func (pm *ProcessManager) SendCommand(name string, command string) error { return nil } + +func NewInstanceLogBuffer(maxLines int) *InstanceLogBuffer { + return &InstanceLogBuffer{ + Lines: make([]string, 0, maxLines), + MaxLines: maxLines, + } +} + +func (lb *InstanceLogBuffer) Append(line string) { + lb.Lock() + defer lb.Unlock() + + if len(lb.Lines) >= lb.MaxLines { + // Shift array out by dropping index 0 + lb.Lines = lb.Lines[1:] + } + lb.Lines = append(lb.Lines, line) +} + +func (lb *InstanceLogBuffer) GetSnapshot() []string { + lb.RLock() + defer lb.RUnlock() + + // Return a copy so the caller can safely iterate or serialize to JSON without lock conflicts + snapshot := make([]string, len(lb.Lines)) + copy(snapshot, lb.Lines) + return snapshot +}