Implement automatic update functionality and improve server initialization; add NODE_PATH to Makefile, enhance logging, and update README

This commit is contained in:
alex
2025-07-05 22:13:16 +03:00
parent 2fdc32ce9f
commit 66f3d12412
14 changed files with 271 additions and 72 deletions

View File

@@ -43,18 +43,16 @@ type Internal struct {
}
type Updates struct {
UpdatesEnabled bool `yaml:"enabled" env-default:"false"`
AllowAutoUpdates bool `yaml:"allow_auto_updates" env-default:"false"`
AllowUpdates bool `yaml:"allow_updates" env-default:"false"`
AllowDowngrades bool `yaml:"allow_downgrades" env-default:"false"`
CheckInterval time.Duration `yaml:"check_interval" env-default:"2h"`
RepositoryURL string `yaml:"repository_url" env-default:""`
WantedVersion string `yaml:"wanted_version" env-default:"latest-stable"`
UpdatesEnabled bool `yaml:"enabled" env-default:"false"`
CheckInterval time.Duration `yaml:"check_interval" env-default:"2h"`
RepositoryURL string `yaml:"repository_url" env-default:""`
WantedVersion string `yaml:"wanted_version" env-default:"latest-stable"`
}
// ConfigEnv structure for environment variables
type ConfigEnv struct {
ConfigPath string `env:"CONFIG_PATH" env-default:"./cfg/config.yaml"`
NodePath string `env:"NODE_PATH" env-default:"./"`
}
// MustLoadConfig loads the configuration from the specified path and environment variables.

View File

@@ -1,7 +1,9 @@
package config
import "os"
// UUIDLength is uuids length for sessions. By default it is 16 bytes.
var UUIDLength byte = 4
var UUIDLength byte = 16
// ApiRoute setting for go-chi for main route for api requests
var ApiRoute string = "/api/{ver}"
@@ -17,6 +19,12 @@ var NodeVersion string
// In the repository, the file specified in the variable contains the current information about updates
var ActualFileName string = "actual.txt"
// UpdateArchiveName is the name of the archive that will be used for updates.
var UpdateArchiveName string = "gosally-node"
// UpdateInstallPath is the path where the update will be installed.
var UpdateDownloadPath string = os.TempDir()
type _internalConsts struct{}
type _serverConsts struct{}
type _updateConsts struct{}
@@ -28,7 +36,9 @@ func (_ _updateConsts) GetNodeVersion() string {
}
return NodeVersion
}
func (_ _updateConsts) GetActualFileName() string { return ActualFileName }
func (_ _updateConsts) GetActualFileName() string { return ActualFileName }
func (_ _updateConsts) GetUpdateArchiveName() string { return UpdateArchiveName }
func (_ _updateConsts) GetUpdateDownloadPath() string { return UpdateDownloadPath }
func GetInternalConsts() _internalConsts { return _internalConsts{} }
func (_ _internalConsts) GetUUIDLength() byte { return UUIDLength }

45
core/init/init.go Normal file
View File

@@ -0,0 +1,45 @@
package init
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"syscall"
)
func init() {
if strings.HasPrefix(os.Args[0], "/tmp") {
return
}
runPath, err := os.MkdirTemp("", "*-gs-runtime")
log.SetOutput(os.Stderr)
input, err := os.Open(os.Args[0])
if err != nil {
log.Fatalf("Failed to init node: %s", err)
}
runBinaryPath := filepath.Join(runPath, "node")
output, err := os.Create(runBinaryPath)
if err != nil {
log.Fatalf("Failed to init node: %s", err)
}
if _, err := io.Copy(output, input); err != nil {
log.Fatalf("Failed to init node: %s", err)
}
// Делаем исполняемым (на всякий случай)
if err := os.Chmod(runBinaryPath, 0755); err != nil {
log.Fatalf("Failed to init node: %s", err)
}
input.Close()
output.Close()
runArgs := os.Args
runArgs[0] = runBinaryPath
if err := syscall.Exec(runBinaryPath, runArgs, append(os.Environ(), "GS_RUNTIME_PATH=" + runPath)); err != nil {
log.Fatalf("Failed to init node: %s", err)
}
}

View File

