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

@@ -2,6 +2,8 @@ APP_NAME := node
BIN_DIR := bin
GOPATH := $(shell go env GOPATH)
export CONFIG_PATH := ./config.yaml
export NODE_PATH := $(shell pwd)
LDFLAGS := -X 'github.com/akyaiy/GoSally-mvp/core/config.NodeVersion=v0.0.1-dev'
CGO_CFLAGS := -I/usr/local/include
CGO_LDFLAGS := -L/usr/local/lib -llua5.1 -lm -ldl
@@ -38,6 +40,10 @@ runq: build
@echo "Running!"
./$(BIN_DIR)/$(APP_NAME) | jq
pure-run:
@echo "Running!"
./$(BIN_DIR)/$(APP_NAME) | jq
test:
@go test ./... | grep -v '^?' || true

View File

@@ -4,7 +4,7 @@
System that allows you to build your own infrastructure based on identical nodes and various scripts written using built-in Lua 5.1, shebang scripts (scripts that start with the `#!` symbols), compiled binaries.
### Features
Go Sally is not viable at the moment, but it already has the ability to run embedded scripts, log slog events to stdout, and handle RPC like requests.
Go Sally is not viable at the moment, but it already has the ability to run embedded scripts, log slog events to stdout, handle RPC like requests, and independent automatic update from the repository (my pride, to be honest).
### Example of use
The basic directory tree looks something like this

View File

@@ -1,19 +1,21 @@
package main
import (
"fmt"
"log/slog"
"net"
"net/http"
"regexp"
"time"
"golang.org/x/net/netutil"
"github.com/akyaiy/GoSally-mvp/core/config"
gs "github.com/akyaiy/GoSally-mvp/core/general_server"
_ "github.com/akyaiy/GoSally-mvp/core/init"
"github.com/akyaiy/GoSally-mvp/core/logs"
"github.com/akyaiy/GoSally-mvp/core/sv1"
"github.com/akyaiy/GoSally-mvp/core/update"
"github.com/go-chi/cors"
"github.com/go-chi/chi/v5"
)
@@ -27,20 +29,37 @@ func init() {
log = logs.SetupLogger(cfg.Mode)
log = log.With("mode", cfg.Mode)
log.Info("Initializing server", slog.String("address", cfg.HTTPServer.Address))
currentV, currentB, _ := update.NewUpdater(*log, cfg).GetCurrentVersion()
log.Info("Initializing server", slog.String("address", cfg.HTTPServer.Address), slog.String("version", string(currentV)+"-"+string(currentB)))
log.Debug("Server running in debug mode")
}
func UpdateDaemon(u *update.Updater, cfg config.ConfigConf) {
for {
isNewUpdate, err := u.CkeckUpdates()
if err != nil {
log.Error("Failed to check for updates", slog.String("error", err.Error()))
}
if isNewUpdate {
log.Info("New update available, starting update process...")
err = u.Update()
if err != nil {
log.Error("Failed to update", slog.String("error", err.Error()))
} else {
log.Info("Update completed successfully")
}
} else {
log.Info("No new updates available")
}
time.Sleep(cfg.CheckInterval)
}
}
func main() {
updater := update.NewUpdater(*log, cfg)
versuion, versionType, _ := updater.GetCurrentVersion()
fmt.Printf("Current version: %s (%s)\n", versuion, versionType)
ver, vert, _ := updater.GetLatestVersion(versionType)
fmt.Printf("Latest version: %s (%s)\n", ver, vert)
go UpdateDaemon(updater, *cfg)
fmt.Println("Checking for updates...")
isNewUpdate, _ := updater.CkeckUpdates()
fmt.Println("Update check result:", isNewUpdate)
serverv1 := sv1.InitV1Server(&sv1.HandlerV1InitStruct{
Log: *log,
Config: cfg,
@@ -54,6 +73,13 @@ func main() {
}, serverv1)
r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Route(config.GetServerConsts().GetApiRoute()+config.GetServerConsts().GetComDirRoute(), func(r chi.Router) {
r.Get("/", s.HandleList)
r.Get("/{cmd}", s.Handle)

View File

@@ -1,7 +1,7 @@
mode: "dev"
http_server:
address: "0.0.0.0:8080"
address: "192.168.1.176:8080"
timeout: 3s
idle_timeout: 30s
api:
@@ -23,10 +23,5 @@ com_dir: "com/"
updates:
enabled: true
allow-auto-updates: true
allow-updates: true
allow-downgrades: false
check-interval: 1h
repository_url: "https://repo.serve.lv/raw/go-sally"
wanted-version: "latest-stable"

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

@@ -20,39 +20,39 @@ func (h *HandlerV1) Handle(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")
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")

1
go.mod
View File

@@ -11,6 +11,7 @@ require (
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/go-chi/cors v1.2.2
github.com/joho/godotenv v1.5.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect

2
go.sum
View File

@@ -3,6 +3,8 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=