Initial QSfera import
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/pkg/x/path/filepathx"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidApp is the error when an app is invalid
|
||||
ErrInvalidApp = errors.New("invalid app")
|
||||
|
||||
// ErrMissingManifest is the error when the manifest is missing
|
||||
ErrMissingManifest = errors.New("missing manifest")
|
||||
|
||||
// ErrInvalidManifest is the error when the manifest is invalid
|
||||
ErrInvalidManifest = errors.New("invalid manifest")
|
||||
|
||||
// ErrEntrypointDoesNotExist is the error when the entrypoint does not exist or is not a file
|
||||
ErrEntrypointDoesNotExist = errors.New("entrypoint does not exist")
|
||||
|
||||
validate = validator.New(validator.WithRequiredStructEnabled())
|
||||
)
|
||||
|
||||
const (
|
||||
// _manifest is the name of the manifest file for an application
|
||||
_manifest = "manifest.json"
|
||||
|
||||
// _config contains the dedicated app configuration
|
||||
_config = "config.json"
|
||||
)
|
||||
|
||||
// Application contains the metadata of an application
|
||||
type Application struct {
|
||||
// ID is the unique identifier of the application
|
||||
ID string `json:"-"`
|
||||
|
||||
// Disabled is a flag to disable the application
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
// Entrypoint is the entrypoint of the application within the bundle
|
||||
Entrypoint string `json:"entrypoint" validate:"required"`
|
||||
|
||||
// Config contains the application-specific configuration
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// ToExternal converts an Application to an ExternalApp configuration
|
||||
func (a Application) ToExternal(entrypoint string) config.ExternalApp {
|
||||
return config.ExternalApp{
|
||||
ID: a.ID,
|
||||
Path: filepathx.JailJoin(entrypoint, a.Entrypoint),
|
||||
Config: a.Config,
|
||||
}
|
||||
}
|
||||
|
||||
// List returns a list of applications from the given filesystems,
|
||||
// individual filesystems are searched for applications, and the list is merged.
|
||||
// Last finding gets priority in case of conflicts, so the order of the filesystems is important.
|
||||
func List(logger log.Logger, data map[string]config.App, fSystems ...fs.FS) []Application {
|
||||
registry := map[string]Application{}
|
||||
|
||||
for _, fSystem := range fSystems {
|
||||
if fSystem == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entries, err := fs.ReadDir(fSystem, ".")
|
||||
if err != nil {
|
||||
// skip non-directory listings, every app needs to be contained inside a directory
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
var appData config.App
|
||||
name := entry.Name()
|
||||
|
||||
// configuration for the application is optional, if it is not present, the default configuration is used
|
||||
if data, ok := data[name]; ok {
|
||||
appData = data
|
||||
}
|
||||
|
||||
application, err := build(fSystem, name, appData)
|
||||
if err != nil {
|
||||
// if app creation fails, log the error and continue with the next app
|
||||
logger.Debug().Err(err).Str("path", entry.Name()).Msg("failed to load application")
|
||||
continue
|
||||
}
|
||||
|
||||
if application.Disabled {
|
||||
// if the app is disabled, skip it
|
||||
continue
|
||||
}
|
||||
|
||||
// everything is fine, add the application to the list of applications
|
||||
registry[name] = application
|
||||
}
|
||||
}
|
||||
|
||||
return maps.Values(registry)
|
||||
}
|
||||
|
||||
func build(fSystem fs.FS, id string, globalConfig config.App) (Application, error) {
|
||||
// skip non-directory listings, every app needs to be contained inside a directory
|
||||
entry, err := fs.Stat(fSystem, id)
|
||||
if err != nil || !entry.IsDir() {
|
||||
return Application{}, ErrInvalidApp
|
||||
}
|
||||
|
||||
var application Application
|
||||
// build the application
|
||||
{
|
||||
r, err := fSystem.Open(path.Join(id, _manifest))
|
||||
if err != nil {
|
||||
return Application{}, errors.Join(err, ErrMissingManifest)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if json.NewDecoder(r).Decode(&application) != nil {
|
||||
return Application{}, errors.Join(err, ErrInvalidManifest)
|
||||
}
|
||||
|
||||
if err := validate.Struct(application); err != nil {
|
||||
return Application{}, errors.Join(err, ErrInvalidManifest)
|
||||
}
|
||||
}
|
||||
|
||||
var localConfig config.App
|
||||
// build the local configuration
|
||||
{
|
||||
r, err := fSystem.Open(path.Join(id, _config))
|
||||
if err == nil {
|
||||
defer r.Close()
|
||||
}
|
||||
|
||||
// as soon as we have a local configuration, we expect it to be valid
|
||||
if err == nil && json.NewDecoder(r).Decode(&localConfig) != nil {
|
||||
return Application{}, errors.Join(err, ErrInvalidManifest)
|
||||
}
|
||||
}
|
||||
|
||||
// apply overloads the application with the source configuration,
|
||||
// not all fields are considered secure, therefore, the allowed values are hand-picked
|
||||
overloadApplication := func(source config.App) error {
|
||||
// overload the configuration,
|
||||
// configuration options are considered secure and can be overloaded
|
||||
err = mergo.Merge(&application.Config, source.Config, mergo.WithOverride)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// overload the disabled state, consider it secure and allow overloading
|
||||
application.Disabled = source.Disabled
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// overload the application configuration from the manifest with the local and global configuration
|
||||
// priority is local.config > global.config > manifest.config
|
||||
{
|
||||
// overload the default configuration with the global application-specific configuration,
|
||||
// that configuration has priority, and failing is fine here
|
||||
_ = overloadApplication(globalConfig)
|
||||
|
||||
// overload the default configuration with the local application-specific configuration,
|
||||
// that configuration has priority, and failing is fine here
|
||||
_ = overloadApplication(localConfig)
|
||||
}
|
||||
|
||||
// the entrypoint is jailed to the app directory
|
||||
application.Entrypoint = filepathx.JailJoin(id, application.Entrypoint)
|
||||
info, err := fs.Stat(fSystem, application.Entrypoint)
|
||||
switch {
|
||||
case err != nil:
|
||||
return Application{}, errors.Join(err, ErrEntrypointDoesNotExist)
|
||||
case info.IsDir():
|
||||
return Application{}, ErrEntrypointDoesNotExist
|
||||
}
|
||||
|
||||
application.ID = id
|
||||
|
||||
return application, nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package apps_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/onsi/gomega"
|
||||
|
||||
"github.com/qsfera/server/pkg/log"
|
||||
"github.com/qsfera/server/services/web/pkg/apps"
|
||||
"github.com/qsfera/server/services/web/pkg/config"
|
||||
)
|
||||
|
||||
func TestApplication_ToExternal(t *testing.T) {
|
||||
g := gomega.NewWithT(t)
|
||||
app := apps.Application{
|
||||
ID: "app",
|
||||
Entrypoint: "entrypoint.js",
|
||||
Config: map[string]any{
|
||||
"foo": "bar",
|
||||
},
|
||||
}
|
||||
|
||||
externalApp := app.ToExternal("path")
|
||||
|
||||
g.Expect(externalApp.ID).To(gomega.Equal("app"))
|
||||
g.Expect(externalApp.Path).To(gomega.Equal("path/entrypoint.js"))
|
||||
g.Expect(externalApp.Config).To(gomega.Equal(app.Config))
|
||||
}
|
||||
|
||||
func TestBuild(t *testing.T) {
|
||||
g := gomega.NewWithT(t)
|
||||
dir := &fstest.MapFile{
|
||||
Mode: fs.ModeDir,
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": &fstest.MapFile{},
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrInvalidApp))
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrMissingManifest))
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/manifest.json": dir,
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest))
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/manifest.json": &fstest.MapFile{
|
||||
Data: []byte("{}"),
|
||||
},
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest))
|
||||
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/entrypoint.js": &fstest.MapFile{},
|
||||
"app/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
|
||||
},
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/entrypoint.js": dir,
|
||||
"app/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
|
||||
},
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist))
|
||||
}
|
||||
|
||||
{
|
||||
_, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
|
||||
},
|
||||
}, "app", config.App{})
|
||||
g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist))
|
||||
}
|
||||
|
||||
{
|
||||
application, err := apps.Build(fstest.MapFS{
|
||||
"app": dir,
|
||||
"app/entrypoint.js": &fstest.MapFile{},
|
||||
"app/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js", "config": {"k1": "1", "k2": "2", "k3": "3"}}`),
|
||||
},
|
||||
"app/config.json": &fstest.MapFile{
|
||||
Data: []byte(`{"config": {"k2": "overwritten-from-config.json", "injected_from_config_json": "11"}}`),
|
||||
},
|
||||
}, "app", config.App{Config: map[string]any{"k2": "overwritten-from-apps.yaml", "k3": "overwritten-from-apps.yaml", "injected_from_apps_yaml": "22"}})
|
||||
g.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
|
||||
g.Expect(application.Entrypoint).To(gomega.Equal("app/entrypoint.js"))
|
||||
g.Expect(application.Config).To(gomega.Equal(map[string]any{
|
||||
"k1": "1", "k2": "overwritten-from-config.json", "k3": "overwritten-from-apps.yaml", "injected_from_config_json": "11", "injected_from_apps_yaml": "22",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
g := gomega.NewWithT(t)
|
||||
|
||||
applications := apps.List(log.NopLogger(), map[string]config.App{})
|
||||
g.Expect(applications).To(gomega.BeEmpty())
|
||||
|
||||
applications = apps.List(log.NopLogger(), map[string]config.App{}, nil)
|
||||
g.Expect(applications).To(gomega.BeEmpty())
|
||||
|
||||
applications = apps.List(log.NopLogger(), map[string]config.App{}, fstest.MapFS{})
|
||||
g.Expect(applications).To(gomega.BeEmpty())
|
||||
|
||||
dir := &fstest.MapFile{
|
||||
Mode: fs.ModeDir,
|
||||
}
|
||||
|
||||
applications = apps.List(log.NopLogger(), map[string]config.App{
|
||||
"app": {
|
||||
Disabled: true,
|
||||
},
|
||||
}, fstest.MapFS{
|
||||
"app": dir,
|
||||
})
|
||||
g.Expect(applications).To(gomega.BeEmpty())
|
||||
|
||||
applications = apps.List(log.NopLogger(), map[string]config.App{
|
||||
"app": {},
|
||||
}, fstest.MapFS{
|
||||
"app": dir,
|
||||
})
|
||||
g.Expect(applications).To(gomega.BeEmpty())
|
||||
|
||||
applications = apps.List(log.NopLogger(), map[string]config.App{
|
||||
"app-3": {
|
||||
Config: map[string]any{
|
||||
"foo": "local conf 1",
|
||||
"bar": "local conf 2",
|
||||
},
|
||||
},
|
||||
}, fstest.MapFS{
|
||||
"app-1": dir,
|
||||
"app-1/entrypoint.js": &fstest.MapFile{},
|
||||
"app-1/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app-1", "entrypoint":"entrypoint.js", "config": {"foo": "fs1"}}`),
|
||||
},
|
||||
"app-2": dir,
|
||||
"app-2/entrypoint.js": &fstest.MapFile{},
|
||||
"app-2/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app-2", "entrypoint":"entrypoint.js", "config": {"foo": "fs1"}}`),
|
||||
},
|
||||
}, fstest.MapFS{
|
||||
"app-1": dir,
|
||||
"app-1/entrypoint.js": &fstest.MapFile{},
|
||||
"app-1/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app-1", "entrypoint":"entrypoint.js", "config": {"foo": "fs2"}}`),
|
||||
},
|
||||
"app-3": dir,
|
||||
"app-3/entrypoint.js": &fstest.MapFile{},
|
||||
"app-3/manifest.json": &fstest.MapFile{
|
||||
Data: []byte(`{"id":"app-3", "entrypoint":"entrypoint.js", "config": {"foo": "fs2"}}`),
|
||||
},
|
||||
})
|
||||
g.Expect(len(applications)).To(gomega.Equal(3))
|
||||
|
||||
for _, application := range applications {
|
||||
switch {
|
||||
case application.Entrypoint == "app-1/entrypoint.js":
|
||||
g.Expect(application.Config["foo"]).To(gomega.Equal("fs2"))
|
||||
case application.Entrypoint == "app-2/entrypoint.js":
|
||||
g.Expect(application.Config["foo"]).To(gomega.Equal("fs1"))
|
||||
case application.Entrypoint == "app-3/entrypoint.js":
|
||||
g.Expect(application.Config["foo"]).To(gomega.Equal("local conf 1"))
|
||||
g.Expect(application.Config["bar"]).To(gomega.Equal("local conf 2"))
|
||||
default:
|
||||
t.Fatalf("unexpected application %s", application.Entrypoint)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package apps
|
||||
|
||||
var Build = build
|
||||
Reference in New Issue
Block a user