diff --git a/pkg/service/constants.go b/pkg/service/constants.go new file mode 100644 index 0000000..52e990a --- /dev/null +++ b/pkg/service/constants.go @@ -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 = ` + + + +

+ # setup environment variables
+ export CONFIGURATOR_HOST={{ configurator.host }}
+ export CONFIGURATOR_PATH={{ configurator.path }}
+ export CONFIGURATOR_SERVER_ROOT={{ configurator.server_root }}
+
+ # start the service
+ configurator serve --root $HOME/apps/configurator/server --init
+
+ # download a file or directory (as archive)
+ configurator download
+ configurator download --host http://localhost:5050 --path help.txt
+
+ # download files with rendering using plugins
+ configurator download --plugins smd,jinja2 --profile compute
+ curl $CONFIGURATOR_HOST/download/help.txt?plugins=smd,jinja2
+
+ # upload a file or directory (recursively)
+ configurator upload
+ configurator upload --host http://localhost:5050 --path help.txt
+
+ # list the files in a directory
+ configurator list --path help.txt
+ configurator list --host http://localhost:5050 --path help.txt
+ curl http://localhost:5050/list/test
+

+ + +` +) + +// configurator.host: https://example.com +// configurator.path: test +// configurator.server_root: $HOME/apps/configurator diff --git a/pkg/service/plugins.go b/pkg/service/plugins.go new file mode 100644 index 0000000..c3a6c43 --- /dev/null +++ b/pkg/service/plugins.go @@ -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 +} diff --git a/pkg/service/profile.go b/pkg/service/profile.go index 2fa7c2d..9ba7f2e 100644 --- a/pkg/service/profile.go +++ b/pkg/service/profile.go @@ -9,7 +9,6 @@ import ( "os" "path/filepath" - configurator "git.towk2.me/towk/configurator/pkg" "github.com/go-chi/chi/v5" "github.com/tidwall/sjson" ) @@ -26,7 +25,7 @@ type Profile struct { func (s *Service) GetProfiles() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var ( - path = s.RootPath + PROFILES_RELPATH + path = s.RootPath + RELPATH_PROFILES profiles []*Profile contents []byte err error @@ -272,44 +271,6 @@ func (s *Service) GetProfileData() http.HandlerFunc { // 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) { var ( contents []byte diff --git a/pkg/service/routes.go b/pkg/service/routes.go index 21781df..eb6f6cc 100644 --- a/pkg/service/routes.go +++ b/pkg/service/routes.go @@ -3,44 +3,91 @@ package service import ( "encoding/json" "fmt" + "io/fs" "net/http" "os" "path/filepath" "strings" + "time" "git.towk2.me/towk/configurator/pkg/util" + "github.com/rs/zerolog/log" ) func (s *Service) Download() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var ( - path = strings.TrimPrefix(r.URL.Path, "/download") + path = s.PathForData() + strings.TrimPrefix(r.URL.Path, "/download") fileInfo os.FileInfo out *os.File + contents []byte 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 - if fileInfo, err = os.Stat(filepath.Clean(path)); err != nil { + if fileInfo, err = os.Stat(path); err == nil { if fileInfo.IsDir() { - // recursively walk dir acompressednd get all filenames - // download directory as archive - out, err = os.Create(fmt.Sprintf("%s.tar", path)) - if err != nil { + // create an archive of the directory and download + log.Debug(). + Str("type", "directory"). + 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 { // 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 { 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) { w.Header().Set("Content-Type", "application/json") - data := map[string]any{ - "code": 200, + err := json.NewEncoder(w).Encode(map[string]any{ + "code": http.StatusOK, "message": "Configurator is healthy", - } - err := json.NewEncoder(w).Encode(data) + }) if err != nil { - fmt.Printf("failed to encode JSON: %v\n", err) + fmt.Printf("failed to encode JSON response body: %v\n", err) return } } diff --git a/pkg/service/service.go b/pkg/service/service.go index c208b15..528cdac 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -15,36 +15,69 @@ import ( "git.towk2.me/towk/configurator/pkg/util" "github.com/go-chi/chi/middleware" "github.com/go-chi/chi/v5" -) - -const ( - PLUGINS_RELPATH = "/plugins" - TEMPLATES_RELPATH = "/templates" - PROFILES_RELPATH = "/profiles" + "github.com/rs/zerolog/log" ) type Service struct { + Addr string RootPath string `yaml:"root,omitempty"` Environment map[string]string // max counts PluginsMaxCount 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 { return &Service{ - RootPath: ".", + Addr: ":5050", + RootPath: "./", Environment: map[string]string{ - "CONFIGURATOR_HOST_URI": "", - "ACCESS_TOKEN": "", + "CONFIGURATOR_HOST": "", + "CONFIGURATOR_ROOT": "", + "ACCESS_TOKEN": "", }, - PluginsMaxCount: 64, - ProfilesMaxCount: 256, + PluginsMaxCount: DEFAULT_PLUGINS_MAX_COUNT, + 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. func (s *Service) Serve() error { router := chi.NewRouter() @@ -53,15 +86,16 @@ func (s *Service) Serve() error { router.Use(middleware.Logger) router.Use(middleware.Recoverer) router.Use(middleware.StripSlashes) - router.Use(middleware.Timeout(60 * time.Second)) + router.Use(middleware.Timeout(s.Timeout * time.Second)) if s.requireAuth() { } else { // general + // router.Handle("/download/*", http.StripPrefix("/download/", http.FileServer(http.Dir(s.PathForData())))) router.Get("/download/*", s.Download()) router.Post("/upload", s.Upload()) - router.Get("/list", s.List()) + router.Get("/list/*", s.List()) // profiles router.Get("/profiles", s.GetProfiles()) @@ -76,14 +110,14 @@ func (s *Service) Serve() error { // router.Get("/profile/{id}/paths/{path}", s.GetProfilePath()) // plugins - router.Get("/plugins", s.GetPlugins()) - router.Post("/plugins", s.CreatePlugins()) - router.Delete("/plugins/{id}", s.DeletePlugins()) + router.Get("/plugins", s.ListPlugins()) + router.Post("/plugins", s.CreatePlugin()) + router.Delete("/plugins/{id}", s.DeletePlugin()) } // always available public routes go here router.HandleFunc("/status", s.GetStatus) - return http.ListenAndServe(":8080", router) + return http.ListenAndServe(s.Addr, router) } 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 { - 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 { - 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) }