feat: updated server implementation

This commit is contained in:
David Allen 2025-08-18 11:09:51 -06:00
parent 0d27f07a8b
commit d56a9e452f
Signed by: towk
GPG key ID: 0430CDBE22619155
5 changed files with 348 additions and 75 deletions

51
pkg/service/constants.go Normal file
View file

@ -0,0 +1,51 @@
package service
const (
RELPATH_PLUGINS = "/plugins"
RELPATH_PROFILES = "/profiles"
RELPATH_DATA = "/data"
RELPATH_METADATA = "/.configurator"
RELPATH_HELP = RELPATH_DATA + "/index.html"
DEFAULT_TIMEOUT_IN_SECS = 60
DEFAULT_PLUGINS_MAX_COUNT = 64
DEFAULT_PROFILES_MAX_COUNT = 256
DEFAULT_METADATA = ``
DEFAULT_HOME = `
<!DOCTYPE html>
<html>
<body>
<p>
# setup environment variables</br>
export CONFIGURATOR_HOST={{ configurator.host }}</br>
export CONFIGURATOR_PATH={{ configurator.path }}</br>
export CONFIGURATOR_SERVER_ROOT={{ configurator.server_root }}</br>
</br>
# start the service</br>
configurator serve --root $HOME/apps/configurator/server --init</br>
</br>
# download a file or directory (as archive)</br>
configurator download</br>
configurator download --host http://localhost:5050 --path help.txt</br>
</br>
# download files with rendering using plugins</br>
configurator download --plugins smd,jinja2 --profile compute</br>
curl $CONFIGURATOR_HOST/download/help.txt?plugins=smd,jinja2</br>
</br>
# upload a file or directory (recursively)</br>
configurator upload</br>
configurator upload --host http://localhost:5050 --path help.txt</br>
</br>
# list the files in a directory</br>
configurator list --path help.txt</br>
configurator list --host http://localhost:5050 --path help.txt</br>
curl http://localhost:5050/list/test</br>
</p>
<body>
</html>
`
)
// configurator.host: https://example.com
// configurator.path: test
// configurator.server_root: $HOME/apps/configurator

120
pkg/service/plugins.go Normal file
View file

