From f774465e0318fab16bc020190fb8a0a0b18fea01 Mon Sep 17 00:00:00 2001 From: Ajay Kidave Date: Fri, 23 Aug 2024 12:57:42 -0700 Subject: [PATCH] Added app level config update apis --- cmd/clace/app_cmds.go | 22 +++--- .../{app_updates.go => app_update_cmds.go} | 78 ++++++++++++++++++- internal/app/app.go | 36 ++++----- internal/app/apptype/appconfig.go | 18 ++--- internal/app/container/manager.go | 12 +-- internal/app/dev/appdev.go | 2 +- internal/app/handler.go | 6 +- internal/app/setup.go | 26 +++---- internal/app/tests/app_test_helper.go | 2 +- internal/app/tests/basic_test.go | 6 +- internal/server/app_apis.go | 4 +- internal/server/app_updates.go | 58 ++++++++++++++ internal/system/clace.default.toml | 17 ++-- internal/system/config_test.go | 9 ++- internal/types/api.go | 14 ++-- internal/types/types.go | 24 +++++- 16 files changed, 243 insertions(+), 91 deletions(-) rename cmd/clace/{app_updates.go => app_update_cmds.go} (78%) diff --git a/cmd/clace/app_cmds.go b/cmd/clace/app_cmds.go index e711a3b..fba5afd 100644 --- a/cmd/clace/app_cmds.go +++ b/cmd/clace/app_cmds.go @@ -69,13 +69,13 @@ func appCreateCommand(commonFlags []cli.Flag, clientConfig *types.ClientConfig) }) flags = append(flags, &cli.StringSliceFlag{ - Name: "container-options", + Name: "container-option", Aliases: []string{"copt"}, Usage: "Set a container option. Format is opt[=optValue]", }) flags = append(flags, &cli.StringSliceFlag{ - Name: "container-args", + Name: "container-arg", Aliases: []string{"carg"}, Usage: "Set an argument for building the container image. Format is argKey=argValue", }) @@ -83,7 +83,7 @@ func appCreateCommand(commonFlags []cli.Flag, clientConfig *types.ClientConfig) flags = append(flags, &cli.StringSliceFlag{ Name: "app-config", - Aliases: []string{"config"}, + Aliases: []string{"conf"}, Usage: "Set an default config option for the app. Format is configKey=configValue", }) @@ -133,14 +133,14 @@ Examples: paramValues[key] = value } - containerOptions := cCtx.StringSlice("container-options") + containerOptions := cCtx.StringSlice("container-option") coptMap := make(map[string]string) for _, param := range containerOptions { key, value, _ := strings.Cut(param, "=") coptMap[key] = value // value can be empty string } - containerArgs := cCtx.StringSlice("container-args") + containerArgs := cCtx.StringSlice("container-arg") cargMap := make(map[string]string) for _, param := range containerArgs { key, value, ok := strings.Cut(param, "=") @@ -150,14 +150,14 @@ Examples: cargMap[key] = value } - appDefaults := cCtx.StringSlice("app-config") - defMap := make(map[string]string) - for _, def := range appDefaults { + appConfig := cCtx.StringSlice("app-config") + confMap := make(map[string]string) + for _, def := range appConfig { key, value, ok := strings.Cut(def, "=") if !ok { - return fmt.Errorf("invalid app default format: %s", def) + return fmt.Errorf("invalid app config format: %s", def) } - defMap[key] = value + confMap[key] = value } body := types.CreateAppRequest{ @@ -171,7 +171,7 @@ Examples: ParamValues: paramValues, ContainerOptions: coptMap, ContainerArgs: cargMap, - AppDefaults: defMap, + AppConfig: confMap, } var createResult types.AppCreateResponse err := client.Post("/_clace/app", values, body, &createResult) diff --git a/cmd/clace/app_updates.go b/cmd/clace/app_update_cmds.go similarity index 78% rename from cmd/clace/app_updates.go rename to cmd/clace/app_update_cmds.go index aa99377..44da5b4 100644 --- a/cmd/clace/app_updates.go +++ b/cmd/clace/app_update_cmds.go @@ -264,6 +264,9 @@ func appUpdateMetadataCommand(commonFlags []cli.Flag, clientConfig *types.Client Usage: `Update Clace app metadata. Metadata updates are staged and have to be promoted to prod. Use "clace param" to update app parameter metadata.`, Subcommands: []*cli.Command{ appUpdateAppSpec(commonFlags, clientConfig), + appUpdateConfig(commonFlags, clientConfig, "container-option", "copt", types.AppMetadataContainerOptions), + appUpdateConfig(commonFlags, clientConfig, "container-arg", "carg", types.AppMetadataContainerArgs), + appUpdateConfig(commonFlags, clientConfig, "app-config", "conf", types.AppMetadataAppConfig), }, } } @@ -284,7 +287,7 @@ func appUpdateAppSpec(commonFlags []cli.Flag, clientConfig *types.ClientConfig) UsageText: `args: The first required argument is a string, a valid app spec name or - (to unset spec). -The second required argument is . ` + PATH_SPEC_HELP + ` +The last required argument is . ` + PATH_SPEC_HELP + ` Examples: Update all apps, across domains: clace app update-metadata spec - all @@ -310,9 +313,78 @@ The second required argument is . ` + PATH_SPEC_HELP + ` } for _, updateResult := range updateResponse.StagedUpdateResults { - fmt.Printf("Updating %s\n", updateResult) + fmt.Printf("Updated %s\n", updateResult) + } + + if len(updateResponse.PromoteResults) > 0 { + fmt.Fprintf(cCtx.App.Writer, "Promoted apps: ") + for i, promoteResult := range updateResponse.PromoteResults { + if i > 0 { + fmt.Fprintf(cCtx.App.Writer, ", ") + } + fmt.Fprintf(cCtx.App.Writer, "%s", promoteResult) + } + fmt.Fprintln(cCtx.App.Writer) + } + + fmt.Fprintf(cCtx.App.Writer, "%d app(s) updated, %d app(s) promoted.\n", len(updateResponse.StagedUpdateResults), len(updateResponse.PromoteResults)) + + if updateResponse.DryRun { + fmt.Print(DRY_RUN_MESSAGE) + } + + return nil + }, + } +} + +// appUpdateConfig creates a command to update app metadata config +func appUpdateConfig(commonFlags []cli.Flag, clientConfig *types.ClientConfig, arg string, shortFlag string, configType types.AppMetadataConfigType) *cli.Command { + flags := make([]cli.Flag, 0, len(commonFlags)+2) + flags = append(flags, commonFlags...) + flags = append(flags, dryRunFlag()) + flags = append(flags, newBoolFlag(PROMOTE_FLAG, "p", "Promote the change from stage to prod", false)) + + return &cli.Command{ + Name: arg, + Aliases: []string{shortFlag}, + Usage: fmt.Sprintf("Update %s metadata for apps", arg), + Flags: flags, + Before: altsrc.InitInputSourceWithContext(flags, altsrc.NewTomlSourceFromFlagFunc(configFileFlagName)), + ArgsUsage: "key=value ", + + UsageText: fmt.Sprintf(`args: key=value [key=value ...] +The initial arguments key=value are strings, the key to set and the value to use delimited by =. The value is optional for +container options. The last argument is . `+PATH_SPEC_HELP+` + + Examples: + Update all apps, across domains: clace app update-metadata %s key=value all + Update apps in the example.com domain: clace app update-metadata %s key=value "example.com:**"`, arg, arg), + + Action: func(cCtx *cli.Context) error { + if cCtx.NArg() < 2 { + return fmt.Errorf("requires at least two arguments: key=value [key=value ...] ") + } + + client := system.NewHttpClient(clientConfig.ServerUri, clientConfig.AdminUser, clientConfig.Client.AdminPassword, clientConfig.Client.SkipCertCheck) + values := url.Values{} + + values.Add("appPathGlob", cCtx.Args().Get(cCtx.NArg()-1)) + values.Add(DRY_RUN_ARG, strconv.FormatBool(cCtx.Bool(DRY_RUN_FLAG))) + values.Add(PROMOTE_ARG, strconv.FormatBool(cCtx.Bool(PROMOTE_FLAG))) + + body := types.CreateUpdateAppMetadataRequest() + body.ConfigType = configType + body.ConfigEntries = cCtx.Args().Slice()[:cCtx.NArg()-1] + + var updateResponse types.AppUpdateMetadataResponse + if err := client.Post("/_clace/app_metadata", values, body, &updateResponse); err != nil { + return err + } + + for _, updateResult := range updateResponse.StagedUpdateResults { + fmt.Printf("Updated %s\n", updateResult) } - fmt.Fprintf(cCtx.App.Writer, "%d app(s) updated.\n", len(updateResponse.StagedUpdateResults)) if len(updateResponse.PromoteResults) > 0 { fmt.Fprintf(cCtx.App.Writer, "Promoted apps: ") diff --git a/internal/app/app.go b/internal/app/app.go index 509b2a6..b66a772 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -46,7 +46,7 @@ type App struct { Name string CustomLayout bool - Config *apptype.AppConfig + codeConfig *apptype.CodeConfig sourceFS *appfs.SourceFs initMutex sync.Mutex initialized bool @@ -73,7 +73,7 @@ type App struct { sseListeners []chan SSEMessage funcMap template.FuncMap starlarkCache map[string]*starlarkCacheEntry - appDefaults types.AppDefaults + appConfig types.AppConfig } type starlarkCacheEntry struct { @@ -88,7 +88,7 @@ type SSEMessage struct { func NewApp(sourceFS *appfs.SourceFs, workFS *appfs.WorkFs, logger *types.Logger, appEntry *types.AppEntry, systemConfig *types.SystemConfig, - plugins map[string]types.PluginSettings, appDefaults types.AppDefaults) (*App, error) { + plugins map[string]types.PluginSettings, appConfig types.AppConfig) (*App, error) { newApp := &App{ sourceFS: sourceFS, Logger: logger, @@ -97,8 +97,8 @@ func NewApp(sourceFS *appfs.SourceFs, workFS *appfs.WorkFs, logger *types.Logger starlarkCache: map[string]*starlarkCacheEntry{}, } newApp.plugins = NewAppPlugins(newApp, plugins, appEntry.Metadata.Accounts) - newApp.appDefaults = appDefaults - if err := newApp.updateAppDefaults(); err != nil { + newApp.appConfig = appConfig + if err := newApp.updateAppConfig(); err != nil { return nil, err } @@ -213,16 +213,16 @@ func (a *App) Reload(force, immediate bool, dryRun DryRun) (bool, error) { // Config lock is not present, use default config a.Debug().Msg("No config lock file found, using default config") - a.Config = apptype.NewAppConfig() + a.codeConfig = apptype.NewCodeConfig() if a.IsDev { - a.appDev.Config = a.Config + a.appDev.Config = a.codeConfig a.appDev.SaveConfigLockFile() } } else { // Config lock file is present, read defaults from that a.Debug().Msg("Config lock file found, using config from lock file") - a.Config = apptype.NewCompatibleAppConfig() - if err := json.Unmarshal(configData, a.Config); err != nil { + a.codeConfig = apptype.NewCompatibleCodeConfig() + if err := json.Unmarshal(configData, a.codeConfig); err != nil { return false, err } } @@ -234,7 +234,7 @@ func (a *App) Reload(force, immediate bool, dryRun DryRun) (bool, error) { if a.IsDev { // Copy settings into appdev - a.appDev.Config = a.Config + a.appDev.Config = a.codeConfig a.appDev.CustomLayout = a.CustomLayout // Initialize style configuration @@ -273,14 +273,14 @@ func (a *App) Reload(force, immediate bool, dryRun DryRun) (bool, error) { // Parse HTML templates if there are HTML routes if a.usesHtmlTemplate { - baseFiles, err := a.sourceFS.Glob(path.Join(a.Config.Routing.BaseTemplates, "*.go.html")) + baseFiles, err := a.sourceFS.Glob(path.Join(a.codeConfig.Routing.BaseTemplates, "*.go.html")) if err != nil { return false, err } if len(baseFiles) == 0 { // No base templates found, use the default unstructured templates - if a.template, err = a.sourceFS.ParseFS(a.funcMap, a.Config.Routing.TemplateLocations...); err != nil { + if a.template, err = a.sourceFS.ParseFS(a.funcMap, a.codeConfig.Routing.TemplateLocations...); err != nil { return false, err } } else { @@ -291,7 +291,7 @@ func (a *App) Reload(force, immediate bool, dryRun DryRun) (bool, error) { } a.templateMap = make(map[string]*template.Template) - for _, paths := range a.Config.Routing.TemplateLocations { + for _, paths := range a.codeConfig.Routing.TemplateLocations { files, err := a.sourceFS.Glob(paths) if err != nil { return false, err @@ -702,18 +702,18 @@ func (a *App) loadStarlark(thread *starlark.Thread, module string, cache map[str return cacheEntry.globals, cacheEntry.err } -// updateAppDefaults updates the app defaults from the metadata +// updateAppConfig updates the app defaults from the metadata // It creates a TOML intermediate string so that the TOML parsing can be used -func (a *App) updateAppDefaults() error { - if len(a.Metadata.AppDefaults) == 0 { +func (a *App) updateAppConfig() error { + if len(a.Metadata.AppConfig) == 0 { return nil } buf := strings.Builder{} - for key, value := range a.Metadata.AppDefaults { + for key, value := range a.Metadata.AppConfig { buf.WriteString(fmt.Sprintf("%s=\"%s\"\n", key, value)) } - _, err := toml.Decode(buf.String(), &a.appDefaults) + _, err := toml.Decode(buf.String(), &a.appConfig) return err } diff --git a/internal/app/apptype/appconfig.go b/internal/app/apptype/appconfig.go index 3a4ee24..02139e6 100644 --- a/internal/app/apptype/appconfig.go +++ b/internal/app/apptype/appconfig.go @@ -3,7 +3,7 @@ package apptype -type AppConfig struct { +type CodeConfig struct { Routing RouteConfig `json:"routing"` Htmx HtmxConfig `json:"htmx"` } @@ -22,12 +22,12 @@ type HtmxConfig struct { Version string `json:"version"` } -// NewAppConfig creates an AppConfig with default values. This config is used when lock +// NewCodeConfig creates an CodeConfig with default values. This config is used when lock // file is not present. The config file load order is // -// DefaultAppConfig -> StarlarkAppConfig -func NewAppConfig() *AppConfig { - return &AppConfig{ +// DefaultCodeConfig -> StarlarkCodeConfig +func NewCodeConfig() *CodeConfig { + return &CodeConfig{ Routing: RouteConfig{ TemplateLocations: []string{"*.go.html"}, BaseTemplates: "base_templates", @@ -41,19 +41,19 @@ func NewAppConfig() *AppConfig { } } -// NewCompatibleAppConfig creates an AppConfig focused on maintaining backward compatibility. +// NewCompatibleCodeConfig creates an CodeConfig focused on maintaining backward compatibility. // This is used when the app is created from a source url where the source has the config lock file // present. The configs are read in the order // -// CompatibleAppConfig -> LockFile -> StarlarkAppConfig +// CompatibleCodeConfig -> LockFile -> StarlarkCodeConfig // // The goal is that if the application has a lock file, then all settings will attempt to be locked // such that there should not be any change in behavior when the Clace version is updated. // Removing the lock file will result in new config defaults getting applied, which can be // done when the app developer wants to do an application refresh. Refresh will require additional // testing to ensure that UI functionality is not changed.. -func NewCompatibleAppConfig() *AppConfig { - config := NewAppConfig() +func NewCompatibleCodeConfig() *CodeConfig { + config := NewCodeConfig() config.Htmx.Version = "1.9.1" return config } diff --git a/internal/app/container/manager.go b/internal/app/container/manager.go index 9c4e949..fe98923 100644 --- a/internal/app/container/manager.go +++ b/internal/app/container/manager.go @@ -114,21 +114,11 @@ func extractVolumes(node *parser.Node) []string { ret := []string{} for node.Next != nil { node = node.Next - ret = append(ret, stripQuotes(node.Value)) + ret = append(ret, types.StripQuotes(node.Value)) } return ret } -func stripQuotes(s string) string { - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { - return s[1 : len(s)-1] - } - if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' { - return s[1 : len(s)-1] - } - return s -} - func (m *Manager) GetProxyUrl() string { return fmt.Sprintf("%s://127.0.0.1:%d", m.scheme, m.hostPort) } diff --git a/internal/app/dev/appdev.go b/internal/app/dev/appdev.go index 8f25da1..fc4727b 100644 --- a/internal/app/dev/appdev.go +++ b/internal/app/dev/appdev.go @@ -38,7 +38,7 @@ type AppDev struct { *types.Logger CustomLayout bool - Config *apptype.AppConfig + Config *apptype.CodeConfig systemConfig *types.SystemConfig sourceFS *appfs.WritableSourceFs workFS *appfs.WorkFs diff --git a/internal/app/handler.go b/internal/app/handler.go index 5f7917c..9c6fc21 100644 --- a/internal/app/handler.go +++ b/internal/app/handler.go @@ -60,7 +60,7 @@ func (a *App) createHandlerFunc(fullHtml, fragment string, handler starlark.Call isHtmxRequest := r.Header.Get("HX-Request") == "true" && !(r.Header.Get("HX-Boosted") == "true") - if a.Config.Routing.EarlyHints && !a.IsDev && r.Method == http.MethodGet && + if a.codeConfig.Routing.EarlyHints && !a.IsDev && r.Method == http.MethodGet && r.Header.Get("sec-fetch-mode") == "navigate" && rtype == apptype.HTML_TYPE && !(isHtmxRequest && fragment != "") { // Prod mode, for a GET request from newer browsers on a top level HTML page, send http early hints @@ -85,8 +85,8 @@ func (a *App) createHandlerFunc(fullHtml, fragment string, handler starlark.Call Method: r.Method, IsDev: a.IsDev, IsPartial: isHtmxRequest, - PushEvents: a.Config.Routing.PushEvents, - HtmxVersion: a.Config.Htmx.Version, + PushEvents: a.codeConfig.Routing.PushEvents, + HtmxVersion: a.codeConfig.Htmx.Version, Headers: r.Header, RemoteIP: getRemoteIP(r), } diff --git a/internal/app/setup.go b/internal/app/setup.go index c77794a..912a0d8 100644 --- a/internal/app/setup.go +++ b/internal/app/setup.go @@ -108,7 +108,7 @@ func (a *App) loadStarlarkConfig(dryRun DryRun) error { if err = json.NewEncoder(&jsonBuf).Encode(settingsMap); err != nil { return err } - if err = json.Unmarshal(jsonBuf.Bytes(), a.Config); err != nil { + if err = json.Unmarshal(jsonBuf.Bytes(), a.codeConfig); err != nil { return err } @@ -132,7 +132,7 @@ func (a *App) loadStarlarkConfig(dryRun DryRun) error { } if len(templateFiles) != 0 { // a.UsesHtmlTemplate is set in initRouter, so it cannot be used here - excludeGlob = a.Config.Routing.ContainerExclude + excludeGlob = a.codeConfig.Routing.ContainerExclude } if err := a.containerManager.ProdReload(excludeGlob, bool(dryRun)); err != nil { return err @@ -607,25 +607,25 @@ func (a *App) addProxyConfig(count int, router *chi.Mux, proxyDef *starlarkstruc permsHandler := func(p *httputil.ReverseProxy) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if a.appDefaults.CORS.Setting == "strict" || a.appDefaults.CORS.Setting == "lax" { + if a.appConfig.CORS.Setting == "strict" || a.appConfig.CORS.Setting == "lax" { origin := "*" - if a.appDefaults.CORS.Setting == "strict" { + if a.appConfig.CORS.Setting == "strict" { origin = getRequestUrl(r) } if r.Method == http.MethodOptions { - w.Header().Set("Access-Control-Allow-Origin", cmp.Or(a.appDefaults.CORS.AllowOrigin, origin)) - w.Header().Set("Access-Control-Allow-Methods", a.appDefaults.CORS.AllowMethods) - w.Header().Set("Access-Control-Allow-Headers", a.appDefaults.CORS.AllowHeaders) - w.Header().Set("Access-Control-Allow-Credentials", a.appDefaults.CORS.AllowCredentials) - w.Header().Set("Access-Control-Max-Age", a.appDefaults.CORS.MaxAge) + w.Header().Set("Access-Control-Allow-Origin", cmp.Or(a.appConfig.CORS.AllowOrigin, origin)) + w.Header().Set("Access-Control-Allow-Methods", a.appConfig.CORS.AllowMethods) + w.Header().Set("Access-Control-Allow-Headers", a.appConfig.CORS.AllowHeaders) + w.Header().Set("Access-Control-Allow-Credentials", a.appConfig.CORS.AllowCredentials) + w.Header().Set("Access-Control-Max-Age", a.appConfig.CORS.MaxAge) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Length", "0") w.WriteHeader(http.StatusNoContent) return } else { - w.Header().Set("Access-Control-Allow-Origin", cmp.Or(a.appDefaults.CORS.AllowOrigin, origin)) - w.Header().Set("Access-Control-Allow-Methods", a.appDefaults.CORS.AllowMethods) - w.Header().Set("Access-Control-Allow-Headers", a.appDefaults.CORS.AllowHeaders) + w.Header().Set("Access-Control-Allow-Origin", cmp.Or(a.appConfig.CORS.AllowOrigin, origin)) + w.Header().Set("Access-Control-Allow-Methods", a.appConfig.CORS.AllowMethods) + w.Header().Set("Access-Control-Allow-Headers", a.appConfig.CORS.AllowHeaders) } } @@ -755,7 +755,7 @@ func (a *App) handleFragments(router *chi.Mux, pagePath string, pageCount int, h } func (a *App) createInternalRoutes(router *chi.Mux) error { - if a.IsDev || a.Config.Routing.PushEvents { + if a.IsDev || a.codeConfig.Routing.PushEvents { router.Get(types.APP_INTERNAL_URL_PREFIX+"/sse", a.sseHandler) } diff --git a/internal/app/tests/app_test_helper.go b/internal/app/tests/app_test_helper.go index 2babbf3..31553f5 100644 --- a/internal/app/tests/app_test_helper.go +++ b/internal/app/tests/app_test_helper.go @@ -92,7 +92,7 @@ func CreateTestAppInt(logger *types.Logger, path string, fileData map[string]str } workFS := appfs.NewWorkFs("", &TestWriteFS{TestReadFS: &TestReadFS{fileData: map[string]string{}}}) a, err := app.NewApp(sourceFS, workFS, logger, - createTestAppEntry(id, path, isDev, metadata), &systemConfig, pluginConfig, types.AppDefaults{}) + createTestAppEntry(id, path, isDev, metadata), &systemConfig, pluginConfig, types.AppConfig{}) if err != nil { return nil, nil, err } diff --git a/internal/app/tests/basic_test.go b/internal/app/tests/basic_test.go index 01955de..5b64569 100644 --- a/internal/app/tests/basic_test.go +++ b/internal/app/tests/basic_test.go @@ -88,7 +88,7 @@ def handler(req): testutil.AssertEqualsInt(t, "code", 200, response.Code) testutil.AssertEqualsString(t, "body", `Template got myvalue.`, response.Body.String()) - var config apptype.AppConfig + var config apptype.CodeConfig json.Unmarshal([]byte(fileData[apptype.CONFIG_LOCK_FILE_NAME]), &config) testutil.AssertEqualsString(t, "config", "1.9.2", config.Htmx.Version) @@ -191,7 +191,7 @@ def handler(req): testutil.AssertEqualsInt(t, "code", 200, response.Code) testutil.AssertEqualsString(t, "body", `Template got myvalue.`, response.Body.String()) - var config apptype.AppConfig + var config apptype.CodeConfig json.Unmarshal([]byte(fileData[apptype.CONFIG_LOCK_FILE_NAME]), &config) testutil.AssertEqualsString(t, "config", "1.8", config.Htmx.Version) @@ -222,7 +222,7 @@ def handler(req): testutil.AssertEqualsString(t, "body", `html/template: "t12.tmpl" is undefined`, strings.TrimSpace(response.Body.String())) - var config apptype.AppConfig + var config apptype.CodeConfig json.Unmarshal([]byte(fileData[apptype.CONFIG_LOCK_FILE_NAME]), &config) testutil.AssertEqualsString(t, "config", "1.8", config.Htmx.Version) diff --git a/internal/server/app_apis.go b/internal/server/app_apis.go index 4228c80..413ae5c 100644 --- a/internal/server/app_apis.go +++ b/internal/server/app_apis.go @@ -94,7 +94,7 @@ func (s *Server) CreateApp(ctx context.Context, appPath string, approve, dryRun appEntry.Metadata.ParamValues = appRequest.ParamValues appEntry.Metadata.ContainerOptions = appRequest.ContainerOptions appEntry.Metadata.ContainerArgs = appRequest.ContainerArgs - appEntry.Metadata.AppDefaults = appRequest.AppDefaults + appEntry.Metadata.AppConfig = appRequest.AppConfig auditResult, err := s.createApp(ctx, &appEntry, approve, dryRun, appRequest.GitBranch, appRequest.GitCommit, appRequest.GitAuthName) if err != nil { @@ -294,7 +294,7 @@ func (s *Server) setupApp(appEntry *types.AppEntry, tx types.Transaction) (*app. &appfs.DiskWriteFS{ DiskReadFS: appfs.NewDiskReadFS(&appLogger, appPath, *appEntry.Metadata.SpecFiles), }) - return app.NewApp(sourceFS, workFS, &appLogger, appEntry, &s.config.System, s.config.Plugins, s.config.AppDefaults) + return app.NewApp(sourceFS, workFS, &appLogger, appEntry, &s.config.System, s.config.Plugins, s.config.AppConfig) } func (s *Server) GetAppApi(ctx context.Context, appPath string) (*types.AppGetResponse, error) { diff --git a/internal/server/app_updates.go b/internal/server/app_updates.go index 8774f4d..aca23f2 100644 --- a/internal/server/app_updates.go +++ b/internal/server/app_updates.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net/http" + "strings" "github.com/claceio/clace/internal/app" "github.com/claceio/clace/internal/metadata" @@ -518,6 +519,7 @@ func (s *Server) updateParamHandler(ctx context.Context, tx types.Transaction, a appEntry.Metadata.ParamValues = make(map[string]string) } + paramValue = types.StripQuotes(strings.TrimSpace(paramValue)) if paramValue == "-" { // Delete the entry delete(appEntry.Metadata.ParamValues, paramName) @@ -550,6 +552,62 @@ func (s *Server) updateMetadataHandler(ctx context.Context, tx types.Transaction appEntry.Metadata.SpecFiles = &appFiles } + if updateMetadata.ConfigType != "" && updateMetadata.ConfigType != types.AppMetadataConfigType(types.StringValueUndefined) { + s.updateAppMetadataConfig(appEntry, updateMetadata.ConfigType, updateMetadata.ConfigEntries) + } + appPathDomain := appEntry.AppPathDomain() return appPathDomain, appPathDomain, nil } + +// updateAppMetadataConfig updates the app metadata config +func (s *Server) updateAppMetadataConfig(appEntry *types.AppEntry, configType types.AppMetadataConfigType, configEntries []string) error { + if len(configEntries) == 0 { + return nil + } + + for _, entry := range configEntries { + key, value, ok := strings.Cut(entry, "=") + + if !ok && configType != types.AppMetadataContainerOptions { + return fmt.Errorf("invalid %s %s, need key=value", configType, entry) + } + + key = strings.TrimSpace(key) + value = types.StripQuotes(strings.TrimSpace(value)) + + switch configType { + case types.AppMetadataContainerOptions: + if appEntry.Metadata.ContainerOptions == nil { + appEntry.Metadata.ContainerOptions = make(map[string]string) + } + if value != "-" { + appEntry.Metadata.ContainerOptions[key] = value + } else { + delete(appEntry.Metadata.ContainerOptions, key) + } + case types.AppMetadataContainerArgs: + if appEntry.Metadata.ContainerArgs == nil { + appEntry.Metadata.ContainerArgs = make(map[string]string) + } + if value != "-" { + appEntry.Metadata.ContainerArgs[key] = value + } else { + delete(appEntry.Metadata.ContainerArgs, key) + } + case types.AppMetadataAppConfig: + if appEntry.Metadata.AppConfig == nil { + appEntry.Metadata.AppConfig = make(map[string]string) + } + if value != "-" { + appEntry.Metadata.AppConfig[key] = value + } else { + delete(appEntry.Metadata.AppConfig, key) + } + default: + return fmt.Errorf("invalid config type %s", configType) + } + } + + return nil +} diff --git a/internal/system/clace.default.toml b/internal/system/clace.default.toml index 04f7665..20d943c 100644 --- a/internal/system/clace.default.toml +++ b/internal/system/clace.default.toml @@ -55,11 +55,16 @@ container_command = "auto" # "auto" or "docker" or "podman" [plugin."store.in"] db_connection = "sqlite:$CL_HOME/clace_app.db" -[appdefaults] +[app_config] +# app config can be set at the app level using a metadata config update. For example: +# clace app update-metadata conf --promote cors.allow_methods="GET, POST" /myapp + +# CORS related Config +# default setting is strict, which means allow_origin is set to host url. "lax" allows all origins, "*". +# "disabled" means no CORS headers are set. if allow_origin is set, it will be used as the origin value. cors.setting = "strict" -cors.allow_origin = "" # if empty, set to * for lax and origin host for strict -cors.allow_methods = "GET, POST, PUT, DELETE, PATCH, OPTIONS" -cors.allow_headers = """DNT,User-Agent,X-Requested-With,If-Modified-Since, -Cache-Control,Content-Type,Range,Authorization,X-Requested-With""" +cors.allow_origin = "" +cors.allow_methods = "GET,POST,PUT,DELETE,PATCH,OPTIONS" +cors.allow_headers = "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Requested-With" cors.allow_credentials = "true" -cors.max_age = "2678400" # 31 days +cors.max_age = "2678400" # 31 days diff --git a/internal/system/config_test.go b/internal/system/config_test.go index 5978d41..43807e4 100644 --- a/internal/system/config_test.go +++ b/internal/system/config_test.go @@ -58,8 +58,13 @@ func TestServerConfig(t *testing.T) { // Container Settings testutil.AssertEqualsString(t, "command", "auto", c.System.ContainerCommand) - // App default Settings - testutil.AssertEqualsString(t, "cors setting", "strict", c.AppDefaults.CORS.Setting) + // App CORS default Settings + testutil.AssertEqualsString(t, "cors setting", "strict", c.AppConfig.CORS.Setting) + testutil.AssertEqualsString(t, "cors origin", "", c.AppConfig.CORS.AllowOrigin) + testutil.AssertEqualsString(t, "cors headers", "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Requested-With", c.AppConfig.CORS.AllowHeaders) + testutil.AssertEqualsString(t, "cors methods", "GET,POST,PUT,DELETE,PATCH,OPTIONS", c.AppConfig.CORS.AllowMethods) + testutil.AssertEqualsString(t, "cors methods", "true", c.AppConfig.CORS.AllowCredentials) + testutil.AssertEqualsString(t, "cors methods", "2678400", c.AppConfig.CORS.MaxAge) } func TestClientConfig(t *testing.T) { diff --git a/internal/types/api.go b/internal/types/api.go index 39cb511..41ed12f 100644 --- a/internal/types/api.go +++ b/internal/types/api.go @@ -40,7 +40,7 @@ type CreateAppRequest struct { ParamValues map[string]string `json:"param_values"` ContainerOptions map[string]string `json:"container_options"` ContainerArgs map[string]string `json:"container_args"` - AppDefaults map[string]string `json:"appdefaults"` + AppConfig map[string]string `json:"appconfig"` } // UpdateAppRequest is the request body for updating an app settings @@ -64,12 +64,16 @@ func CreateUpdateAppRequest() UpdateAppRequest { // UpdateAppMetadataRequest is the request body for updating an app metadata type UpdateAppMetadataRequest struct { - Spec StringValue `json:"spec"` + Spec StringValue `json:"spec"` + ConfigType AppMetadataConfigType `json:"config_type"` + ConfigEntries []string `json:"config_entries"` } -func CreateUpdateAppMetadataRequest() UpdateAppRequest { - return UpdateAppRequest{ - Spec: StringValueUndefined, +func CreateUpdateAppMetadataRequest() UpdateAppMetadataRequest { + return UpdateAppMetadataRequest{ + Spec: StringValueUndefined, + ConfigType: AppMetadataConfigType(StringValueUndefined), + ConfigEntries: []string{}, } } diff --git a/internal/types/types.go b/internal/types/types.go index f006722..2688998 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -59,12 +59,12 @@ type ServerConfig struct { Plugins map[string]PluginSettings `toml:"plugin"` Auth map[string]AuthConfig `toml:"auth"` ProfileMode string `toml:"profile_mode"` - AppDefaults AppDefaults `toml:"appdefaults"` + AppConfig AppConfig `toml:"app_config"` } type PluginSettings map[string]any -type AppDefaults struct { +type AppConfig struct { CORS CORS `toml:"cors"` } @@ -282,7 +282,7 @@ type AppMetadata struct { SpecFiles *SpecFiles `json:"spec_files"` ContainerOptions map[string]string `json:"container_options"` ContainerArgs map[string]string `json:"container_args"` - AppDefaults map[string]string `json:"appdefaults"` + AppConfig map[string]string `json:"appconfig"` } // AppSettings contains the settings for an app. Settings are not version controlled. @@ -359,6 +359,14 @@ const ( StringValueUndefined StringValue = "" ) +type AppMetadataConfigType string + +const ( + AppMetadataAppConfig AppMetadataConfigType = "app_config" + AppMetadataContainerOptions AppMetadataConfigType = "container_options" + AppMetadataContainerArgs AppMetadataConfigType = "container_args" +) + type AppVersion struct { Active bool AppId AppId @@ -383,3 +391,13 @@ type Transaction struct { func (t *Transaction) IsInitialized() bool { return t.Tx != nil } + +func StripQuotes(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' { + return s[1 : len(s)-1] + } + return s +}