Merge pull request #9691 from fschade/app-config

isolated app configuration
This commit is contained in:
Florian Schade
2024-08-06 09:35:30 +02:00
committed by GitHub
4 changed files with 171 additions and 84 deletions
+67 -25
View File
@@ -34,6 +34,9 @@ var (
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
@@ -41,6 +44,9 @@ 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"`
@@ -83,18 +89,18 @@ func List(logger log.Logger, data map[string]config.App, fSystems ...fs.FS) []Ap
appData = data
}
if appData.Disabled {
// if the app is disabled, skip it
continue
}
application, err := build(fSystem, name, appData.Config)
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
}
@@ -103,36 +109,72 @@ func List(logger log.Logger, data map[string]config.App, fSystems ...fs.FS) []Ap
return maps.Values(registry)
}
func build(fSystem fs.FS, id string, conf map[string]any) (Application, error) {
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
}
// read the manifest.json from the app directory.
manifest := path.Join(id, _manifest)
reader, err := fSystem.Open(manifest)
if err != nil {
// manifest.json is required
return Application{}, errors.Join(err, ErrMissingManifest)
}
defer reader.Close()
var application Application
if json.NewDecoder(reader).Decode(&application) != nil {
// a valid manifest.json is required
return Application{}, errors.Join(err, ErrInvalidManifest)
// 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)
}
}
if err := validate.Struct(application); err != nil {
// the application is required to be valid
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)
}
}
// overload the default configuration with the application-specific configuration,
// the application-specific configuration has priority, and failing is fine here
_ = mergo.Merge(&application.Config, conf, mergo.WithOverride)
// 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)
+75 -55
View File
@@ -35,69 +35,89 @@ func TestBuild(t *testing.T) {
Mode: fs.ModeDir,
}
_, err := apps.Build(fstest.MapFS{
"app": &fstest.MapFile{},
}, "app", map[string]any{})
g.Expect(err).To(gomega.MatchError(apps.ErrInvalidApp))
{
_, 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", map[string]any{})
g.Expect(err).To(gomega.MatchError(apps.ErrMissingManifest))
{
_, 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", map[string]any{})
g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest))
{
_, 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", map[string]any{})
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", map[string]any{})
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", map[string]any{})
g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist))
{
_, 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/manifest.json": &fstest.MapFile{
Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
},
}, "app", map[string]any{})
g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist))
{
_, 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))
}
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": {"foo": "1", "bar": "2"}}`),
},
}, "app", map[string]any{"foo": "overwritten-1", "baz": "injected-1"})
g.Expect(err).ToNot(gomega.HaveOccurred())
{
_, 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))
}
g.Expect(application.Entrypoint).To(gomega.Equal("app/entrypoint.js"))
g.Expect(application.Config).To(gomega.Equal(map[string]interface{}{
"foo": "overwritten-1", "baz": "injected-1", "bar": "2",
}))
{
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]interface{}{
"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) {