@ -0,0 +1,120 @@
package service
import (
"encoding/json"
"io"
"net/http"
"os"
configurator "git.towk2.me/towk/configurator/pkg"
)
func (s *Service) ListPlugins() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
plugins map[string]configurator.Plugin
names []string
body []byte
err error
)
plugins, err = LoadPluginsFromDir(s.PathForPlugins())
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for name := range plugins {
names = append(names, name)
}
body, err = json.Marshal(names)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(body)
}
}
func (s *Service) CreatePlugin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
plugin configurator.Plugin
path string
err error
)
plugin, err = getPluginFromRequestBody(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// helper to check for valid plugin name
var hasValidName = func(name string) bool {
return name != "" && len(name) < 64
}
// check for a valid plugin name
if !hasValidName(plugin.Name()) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// save plugin at path using it's name
path = s.PathForPluginWithName(plugin.Name())
err = SavePluginToFile(path, &plugin)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
func (s *Service) DeletePlugin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
path string
plugin configurator.Plugin
err error
)
plugin, err = getPluginFromRequestBody(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
path = s.PathForPluginWithName(plugin.Name())
err = os.Remove(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
func getPluginFromRequestBody(r *http.Request) (configurator.Plugin, error) {
var (
plugin configurator.Plugin
body []byte
err error
)
body, err = io.ReadAll(r.Body)
if err != nil {
return nil, err
}
err = json.Unmarshal(body, &plugin)
if err != nil {
return nil, err
}
return plugin, nil
}

View file

@ -9,7 +9,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
configurator "git.towk2.me/towk/configurator/pkg"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
) )
@ -26,7 +25,7 @@ type Profile struct {
func (s *Service) GetProfiles() http.HandlerFunc { func (s *Service) GetProfiles() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var ( var (
path = s.RootPath + PROFILES_RELPATH path = s.RootPath + RELPATH_PROFILES
profiles []*Profile profiles []*Profile
contents []byte contents []byte
err error err error
@ -272,44 +271,6 @@ func (s *Service) GetProfileData() http.HandlerFunc {
// return func(w http.ResponseWriter, r *http.Request) {} // return func(w http.ResponseWriter, r *http.Request) {}
// } // }
func (s *Service) GetPlugins() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
plugins map[string]configurator.Plugin
names []string
body []byte
err error
)
plugins, err = LoadPluginsFromDir(s.PathForPlugins())
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for name := range plugins {
names = append(names, name)
}
body, err = json.Marshal(names)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(body)
}
}
func (s *Service) CreatePlugins() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func (s *Service) DeletePlugins() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {}
}
func loadProfileContents(path string) ([]byte, error) { func loadProfileContents(path string) ([]byte, error) {
var ( var (
contents []byte contents []byte

View file

@ -3,44 +3,91 @@ package service
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"git.towk2.me/towk/configurator/pkg/util" "git.towk2.me/towk/configurator/pkg/util"
"github.com/rs/zerolog/log"
) )
func (s *Service) Download() http.HandlerFunc { func (s *Service) Download() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var ( var (
path = strings.TrimPrefix(r.URL.Path, "/download") path = s.PathForData() + strings.TrimPrefix(r.URL.Path, "/download")
fileInfo os.FileInfo fileInfo os.FileInfo
out *os.File out *os.File
contents []byte
err error err error
) )
fmt.Printf("download path: %v\n", path) log.Debug().
Str("path", path).
Str("client_host", r.Host).
Msg("Service.Download()")
// determine if path is directory, file, or exists // determine if path is directory, file, or exists
if fileInfo, err = os.Stat(filepath.Clean(path)); err != nil { if fileInfo, err = os.Stat(path); err == nil {
if fileInfo.IsDir() { if fileInfo.IsDir() {
// recursively walk dir acompressednd get all filenames // create an archive of the directory and download
// download directory as archive log.Debug().
out, err = os.Create(fmt.Sprintf("%s.tar", path)) Str("type", "directory").
if err != nil { Msg("Service.Download()")
archivePath := fmt.Sprintf("%d.tar.gz", time.Now().Unix())
out, err = os.Create(archivePath)
if err != nil {
s.writeErrorResponse(w, fmt.Sprintf("failed to create named file: %v", err), http.StatusInternalServerError)
return
} }
err = util.CreateArchive([]string{path}, out)
if err != nil {
filesToArchive := []string{}
filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
filesToArchive = append(filesToArchive, path)
}
return nil
})
log.Debug().Strs("files", filesToArchive).Send()
err = util.CreateArchive(filesToArchive, out)
if err != nil {
s.writeErrorResponse(w, fmt.Sprintf("failed to create archive: %v", err.Error()), http.StatusInternalServerError)
return
}
contents, err = os.ReadFile(archivePath)
if err != nil {
s.writeErrorResponse(w, fmt.Sprintf("failed to read archive: %v", err.Error()), http.StatusInternalServerError)
return
}
w.Write(contents)
err = os.Remove(archivePath)
if err != nil {
log.Error().Err(err).Msg("failed to remove temporary archive")
return
} }
} else { } else {
// download individual file // download individual file
log.Debug().
Str("type", "file").
Msg("Service.Download()")
contents, err = os.ReadFile(path)
if err != nil {
s.writeErrorResponse(w, fmt.Sprintf("failed to read file to download: %v", err.Error()), http.StatusInternalServerError)
return
} }
w.Write(contents)
}
} else {
s.writeErrorResponse(w, err.Error(), http.StatusBadRequest)
return
} }
} }
} }
@ -52,19 +99,54 @@ func (s *Service) Upload() http.HandlerFunc {
func (s *Service) List() http.HandlerFunc { func (s *Service) List() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var (
path = s.PathForData() + strings.TrimPrefix(r.URL.Path, "/list")
entries []string
body []byte
err error
)
// show what we're listing
log.Debug().Str("path", path).Msg("Service.List()")
// walk directory and show all entries "ls"
err = filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
entries = append(entries, d.Name())
return nil
})
if err != nil {
switch err {
case fs.ErrNotExist, fs.ErrInvalid:
http.Error(w, "No such file or directory...", http.StatusBadRequest)
case fs.ErrPermission:
http.Error(w, "Invalid permissions...", http.StatusForbidden)
default:
http.Error(w, "Something went wrong (file or directory *probably* does not exist)...", http.StatusInternalServerError)
}
return
}
body, err = json.Marshal(entries)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(body)
} }
} }
func (s *Service) GetStatus(w http.ResponseWriter, r *http.Request) { func (s *Service) GetStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
data := map[string]any{ err := json.NewEncoder(w).Encode(map[string]any{
"code": 200, "code": http.StatusOK,
"message": "Configurator is healthy", "message": "Configurator is healthy",
} })
err := json.NewEncoder(w).Encode(data)
if err != nil { if err != nil {
fmt.Printf("failed to encode JSON: %v\n", err) fmt.Printf("failed to encode JSON response body: %v\n", err)
return return
} }
} }

View file

