Skip to content

Commit

Permalink
Commit Title (50 char max)
Browse files Browse the repository at this point in the history
Release Notes
=============

Testing Notes
=============

Deploy Notes
============

Technical Change Notes
======================
  • Loading branch information
fschade committed Jul 25, 2024
1 parent f381054 commit 42e7a5b
Show file tree
Hide file tree
Showing 3 changed files with 156 additions and 82 deletions.
12 changes: 12 additions & 0 deletions changelog/unreleased/enhancement-local-app-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Enhancement: Local WEB App configuration

We've added a new feature which allows configuring applications individually instead of using the global apps.yaml file.
With that, each application can have its own configuration file, which will be loaded by the WEB service.

The local configuration has the highest priority and will override the global configuration.
The Following order of precedence is used: local.config > global.config > manifest.config.

Besides the configuration, the application now be disabled by setting the `disabled` field to `true` in one of the configuration files.

https://github.com/owncloud/ocis/pull/9691
https://github.com/owncloud/ocis/issues/9687
90 changes: 66 additions & 24 deletions services/web/pkg/apps/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ 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
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"`

Expand Down Expand Up @@ -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
}
Expand All @@ -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)
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)
}
}
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)
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)
}
}

if err := validate.Struct(application); err != nil {
// the application is required to be valid
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 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)
// 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)
Expand Down
136 changes: 78 additions & 58 deletions services/web/pkg/apps/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/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/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/manifest.json": &fstest.MapFile{
Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
},
}, "app", map[string]any{})
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())
}

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",
}))
{
_, 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]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) {
Expand Down

0 comments on commit 42e7a5b

Please sign in to comment.