From 8d733262ca5453efcd887cc15e6f3104b942ddf8 Mon Sep 17 00:00:00 2001 From: dwan-ith Date: Mon, 10 Aug 2026 02:00:22 +0530 Subject: [PATCH] feat(config): support runtime configuration from environment variables (Issue 1559) --- .env.example | 12 ++ README.md | 5 + charts/README.md | 8 +- charts/values.yaml | 5 +- cmd/command.go | 37 +++++ docs/runtime-configuration.md | 88 +++++++++++ internal/base/conf/conf.go | 75 +++++---- internal/base/conf/environment.go | 203 +++++++++++++++++++++++++ internal/base/conf/environment_test.go | 161 ++++++++++++++++++++ 9 files changed, 559 insertions(+), 35 deletions(-) create mode 100644 docs/runtime-configuration.md create mode 100644 internal/base/conf/environment.go create mode 100644 internal/base/conf/environment_test.go diff --git a/.env.example b/.env.example index b08736cdf..98cdc7d91 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,15 @@ SITE_ADDR=0.0.0.0:3000 # Logging LOG_LEVEL=INFO LOG_PATH= + +# Runtime configuration +# +# Use `answer config export-env -C /data` after installation to produce the +# complete canonical variable set. The two database variables below must be +# non-empty to start without config.yaml. Keep the exported file secret because +# the connection value can contain database credentials. +# +# ANSWER_DATA_DATABASE_DRIVER= +# ANSWER_DATA_DATABASE_CONNECTION= +# +# See docs/runtime-configuration.md for every ANSWER_* runtime variable. diff --git a/README.md b/README.md index cf257aba2..084238d58 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ docker run -d -p 9080:80 -v answer-data:/data --name answer apache/answer:2.0.2 For more information, see [Installation](https://answer.apache.org/docs/installation). +Answer can also start from a complete set of runtime environment variables +without a local `config.yaml`. See [runtime configuration from environment +variables](docs/runtime-configuration.md) for the supported variables, +precedence rules, and safe export workflow. + ### Plugins Answer provides a plugin system for developers to create custom plugins and expand Answer’s features. You can find the [plugin documentation here](https://answer.apache.org/community/plugins). diff --git a/charts/README.md b/charts/README.md index 26cd6a9cf..b8b9f2159 100644 --- a/charts/README.md +++ b/charts/README.md @@ -6,6 +6,12 @@ An open-source knowledge-based community software. You can use it quickly to bui - Kubernetes 1.20+ ## Configuration +Answer can run without a persisted `config.yaml` when the complete runtime +configuration is supplied through environment variables. See [runtime +configuration from environment variables](../docs/runtime-configuration.md). +Uploads still require persistent or external storage if they must survive pod +replacement. + The following table lists the configurable parameters of the answer chart and their default values. | Parameter | Description | Default | @@ -72,4 +78,4 @@ Publish the chart to Artifacthub and add proper installation instructions. E.G. $ helm repo add apache https://charts.answer.apache.org/ $ helm repo update $ helm install apache/answer -n mynamespace -``` \ No newline at end of file +``` diff --git a/charts/values.yaml b/charts/values.yaml index d932db848..1f588631f 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -80,7 +80,8 @@ extraContainers: [] # - containerPort: 5432 # Persistence for the /data volume -# Without persistence, your uploads and config.yaml will not be remembered between restarts. +# Without persistence, uploads will not survive restarts. config.yaml can be +# replaced by the documented ANSWER_* runtime environment variables. persistence: enabled: true # If set to "-", storageClassName: "", which disables dynamic provisioning @@ -167,4 +168,4 @@ nodeSelector: {} tolerations: [] -affinity: {} \ No newline at end of file +affinity: {} diff --git a/cmd/command.go b/cmd/command.go index ca01aa80b..a48436064 100644 --- a/cmd/command.go +++ b/cmd/command.go @@ -77,6 +77,7 @@ func init() { upgradeCmd.Flags().StringVarP(&upgradeVersion, "from", "f", "", "upgrade from specific version, eg: -f v1.1.0") configCmd.Flags().StringSliceVarP(&configFields, "with", "w", []string{}, "the fields that need to be set to the default value, eg: -w allow_password_login") + configCmd.AddCommand(configExportEnvCmd) i18nCmd.Flags().StringVarP(&i18nSourcePath, "source", "s", "", "i18n source path, eg: -s ./i18n/source") @@ -136,6 +137,19 @@ To run answer, use: } } + if conf.RuntimeEnvironmentConfigured() { + fmt.Println("runtime configuration found in environment, try to connect database...") + c, err := conf.ReadConfig(path.GetConfigFilePath()) + if err != nil { + fmt.Println("read environment config failed: ", err.Error()) + return + } + if cli.CheckDBTableExist(c.Data.Database) { + fmt.Println("connect to database successfully and table already exists, do nothing.") + return + } + } + // start installation server to install install.Run(path.GetConfigFilePath()) }, @@ -286,6 +300,29 @@ To run answer, use: }, } + configExportEnvCmd = &cobra.Command{ + Use: "export-env", + Short: "Export runtime configuration in dotenv format", + SilenceUsage: true, + Long: `Export the effective runtime configuration in dotenv format. +The output can contain database credentials and is only written to standard output +when this command is explicitly invoked.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + path.FormatAllPath(dataDirPath) + c, err := conf.ReadConfig(path.GetConfigFilePath()) + if err != nil { + return fmt.Errorf("read config failed: %w", err) + } + output, err := conf.ExportEnvironment(c) + if err != nil { + return fmt.Errorf("export config failed: %w", err) + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), output) + return err + }, + } + i18nCmd = &cobra.Command{ Use: "i18n", Short: "Overwrite i18n files", diff --git a/docs/runtime-configuration.md b/docs/runtime-configuration.md new file mode 100644 index 000000000..1279a8529 --- /dev/null +++ b/docs/runtime-configuration.md @@ -0,0 +1,88 @@ + + +# Runtime configuration from environment variables + +Answer normally reads its runtime configuration from `/data/conf/config.yaml`. +It can also run without that file when both +`ANSWER_DATA_DATABASE_DRIVER` and `ANSWER_DATA_DATABASE_CONNECTION` are set to +non-empty values. This explicit requirement prevents an accidentally missing +configuration file from silently starting Answer with the embedded SQLite +defaults. + +When `config.yaml` exists, canonical `ANSWER_*` environment variables override +the corresponding file values. `SITE_ADDR`, `SWAGGER_HOST`, and +`SWAGGER_ADDRESS_PORT` remain supported for backward compatibility, but their +canonical replacements take precedence when both names are set. + +When `config.yaml` does not exist and the two required database variables are +present, Answer starts with the embedded configuration template and applies all +set environment variables over it. Values omitted from the environment retain +the embedded defaults shown below. + +| Environment variable | `config.yaml` field | Embedded default | +| --- | --- | --- | +| `ANSWER_DEBUG` | `debug` | `false` | +| `ANSWER_SERVER_HTTP_ADDR` | `server.http.addr` | `0.0.0.0:80` | +| `ANSWER_DATA_DATABASE_DRIVER` | `data.database.driver` | `sqlite3` | +| `ANSWER_DATA_DATABASE_CONNECTION` | `data.database.connection` | `/data/sqlite3/answer.db` | +| `ANSWER_DATA_DATABASE_CONN_MAX_LIFE_TIME` | `data.database.conn_max_life_time` | `0` | +| `ANSWER_DATA_DATABASE_MAX_OPEN_CONN` | `data.database.max_open_conn` | `0` | +| `ANSWER_DATA_DATABASE_MAX_IDLE_CONN` | `data.database.max_idle_conn` | `0` | +| `ANSWER_DATA_CACHE_FILE_PATH` | `data.cache.file_path` | `/data/cache/cache.db` | +| `ANSWER_I18N_BUNDLE_DIR` | `i18n.bundle_dir` | `/data/i18n` | +| `ANSWER_SERVICE_CONFIG_UPLOAD_PATH` | `service_config.upload_path` | `/data/uploads` | +| `ANSWER_SERVICE_CONFIG_CLEAN_UP_UPLOADS` | `service_config.clean_up_uploads` | `true` | +| `ANSWER_SERVICE_CONFIG_CLEAN_ORPHAN_UPLOADS_PERIOD_HOURS` | `service_config.clean_orphan_uploads_period_hours` | `48` | +| `ANSWER_SERVICE_CONFIG_PURGE_DELETED_FILES_PERIOD_DAYS` | `service_config.purge_deleted_files_period_days` | `30` | +| `ANSWER_SWAGGERUI_SHOW` | `swaggerui.show` | `true` | +| `ANSWER_SWAGGERUI_PROTOCOL` | `swaggerui.protocol` | `http` | +| `ANSWER_SWAGGERUI_HOST` | `swaggerui.host` | `127.0.0.1` | +| `ANSWER_SWAGGERUI_ADDRESS` | `swaggerui.address` | `:80` | +| `ANSWER_UI_BASE_URL` | `ui.base_url` | empty | +| `ANSWER_UI_API_BASE_URL` | `ui.api_base_url` | empty | + +Boolean values use Go boolean syntax such as `true` or `false`. Integer values +must be base-10 integers. An invalid value stops configuration loading and names +the invalid variable. + +## Exporting an installed configuration + +After installation, export the effective runtime configuration in dotenv format: + +```bash +umask 077 +answer config export-env -C /data > answer-runtime.env +``` + +The command writes only the dotenv document to standard output, so it can also +be piped to a secret-management command. The database connection can contain a +password. Answer never emits the exported document during ordinary `init`, +`upgrade`, or `run` operations; it is produced only by this explicit command. + +The exported file contains the complete canonical variable set. Store it as a +secret, not as a ConfigMap or a source-controlled file. + +To start another instance against the already initialized database, inject the +exported values and run Answer normally. `AUTO_INSTALL` is not needed and should +not be set for this runtime-only path. + +Configuration statelessness does not make uploaded files persistent. Configure +the appropriate upload storage plugin or retain storage for the upload path if +uploads must survive pod replacement. diff --git a/internal/base/conf/conf.go b/internal/base/conf/conf.go index 04e3a19ba..4571c45d7 100644 --- a/internal/base/conf/conf.go +++ b/internal/base/conf/conf.go @@ -21,9 +21,11 @@ package conf import ( "bytes" + "errors" "os" "path/filepath" + "github.com/apache/answer/configs" "github.com/apache/answer/internal/base/data" "github.com/apache/answer/internal/base/path" "github.com/apache/answer/internal/base/server" @@ -46,20 +48,6 @@ type AllConfig struct { UI *server.UI `json:"ui" mapstructure:"ui" yaml:"ui"` } -type envConfigOverrides struct { - SwaggerHost string - SwaggerAddressPort string - SiteAddr string -} - -func loadEnvs() (envOverrides *envConfigOverrides) { - return &envConfigOverrides{ - SwaggerHost: os.Getenv("SWAGGER_HOST"), - SwaggerAddressPort: os.Getenv("SWAGGER_ADDRESS_PORT"), - SiteAddr: os.Getenv("SITE_ADDR"), - } -} - type PathIgnore struct { Users []string `yaml:"users"` } @@ -77,21 +65,32 @@ type Data struct { // SetDefault set default config func (c *AllConfig) SetDefault() { - if c.UI == nil { - c.UI = &server.UI{} + if c.Server == nil { + c.Server = &Server{} } -} - -func (c *AllConfig) SetEnvironmentOverrides() { - envs := loadEnvs() - if envs.SiteAddr != "" { - c.Server.HTTP.Addr = envs.SiteAddr + if c.Server.HTTP == nil { + c.Server.HTTP = &server.HTTP{} + } + if c.Data == nil { + c.Data = &Data{} } - if envs.SwaggerHost != "" { - c.Swaggerui.Host = envs.SwaggerHost + if c.Data.Database == nil { + c.Data.Database = &data.Database{} } - if envs.SwaggerAddressPort != "" { - c.Swaggerui.Address = envs.SwaggerAddressPort + if c.Data.Cache == nil { + c.Data.Cache = &data.CacheConf{} + } + if c.I18n == nil { + c.I18n = &translator.I18n{} + } + if c.ServiceConfig == nil { + c.ServiceConfig = &service_config.ServiceConfig{} + } + if c.Swaggerui == nil { + c.Swaggerui = &router.SwaggerConfig{} + } + if c.UI == nil { + c.UI = &server.UI{} } } @@ -101,15 +100,27 @@ func ReadConfig(configFilePath string) (c *AllConfig, err error) { configFilePath = filepath.Join(path.ConfigFileDir, path.DefaultConfigFileName) } c = &AllConfig{} - config, err := viper.NewWithPath(configFilePath) - if err != nil { - return nil, err + _, statErr := os.Stat(configFilePath) + switch { + case statErr == nil: + config, err := viper.NewWithPath(configFilePath) + if err != nil { + return nil, err + } + if err = config.Parse(&c); err != nil { + return nil, err + } + case errors.Is(statErr, os.ErrNotExist) && RuntimeEnvironmentConfigured(): + if err = yaml.Unmarshal(configs.Config, c); err != nil { + return nil, err + } + default: + return nil, statErr } - if err = config.Parse(&c); err != nil { + c.SetDefault() + if err = c.SetEnvironmentOverrides(); err != nil { return nil, err } - c.SetDefault() - c.SetEnvironmentOverrides() return c, nil } diff --git a/internal/base/conf/environment.go b/internal/base/conf/environment.go new file mode 100644 index 000000000..9990c53c4 --- /dev/null +++ b/internal/base/conf/environment.go @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package conf + +import ( + "fmt" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +const ( + envDatabaseDriver = "ANSWER_DATA_DATABASE_DRIVER" + envDatabaseConnection = "ANSWER_DATA_DATABASE_CONNECTION" +) + +type environmentVariable struct { + name string + aliases []string + apply func(*AllConfig, string) error + value func(*AllConfig) string +} + +func stringEnvironmentVariable( + name string, + aliases []string, + field func(*AllConfig) *string, +) environmentVariable { + return environmentVariable{ + name: name, + aliases: aliases, + apply: func(config *AllConfig, value string) error { + *field(config) = value + return nil + }, + value: func(config *AllConfig) string { + return *field(config) + }, + } +} + +func boolEnvironmentVariable(name string, field func(*AllConfig) *bool) environmentVariable { + return environmentVariable{ + name: name, + apply: func(config *AllConfig, value string) error { + parsed, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("%s must be a boolean: %w", name, err) + } + *field(config) = parsed + return nil + }, + value: func(config *AllConfig) string { + return strconv.FormatBool(*field(config)) + }, + } +} + +func intEnvironmentVariable(name string, field func(*AllConfig) *int) environmentVariable { + return environmentVariable{ + name: name, + apply: func(config *AllConfig, value string) error { + parsed, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("%s must be an integer: %w", name, err) + } + *field(config) = parsed + return nil + }, + value: func(config *AllConfig) string { + return strconv.Itoa(*field(config)) + }, + } +} + +func runtimeEnvironmentVariables() []environmentVariable { + return []environmentVariable{ + boolEnvironmentVariable("ANSWER_DEBUG", func(config *AllConfig) *bool { + return &config.Debug + }), + stringEnvironmentVariable("ANSWER_SERVER_HTTP_ADDR", []string{"SITE_ADDR"}, func(config *AllConfig) *string { + return &config.Server.HTTP.Addr + }), + stringEnvironmentVariable(envDatabaseDriver, nil, func(config *AllConfig) *string { + return &config.Data.Database.Driver + }), + stringEnvironmentVariable(envDatabaseConnection, nil, func(config *AllConfig) *string { + return &config.Data.Database.Connection + }), + intEnvironmentVariable("ANSWER_DATA_DATABASE_CONN_MAX_LIFE_TIME", func(config *AllConfig) *int { + return &config.Data.Database.ConnMaxLifeTime + }), + intEnvironmentVariable("ANSWER_DATA_DATABASE_MAX_OPEN_CONN", func(config *AllConfig) *int { + return &config.Data.Database.MaxOpenConn + }), + intEnvironmentVariable("ANSWER_DATA_DATABASE_MAX_IDLE_CONN", func(config *AllConfig) *int { + return &config.Data.Database.MaxIdleConn + }), + stringEnvironmentVariable("ANSWER_DATA_CACHE_FILE_PATH", nil, func(config *AllConfig) *string { + return &config.Data.Cache.FilePath + }), + stringEnvironmentVariable("ANSWER_I18N_BUNDLE_DIR", nil, func(config *AllConfig) *string { + return &config.I18n.BundleDir + }), + stringEnvironmentVariable("ANSWER_SERVICE_CONFIG_UPLOAD_PATH", nil, func(config *AllConfig) *string { + return &config.ServiceConfig.UploadPath + }), + boolEnvironmentVariable("ANSWER_SERVICE_CONFIG_CLEAN_UP_UPLOADS", func(config *AllConfig) *bool { + return &config.ServiceConfig.CleanUpUploads + }), + intEnvironmentVariable("ANSWER_SERVICE_CONFIG_CLEAN_ORPHAN_UPLOADS_PERIOD_HOURS", func(config *AllConfig) *int { + return &config.ServiceConfig.CleanOrphanUploadsPeriodHours + }), + intEnvironmentVariable("ANSWER_SERVICE_CONFIG_PURGE_DELETED_FILES_PERIOD_DAYS", func(config *AllConfig) *int { + return &config.ServiceConfig.PurgeDeletedFilesPeriodDays + }), + boolEnvironmentVariable("ANSWER_SWAGGERUI_SHOW", func(config *AllConfig) *bool { + return &config.Swaggerui.Show + }), + stringEnvironmentVariable("ANSWER_SWAGGERUI_PROTOCOL", nil, func(config *AllConfig) *string { + return &config.Swaggerui.Protocol + }), + stringEnvironmentVariable("ANSWER_SWAGGERUI_HOST", []string{"SWAGGER_HOST"}, func(config *AllConfig) *string { + return &config.Swaggerui.Host + }), + stringEnvironmentVariable("ANSWER_SWAGGERUI_ADDRESS", []string{"SWAGGER_ADDRESS_PORT"}, func(config *AllConfig) *string { + return &config.Swaggerui.Address + }), + stringEnvironmentVariable("ANSWER_UI_BASE_URL", nil, func(config *AllConfig) *string { + return &config.UI.BaseURL + }), + stringEnvironmentVariable("ANSWER_UI_API_BASE_URL", nil, func(config *AllConfig) *string { + return &config.UI.APIBaseURL + }), + } +} + +func lookupEnvironment(variable environmentVariable) (string, bool) { + if value, ok := os.LookupEnv(variable.name); ok { + return value, true + } + for _, alias := range variable.aliases { + if value, ok := os.LookupEnv(alias); ok && value != "" { + return value, true + } + } + return "", false +} + +// RuntimeEnvironmentConfigured reports whether the required database settings +// are present. Requiring both avoids silently treating an accidentally missing +// config file as a valid environment-only deployment. +func RuntimeEnvironmentConfigured() bool { + driver, driverSet := os.LookupEnv(envDatabaseDriver) + connection, connectionSet := os.LookupEnv(envDatabaseConnection) + return driverSet && driver != "" && connectionSet && connection != "" +} + +// SetEnvironmentOverrides applies runtime environment variables to c. Canonical +// ANSWER_* variables take precedence over the three legacy aliases. +func (c *AllConfig) SetEnvironmentOverrides() error { + c.SetDefault() + for _, variable := range runtimeEnvironmentVariables() { + value, ok := lookupEnvironment(variable) + if !ok { + continue + } + if err := variable.apply(c, value); err != nil { + return err + } + } + return nil +} + +// ExportEnvironment serializes every runtime configuration value in dotenv +// format. Database connection strings may contain credentials, so callers must +// only expose the returned value as the result of an explicit user action. +func ExportEnvironment(c *AllConfig) (string, error) { + c.SetDefault() + values := make(map[string]string, len(runtimeEnvironmentVariables())) + for _, variable := range runtimeEnvironmentVariables() { + values[variable.name] = variable.value(c) + } + return godotenv.Marshal(values) +} diff --git a/internal/base/conf/environment_test.go b/internal/base/conf/environment_test.go new file mode 100644 index 000000000..48319b643 --- /dev/null +++ b/internal/base/conf/environment_test.go @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package conf + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/apache/answer/configs" + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func clearRuntimeEnvironment(t *testing.T) { + t.Helper() + for _, variable := range runtimeEnvironmentVariables() { + names := append([]string{variable.name}, variable.aliases...) + for _, name := range names { + oldValue, wasSet := os.LookupEnv(name) + require.NoError(t, os.Unsetenv(name)) + t.Cleanup(func() { + if wasSet { + require.NoError(t, os.Setenv(name, oldValue)) + return + } + require.NoError(t, os.Unsetenv(name)) + }) + } + } +} + +func writeDefaultConfig(t *testing.T) string { + t.Helper() + configPath := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(configPath, configs.Config, 0o600)) + return configPath +} + +func countRuntimeConfigFields(configType reflect.Type) int { + if configType.Kind() == reflect.Pointer { + configType = configType.Elem() + } + if configType.Kind() != reflect.Struct { + return 1 + } + + count := 0 + for index := range configType.NumField() { + field := configType.Field(index) + if !field.IsExported() { + continue + } + count += countRuntimeConfigFields(field.Type) + } + return count +} + +func TestReadConfigRequiresFileOrRuntimeDatabaseEnvironment(t *testing.T) { + clearRuntimeEnvironment(t) + + _, err := ReadConfig(filepath.Join(t.TempDir(), "missing.yaml")) + + require.Error(t, err) +} + +func TestReadConfigFromEnvironmentWithoutFile(t *testing.T) { + clearRuntimeEnvironment(t) + t.Setenv(envDatabaseDriver, "postgres") + t.Setenv(envDatabaseConnection, "postgres://answer:secret@database/answer?sslmode=require") + t.Setenv("ANSWER_SERVER_HTTP_ADDR", "0.0.0.0:8080") + t.Setenv("ANSWER_DATA_DATABASE_MAX_OPEN_CONN", "25") + t.Setenv("ANSWER_SERVICE_CONFIG_CLEAN_UP_UPLOADS", "false") + t.Setenv("ANSWER_SWAGGERUI_SHOW", "false") + t.Setenv("ANSWER_SWAGGERUI_PROTOCOL", "") + + config, err := ReadConfig(filepath.Join(t.TempDir(), "missing.yaml")) + + require.NoError(t, err) + assert.Equal(t, "postgres", config.Data.Database.Driver) + assert.Equal(t, "postgres://answer:secret@database/answer?sslmode=require", config.Data.Database.Connection) + assert.Equal(t, 25, config.Data.Database.MaxOpenConn) + assert.Equal(t, "0.0.0.0:8080", config.Server.HTTP.Addr) + assert.False(t, config.ServiceConfig.CleanUpUploads) + assert.False(t, config.Swaggerui.Show) + assert.Empty(t, config.Swaggerui.Protocol) + assert.Equal(t, "/data/cache/cache.db", config.Data.Cache.FilePath) +} + +func TestEnvironmentOverridesConfigAndCanonicalNameWins(t *testing.T) { + clearRuntimeEnvironment(t) + t.Setenv("SITE_ADDR", "0.0.0.0:8080") + t.Setenv("ANSWER_SERVER_HTTP_ADDR", "127.0.0.1:9090") + t.Setenv("ANSWER_DATA_DATABASE_CONN_MAX_LIFE_TIME", "120") + + config, err := ReadConfig(writeDefaultConfig(t)) + + require.NoError(t, err) + assert.Equal(t, "127.0.0.1:9090", config.Server.HTTP.Addr) + assert.Equal(t, 120, config.Data.Database.ConnMaxLifeTime) +} + +func TestReadConfigRejectsInvalidEnvironmentValue(t *testing.T) { + clearRuntimeEnvironment(t) + t.Setenv("ANSWER_SWAGGERUI_SHOW", "sometimes") + + _, err := ReadConfig(writeDefaultConfig(t)) + + require.Error(t, err) + assert.Contains(t, err.Error(), "ANSWER_SWAGGERUI_SHOW must be a boolean") +} + +func TestRuntimeEnvironmentConfiguredRequiresDatabasePair(t *testing.T) { + clearRuntimeEnvironment(t) + assert.False(t, RuntimeEnvironmentConfigured()) + + t.Setenv(envDatabaseDriver, "mysql") + assert.False(t, RuntimeEnvironmentConfigured()) + + t.Setenv(envDatabaseConnection, "answer:secret@tcp(database:3306)/answer") + assert.True(t, RuntimeEnvironmentConfigured()) +} + +func TestExportEnvironmentIncludesEveryCanonicalVariable(t *testing.T) { + clearRuntimeEnvironment(t) + config, err := ReadConfig(writeDefaultConfig(t)) + require.NoError(t, err) + config.Data.Database.Driver = "mysql" + config.Data.Database.Connection = "answer:p@ss word@tcp(database:3306)/answer" + + output, err := ExportEnvironment(config) + require.NoError(t, err) + values, err := godotenv.Unmarshal(output) + require.NoError(t, err) + + assert.Len(t, values, countRuntimeConfigFields(reflect.TypeOf(AllConfig{}))) + assert.Equal(t, "mysql", values[envDatabaseDriver]) + assert.Equal(t, "answer:p@ss word@tcp(database:3306)/answer", values[envDatabaseConnection]) + for _, variable := range runtimeEnvironmentVariables() { + assert.Contains(t, values, variable.name) + } +}