@ -15,36 +15,69 @@ import (
"git.towk2.me/towk/configurator/pkg/util" "git.towk2.me/towk/configurator/pkg/util"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/middleware"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) "github.com/rs/zerolog/log"
const (
PLUGINS_RELPATH = "/plugins"
TEMPLATES_RELPATH = "/templates"
PROFILES_RELPATH = "/profiles"
) )
type Service struct { type Service struct {
Addr string
RootPath string `yaml:"root,omitempty"` RootPath string `yaml:"root,omitempty"`
Environment map[string]string Environment map[string]string
// max counts // max counts
PluginsMaxCount int PluginsMaxCount int
ProfilesMaxCount int ProfilesMaxCount int
Timeout time.Duration
} }
// New creates the directories at specified path // New creates a new Service instance with default values
func New() *Service { func New() *Service {
return &Service{ return &Service{
RootPath: ".", Addr: ":5050",
RootPath: "./",
Environment: map[string]string{ Environment: map[string]string{
"CONFIGURATOR_HOST_URI": "", "CONFIGURATOR_HOST": "",
"CONFIGURATOR_ROOT": "",
"ACCESS_TOKEN": "", "ACCESS_TOKEN": "",
}, },
PluginsMaxCount: 64, PluginsMaxCount: DEFAULT_PLUGINS_MAX_COUNT,
ProfilesMaxCount: 256, ProfilesMaxCount: DEFAULT_PROFILES_MAX_COUNT,
Timeout: DEFAULT_TIMEOUT_IN_SECS,
} }
} }
// Init() sets up the default files and directories for the service
func (s *Service) Init() error {
// create the default directories
var err error
err = os.MkdirAll(s.RootPath, 0o777)
if err != nil {
return fmt.Errorf("failed to make service root path: %v", err)
}
err = os.MkdirAll(s.PathForPlugins(), 0o777)
if err != nil {
return fmt.Errorf("failed to make service plugin path: %v", err)
}
err = os.MkdirAll(s.PathForProfiles(), 0o777)
if err != nil {
return fmt.Errorf("failed to make service profile path: %v", err)
}
err = os.MkdirAll(s.PathForData(), 0o777)
if err != nil {
return fmt.Errorf("failed to make service profile path: %v", err)
}
// create the default files
err = os.WriteFile(s.PathForMetadata(), []byte(DEFAULT_METADATA), 0o777)
if err != nil {
return fmt.Errorf("failed to make service metadata file: %v", err)
}
err = os.WriteFile(s.PathForHome(), []byte(DEFAULT_HOME), 0o777)
if err != nil {
return fmt.Errorf("failed to make service metadata file: %v", err)
}
return nil
}
// Serve() starts the configurator service and waits for requests. // Serve() starts the configurator service and waits for requests.
func (s *Service) Serve() error { func (s *Service) Serve() error {
router := chi.NewRouter() router := chi.NewRouter()
@ -53,15 +86,16 @@ func (s *Service) Serve() error {
router.Use(middleware.Logger) router.Use(middleware.Logger)
router.Use(middleware.Recoverer) router.Use(middleware.Recoverer)
router.Use(middleware.StripSlashes) router.Use(middleware.StripSlashes)
router.Use(middleware.Timeout(60 * time.Second)) router.Use(middleware.Timeout(s.Timeout * time.Second))
if s.requireAuth() { if s.requireAuth() {
} else { } else {
// general // general
// router.Handle("/download/*", http.StripPrefix("/download/", http.FileServer(http.Dir(s.PathForData()))))
router.Get("/download/*", s.Download()) router.Get("/download/*", s.Download())
router.Post("/upload", s.Upload()) router.Post("/upload", s.Upload())
router.Get("/list", s.List()) router.Get("/list/*", s.List())
// profiles // profiles
router.Get("/profiles", s.GetProfiles()) router.Get("/profiles", s.GetProfiles())
@ -76,14 +110,14 @@ func (s *Service) Serve() error {
// router.Get("/profile/{id}/paths/{path}", s.GetProfilePath()) // router.Get("/profile/{id}/paths/{path}", s.GetProfilePath())
// plugins // plugins
router.Get("/plugins", s.GetPlugins()) router.Get("/plugins", s.ListPlugins())
router.Post("/plugins", s.CreatePlugins()) router.Post("/plugins", s.CreatePlugin())
router.Delete("/plugins/{id}", s.DeletePlugins()) router.Delete("/plugins/{id}", s.DeletePlugin())
} }
// always available public routes go here // always available public routes go here
router.HandleFunc("/status", s.GetStatus) router.HandleFunc("/status", s.GetStatus)
return http.ListenAndServe(":8080", router) return http.ListenAndServe(s.Addr, router)
} }
func (s *Service) requireAuth() bool { func (s *Service) requireAuth() bool {
@ -225,9 +259,34 @@ func saveToJSONFile[T any](path string, data T) error {
} }
func (s *Service) PathForProfileWithID(id string) string { func (s *Service) PathForProfileWithID(id string) string {
return s.RootPath + PROFILES_RELPATH + "/" + id return s.RootPath + RELPATH_PROFILES + "/" + id
}
func (s *Service) PathForPluginWithName(name string) string {
return s.RootPath + RELPATH_PLUGINS + "/" + name
}
func (s *Service) PathForProfiles() string {
return s.RootPath + RELPATH_PROFILES + "/"
} }
func (s *Service) PathForPlugins() string { func (s *Service) PathForPlugins() string {
return s.RootPath + PLUGINS_RELPATH return s.RootPath + RELPATH_PLUGINS + "/"
}
func (s *Service) PathForData() string {
return s.RootPath + RELPATH_DATA
}
func (s *Service) PathForMetadata() string {
return s.RootPath + RELPATH_METADATA
}
func (s *Service) PathForHome() string {
return s.RootPath + RELPATH_HELP
}
func (s *Service) writeErrorResponse(w http.ResponseWriter, message string, code int) {
http.Error(w, message, code)
log.Error().Msg(message)
} }