@@ -13,46 +13,46 @@ import (
)
// HandlerV1 is the main handler for version 1 of the API.
// The function processes the HTTP request and runs Lua scripts,
// The function processes the HTTP request and runs Lua scripts,
// preparing the environment and subsequently transmitting the execution result
func (h *HandlerV1) Handle(w http.ResponseWriter, r *http.Request) {
uuid16, err := utils.NewUUID()
if err != nil {
h.log.Error("Failed to generate UUID",
slog.String("error", err.Error()))
utils.WriteJSONError(h.w, http.StatusInternalServerError, "failed to generate UUID: "+err.Error())
utils.WriteJSONError(w, http.StatusInternalServerError, "failed to generate UUID: "+err.Error())
return
}
log := h.log.With(
slog.Group("request",
slog.String("version", h.GetVersion()),
slog.String("url", h.r.URL.String()),
slog.String("method", h.r.Method),
slog.String("url", r.URL.String()),
slog.String("method", r.Method),
),
slog.Group("connection",
slog.String("connection-uuid", uuid16),
slog.String("remote", h.r.RemoteAddr),
slog.String("remote", r.RemoteAddr),
),
)
log.Info("Received request")
cmd := chi.URLParam(h.r, "cmd")
cmd := chi.URLParam(r, "cmd")
if !h.allowedCmd.MatchString(string([]rune(cmd)[0])) || !h.listAllowedCmd.MatchString(cmd) {
log.Error("HTTP request error",
slog.String("error", "invalid command"),
slog.String("cmd", cmd),
slog.Int("status", http.StatusBadRequest))
utils.WriteJSONError(h.w, http.StatusBadRequest, "invalid command")
utils.WriteJSONError(w, http.StatusBadRequest, "invalid command")
return
}
scriptPath := h.comMatch(chi.URLParam(h.r, "ver"), cmd)
scriptPath := h.comMatch(chi.URLParam(r, "ver"), cmd)
if scriptPath == "" {
log.Error("HTTP request error",
slog.String("error", "command not found"),
slog.String("cmd", cmd),
slog.Int("status", http.StatusNotFound))
utils.WriteJSONError(h.w, http.StatusNotFound, "command not found")
utils.WriteJSONError(w, http.StatusNotFound, "command not found")
return
}
@@ -62,7 +62,7 @@ func (h *HandlerV1) Handle(w http.ResponseWriter, r *http.Request) {
slog.String("error", "command not found"),
slog.String("cmd", cmd),
slog.Int("status", http.StatusNotFound))
utils.WriteJSONError(h.w, http.StatusNotFound, "command not found")
utils.WriteJSONError(w, http.StatusNotFound, "command not found")
return
}
@@ -70,7 +70,7 @@ func (h *HandlerV1) Handle(w http.ResponseWriter, r *http.Request) {
defer L.Close()
paramsTable := L.NewTable()
qt := h.r.URL.Query()
qt := r.URL.Query()
for k, v := range qt {
if len(v) > 0 {
L.SetField(paramsTable, k, lua.LString(v[0]))
@@ -91,7 +91,7 @@ func (h *HandlerV1) Handle(w http.ResponseWriter, r *http.Request) {
if err := L.DoFile(prepareLuaEnv); err != nil {
log.Error("Failed to prepare lua environment",
slog.String("error", err.Error()))
utils.WriteJSONError(h.w, http.StatusInternalServerError, "lua error: "+err.Error())
utils.WriteJSONError(w, http.StatusInternalServerError, "lua error: "+err.Error())
return
}
} else {
@@ -101,7 +101,7 @@ func (h *HandlerV1) Handle(w http.ResponseWriter, r *http.Request) {
if err := L.DoFile(scriptPath); err != nil {
log.Error("Failed to execute lua script",
slog.String("error", err.Error()))
utils.WriteJSONError(h.w, http.StatusInternalServerError, "lua error: "+err.Error())
utils.WriteJSONError(w, http.StatusInternalServerError, "lua error: "+err.Error())
return
}
@@ -109,7 +109,7 @@ func (h *HandlerV1) Handle(w http.ResponseWriter, r *http.Request) {
tbl, ok := lv.(*lua.LTable)
if !ok {
log.Error("Lua global 'Out' is not a table")
utils.WriteJSONError(h.w, http.StatusInternalServerError, "'Out' is not a table")
utils.WriteJSONError(w, http.StatusInternalServerError, "'Out' is not a table")
return
}
@@ -117,7 +117,7 @@ func (h *HandlerV1) Handle(w http.ResponseWriter, r *http.Request) {
resultTbl, ok := resultVal.(*lua.LTable)
if !ok {
log.Error("Lua global 'Result' is not a table")
utils.WriteJSONError(h.w, http.StatusInternalServerError, "'Result' is not a table")
utils.WriteJSONError(w, http.StatusInternalServerError, "'Result' is not a table")
return
}
@@ -126,8 +126,8 @@ func (h *HandlerV1) Handle(w http.ResponseWriter, r *http.Request) {
out[key.String()] = utils.ConvertLuaTypesToGolang(value)
})
h.w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(h.w).Encode(out); err != nil {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(out); err != nil {
log.Error("Failed to encode JSON response",
slog.String("error", err.Error()))
}

View File

@@ -18,18 +18,18 @@ func (h *HandlerV1) HandleList(w http.ResponseWriter, r *http.Request) {
if err != nil {
h.log.Error("Failed to generate UUID",
slog.String("error", err.Error()))
utils.WriteJSONError(h.w, http.StatusInternalServerError, "failed to generate UUID: "+err.Error())
utils.WriteJSONError(w, http.StatusInternalServerError, "failed to generate UUID: "+err.Error())
return
}
log := h.log.With(
slog.Group("request",
slog.String("version", h.GetVersion()),
slog.String("url", h.r.URL.String()),
slog.String("method", h.r.Method),
slog.String("url", r.URL.String()),
slog.String("method", r.Method),
),
slog.Group("connection",
slog.String("connection-uuid", uuid16),
slog.String("remote", h.r.RemoteAddr),
slog.String("remote", r.RemoteAddr),
),
)
log.Info("Received request")
@@ -46,11 +46,11 @@ func (h *HandlerV1) HandleList(w http.ResponseWriter, r *http.Request) {
if files, err = os.ReadDir(h.cfg.ComDir); err != nil {
log.Error("Failed to read commands directory",
slog.String("error", err.Error()))
utils.WriteJSONError(h.w, http.StatusInternalServerError, "failed to read commands directory: "+err.Error())
utils.WriteJSONError(w, http.StatusInternalServerError, "failed to read commands directory: "+err.Error())
return
}
apiVer := chi.URLParam(h.r, "ver")
apiVer := chi.URLParam(r, "ver")
// Сначала ищем версионные
for _, file := range files {
@@ -110,8 +110,8 @@ func (h *HandlerV1) HandleList(w http.ResponseWriter, r *http.Request) {
log.Info("Session completed")
h.w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(h.w).Encode(commands); err != nil {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(commands); err != nil {
h.log.Error("Failed to write JSON error response",
slog.String("error", err.Error()))
}

View File

@@ -4,7 +4,6 @@ package sv1
import (
"log/slog"
"net/http"
"regexp"
"github.com/akyaiy/GoSally-mvp/core/config"
@@ -21,9 +20,6 @@ type HandlerV1InitStruct struct {
// HandlerV1 implements the ServerV1UtilsContract and serves as the main handler for API requests.
type HandlerV1 struct {
w http.ResponseWriter
r *http.Request
log slog.Logger
cfg *config.ConfigConf

View File

@@ -2,21 +2,18 @@ package sv1
import (
"log/slog"
"net/http"
"os"
"regexp"
"github.com/akyaiy/GoSally-mvp/core/utils"
)
func (h *HandlerV1) errNotFound(w http.ResponseWriter, r *http.Request) {
utils.WriteJSONError(h.w, http.StatusBadRequest, "invalid request")
h.log.Error("HTTP request error",
slog.String("remote", h.r.RemoteAddr),
slog.String("method", h.r.Method),
slog.String("url", h.r.URL.String()),
slog.Int("status", http.StatusBadRequest))
}
// func (h *HandlerV1) errNotFound(w http.ResponseWriter, r *http.Request) {
// utils.WriteJSONError(h.w, http.StatusBadRequest, "invalid request")
// h.log.Error("HTTP request error",
// slog.String("remote", h.r.RemoteAddr),
// slog.String("method", h.r.Method),
// slog.String("url", h.r.URL.String()),
// slog.Int("status", http.StatusBadRequest))
// }
func (h *HandlerV1) extractDescriptionStatic(path string) (string, error) {
data, err := os.ReadFile(path)

View File

@@ -1,10 +1,15 @@
package update
import (
"archive/tar"
"compress/gzip"
"errors"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@@ -187,15 +192,133 @@ func (u *Updater) CkeckUpdates() (IsNewUpdate, error) {
if currentVersion == latestVersion && currentBranch == latestBranch {
return false, nil
}
u.Log.Info("New update available",
slog.String("current_version", string(currentVersion)),
slog.String("current_branch", string(currentBranch)),
slog.String("latest_version", string(latestVersion)),
slog.String("latest_branch", string(latestBranch)),
)
return true, nil
}
func (u *Updater) Update() error {
if !(u.Config.UpdatesEnabled) {
return errors.New("updates are disabled in config, skipping update")
}
downloadPath, err := os.MkdirTemp("", "*-gs-up")
if err != nil {
return errors.New("failed to create temp dir " + err.Error())
}
//defer os.RemoveAll(downloadPath)
_, currentBranch, err := u.GetCurrentVersion()
if err != nil {
return errors.New("failed to get current version: " + err.Error())
}
latestVersion, latestBranch, err := u.GetLatestVersion(currentBranch)
if err != nil {
return errors.New("failed to get latest version: " + err.Error())
}
updateArchiveName := config.GetUpdateConsts().GetUpdateArchiveName() + ".v" + string(latestVersion) + "-" + string(latestBranch)
updateDest := u.Config.Updates.RepositoryURL + "/" + updateArchiveName + ".tar.gz"
resp, err := http.Get(updateDest)
if err != nil {
return errors.New("failed to fetch latest version archive: " + err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return errors.New("failed to fetch latest version archive: status " + resp.Status + ", body: " + string(body))
}
gzReader, err := gzip.NewReader(resp.Body)
if err != nil {
return errors.New("failed to create gzip reader: " + err.Error())
}
defer gzReader.Close()
tarReader := tar.NewReader(gzReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break // archive is fully read
}
if err != nil {
return errors.New("failed to read tar header: " + err.Error())
}
targetPath := filepath.Join(downloadPath, header.Name)
switch header.Typeflag {
case tar.TypeDir:
// Создаём директорию
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
return errors.New("failed to create directory: " + err.Error())
}
case tar.TypeReg:
// Создаём директорию, если её ещё нет
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return errors.New("failed to create directory for file: " + err.Error())
}
// Создаём файл
outFile, err := os.Create(targetPath)
if err != nil {
return errors.New("failed to create file: " + err.Error())
}
// Копируем содержимое
if _, err := io.Copy(outFile, tarReader); err != nil {
outFile.Close()
return errors.New("failed to copy file content: " + err.Error())
}
outFile.Close()
default:
return errors.New("unsupported tar entry type: " + string(header.Typeflag))
}
}
return u.InstallAndRestart(filepath.Join(downloadPath, updateArchiveName, "node"))
}
func (u *Updater) InstallAndRestart(newBinaryPath string) error {
nodePath := os.Getenv("NODE_PATH")
if nodePath == "" {
return errors.New("NODE_PATH environment variable is not set")
}
installDir := filepath.Join(nodePath, "bin")
targetPath := filepath.Join(installDir, "node")
// Копируем новый бинарник
input, err := os.Open(newBinaryPath)
if err != nil {
return err
}
output, err := os.Create(targetPath)
if err != nil {
return err
}
if _, err := io.Copy(output, input); err != nil {
return err
}
if err := os.Chmod(targetPath, 0755); err != nil {
return errors.New("failed to chmod file: " + err.Error())
}
input.Close()
output.Close()
// Запускаем новый процесс
u.Log.Info("Launching new version...", slog.String("path", targetPath))
cmd := exec.Command(targetPath, os.Args[1:]...)
cmd.Env = os.Environ()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = nil
if err = cmd.Start(); err != nil {
return err
}
// if err := syscall.Exec(targetPath, os.Args, os.Environ()); err != nil {
// u.Log.Error("Failed to run new version automatickly", slog.String("err", err.Error()))
// return err
// }
u.Log.Info("Shutting down")
os.Exit(0)
return errors.New("failed to shutdown the process")
}
// func (u *Updater) Update() error {
// if !(u.Config.UpdatesEnabled && u.Config.Updates.AllowUpdates && u.Config.Updates.AllowDowngrades) {
// u.Log.Info("Updates are disabled in config, skipping update")