Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,6 @@ All PR titles should start with Upper case and have no dot at the end.
and [base-builder](https://github.com/temporalio/docker-builds/blob/main/docker/base-images/base-builder.Dockerfile)~~
**Note:** The docker-builds repository is now deprecated and will be archived.

<!-- TODO: Remove docker/config_template.yaml after temporalio/docker-builds repository is archived -->

## License

MIT License, please see [LICENSE](LICENSE) for details.
341 changes: 170 additions & 171 deletions common/config/config_template_embedded.yaml

Large diffs are not rendered by default.

31 changes: 23 additions & 8 deletions common/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"os"
"strings"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -13,17 +14,31 @@ func TestToString(t *testing.T) {
require.NotEmpty(t, cfg.String())
}

func TestEmbeddedTemplateOnlyDiffersFromDockerByComment(t *testing.T) {
embeddedContent, err := os.ReadFile("config_template_embedded.yaml")
func TestEmbeddedTemplateConsistentWithDockerTemplate(t *testing.T) {
embeddedBytes, err := os.ReadFile("config_template_embedded.yaml")
require.NoError(t, err)

dockerContent, err := os.ReadFile("../../docker/config_template.yaml")
embeddedFiltered := skipComments(embeddedBytes, 3)
dockerBytes, err := os.ReadFile("../../docker/config_template.yaml")
require.NoError(t, err)
dockerFiltered := skipComments(dockerBytes, 3)
require.Equal(t, embeddedFiltered, dockerFiltered, "embedded template does not match docker template")

dockerWithComment := "# enable-template\n" + string(dockerContent)
}

func skipComments(fb []byte, first int) string {
lines := strings.Split(string(fb), "\n")
i := 0
var filtered []string
for i < first && i < len(lines) {

require.Equal(t, dockerWithComment, string(embeddedContent),
"Embedded template (config_template_embedded.yaml) must only differ from docker template "+
"(docker/config_template.yaml) by the '# enable-template' comment at the top. "+
"This comment is required for the config loader to detect and process the template.")
if strings.HasPrefix(strings.TrimSpace(lines[i]), "#") {
i++
continue
}
filtered = append(filtered, strings.TrimSpace(lines[i]))
i++
}
filtered = append(filtered, lines[i:]...)
return strings.Join(filtered, "\n")
}
97 changes: 10 additions & 87 deletions common/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ type loadOptions struct {
zone string
configFilePath string
useEmbeddedOnly bool
envMap map[string]string
}

type loadOption func(*loadOptions)
Expand Down Expand Up @@ -117,31 +116,19 @@ func WithEmbedded() loadOption {
}
}

// WithEnvMap provides a custom environment variable map for template rendering.
// If not provided, the loader will use os.Environ() to populate environment variables.
func WithEnvMap(envMap map[string]string) loadOption {
return func(o *loadOptions) {
if envMap != nil {
o.envMap = envMap
}
}
}

// Load loads and validates the Temporal server configuration.
// It supports multiple loading strategies based on the provided options:
// - Embedded template with environment variables (WithEmbedded)
// - Single config file (WithConfigFile)
// - Legacy hierarchical config directory (WithConfigDir, WithEnv, WithZone)
//
// Configuration files can be templated using Go template syntax with dockerize-compatible
// Configuration files can be templated using Go template syntax with sprig-compatible
// functions. To enable templating, add "# enable-template" comment in the first 1KB of the file.
//
// Returns the loaded configuration or an error if loading or validation fails.
func Load(opts ...loadOption) (*Config, error) {
cfg := &Config{}
options := &loadOptions{
envMap: loadEnvMap(),
}
options := &loadOptions{}

for _, opt := range opts {
opt(options)
Expand All @@ -154,21 +141,18 @@ func Load(opts ...loadOption) (*Config, error) {
}

func (opts *loadOptions) load(config any) error {
if opts.envMap == nil {
opts.envMap = loadEnvMap()
}

if opts.useEmbeddedOnly {
stdlog.Println("Loading configuration from environment variables only")
return loadAndUnmarshalContent(embeddedConfigTemplate, "config_template_embedded.yaml", opts.envMap, config)
return loadAndUnmarshalContent(embeddedConfigTemplate, "config_template_embedded.yaml", config)
}

if opts.configFilePath != "" {
content, err := readConfigFile(opts.configFilePath)
if err != nil {
return err
}
return loadAndUnmarshalContent(content, filepath.Base(opts.configFilePath), opts.envMap, config)
return loadAndUnmarshalContent(content, filepath.Base(opts.configFilePath), config)
}
return opts.loadLegacy(config)

Expand Down Expand Up @@ -215,7 +199,7 @@ func (opts *loadOptions) loadLegacy(config any) error {
return err
}

processedData, err := processConfigFile(data, filepath.Base(f), opts.envMap)
processedData, err := processConfigFile(data, filepath.Base(f))
if err != nil {
return err
}
Expand All @@ -240,7 +224,7 @@ func readConfigFile(path string) ([]byte, error) {
}

// processConfigFile processes a config file, rendering it as a template if enabled
func processConfigFile(data []byte, filename string, envMap map[string]string) ([]byte, error) {
func processConfigFile(data []byte, filename string) ([]byte, error) {
// If the config file contains "enable-template" in a comment within the first 1KB, then
// we will treat the file as a template and render it.
templating, err := checkTemplatingEnabled(data)
Expand All @@ -253,83 +237,22 @@ func processConfigFile(data []byte, filename string, envMap map[string]string) (
}

stdlog.Printf("Processing config file as template; filename=%v\n", filename)
return renderTemplate(data, filename, envMap)
}

// templateContext mimics dockerize's Context struct to support .Env.VAR_NAME syntax.
// In dockerize, .Env is a method that returns the environment map, allowing dot-based access.
type templateContext struct {
envMap map[string]string
}

// Env returns the environment variable map, matching dockerize's Context.Env() method.
// This allows templates to use .Env.VAR_NAME syntax for environment variable access.
func (c *templateContext) Env() map[string]string {
return c.envMap
}

// defaultValue implements dockerize-compatible default handling.
// This properly handles nil values from missing map keys when using .Env.VAR syntax.
// Args order: value first, default second (e.g., {{ default .Env.VAR "fallback" }})
func defaultValue(args ...any) (string, error) {
if len(args) == 0 {
return "", errors.New("default called with no values")
}

if len(args) > 0 {
if args[0] != nil {
val, ok := args[0].(string)
if !ok {
return "", errors.New("first argument is not a string")
}
return val, nil
}
}

if len(args) > 1 {
if args[1] == nil {
return "", errors.New("default called with nil default value")
}

val, ok := args[1].(string)
if !ok {
return "", errors.New("default is not a string value, hint: surround it w/ double quotes")
}

return val, nil
}

return "", errors.New("default called with no default value")
}

// renderTemplate renders a config file as a Go template with environment variables.
// It uses dockerize-compatible template functions and supports .Env.VAR syntax.
func renderTemplate(data []byte, filename string, envMap map[string]string) ([]byte, error) {
templateFuncs := sprig.FuncMap()
// Override sprig's default with dockerize's implementation that properly handles
// nil values from missing environment variables
templateFuncs["default"] = defaultValue

// Create a context with Env() method that returns the environment map
// Templates access environment variables using .Env.VAR_NAME syntax
ctx := &templateContext{envMap: envMap}

tpl, err := template.New(filename).Funcs(templateFuncs).Parse(string(data))
tpl, err := template.New(filename).Funcs(sprig.FuncMap()).Parse(string(data))
if err != nil {
return nil, err
}

var rendered bytes.Buffer
err = tpl.Execute(&rendered, ctx)
err = tpl.Execute(&rendered, nil)
if err != nil {
return nil, err
}

return rendered.Bytes(), nil
}

func loadAndUnmarshalContent(content []byte, filename string, envMap map[string]string, config any) error {
processed, err := processConfigFile(content, filename, envMap)
func loadAndUnmarshalContent(content []byte, filename string, config any) error {
processed, err := processConfigFile(content, filename)
if err != nil {
return fmt.Errorf("failed to process config file %s: %w", filename, err)
}
Expand Down
113 changes: 3 additions & 110 deletions common/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,82 +12,6 @@ import (

const fileMode = os.FileMode(0644)

func TestRenderTemplateWithEnvVars(t *testing.T) {
templateContent := []byte(`# enable-template
log:
level: {{ default .Env.LOG_LEVEL "info" }}
persistence:
numHistoryShards: {{ default .Env.NUM_HISTORY_SHARDS "4" }}`)

testCases := []struct {
name string
envMap map[string]string
expectedLogLevel string
expectedNumShards string
}{
{
name: "with environment variables set",
envMap: map[string]string{
"LOG_LEVEL": "debug",
"NUM_HISTORY_SHARDS": "8",
},
expectedLogLevel: "debug",
expectedNumShards: "8",
},
{
name: "with no environment variables - uses defaults",
envMap: map[string]string{},
expectedLogLevel: "info",
expectedNumShards: "4",
},
{
name: "with partial environment variables",
envMap: map[string]string{
"LOG_LEVEL": "warn",
},
expectedLogLevel: "warn",
expectedNumShards: "4",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
rendered, err := renderTemplate(templateContent, "test.yaml", tc.envMap)
require.NoError(t, err)

renderedStr := string(rendered)
require.Contains(t, renderedStr, "level: "+tc.expectedLogLevel)
require.Contains(t, renderedStr, "numHistoryShards: "+tc.expectedNumShards)
})
}
}

func TestProcessConfigFile(t *testing.T) {
content := []byte(`log:
level: info`)

envMap := map[string]string{"LOG_LEVEL": "debug"}
processed, err := processConfigFile(content, "test.yaml", envMap)
require.NoError(t, err)
require.Equal(t, content, processed)
}

func TestLoadWithEmbeddedTemplate(t *testing.T) {
envMap := map[string]string{
"DB": "postgres12",
"POSTGRES_SEEDS": "localhost",
}

cfg, err := Load(WithEmbedded(), WithEnvMap(envMap))
require.NoError(t, err)

// Verify embedded template loaded with defaults
require.Equal(t, "info", cfg.Log.Level)
require.Equal(t, int32(4), cfg.Persistence.NumHistoryShards)
require.NotNil(t, cfg.Services["frontend"])
require.Equal(t, 7233, cfg.Services["frontend"].RPC.GRPCPort)
}

func TestLoad(t *testing.T) {
const staticConfig = `log:
level: warn
Expand All @@ -110,9 +34,9 @@ services:

const templateConfig = `# enable-template
log:
level: {{ default .Env.LOG_LEVEL "info" }}
level: {{ default "info" (env "LOG_LEVEL") }}
persistence:
numHistoryShards: {{ default .Env.NUM_HISTORY_SHARDS "4" }}
numHistoryShards: {{ default "4" (env "NUM_HISTORY_SHARDS") }}
defaultStore: default
datastores:
default:
Expand All @@ -124,7 +48,7 @@ persistence:
services:
frontend:
rpc:
grpcPort: {{ default .Env.FRONTEND_GRPC_PORT "7233" }}
grpcPort: {{ default "7233" (env "FRONTEND_GRPC_PORT") }}
bindOnIP: "127.0.0.1"
`

Expand Down Expand Up @@ -156,37 +80,6 @@ services:
require.Equal(t, 9233, cfg.Services["frontend"].RPC.GRPCPort)
},
},
{
name: "template config with custom env vars",
configContent: templateConfig,
loadOptions: func(configPath string) []loadOption {
envMap := map[string]string{
"LOG_LEVEL": "debug",
"NUM_HISTORY_SHARDS": "8",
"FRONTEND_GRPC_PORT": "8233",
}
return []loadOption{WithConfigDir(filepath.Dir(configPath)), WithEnvMap(envMap)}
},
expectError: false,
validateConfig: func(t *testing.T, cfg *Config) {
require.Equal(t, "debug", cfg.Log.Level)
require.Equal(t, int32(8), cfg.Persistence.NumHistoryShards)
require.Equal(t, 8233, cfg.Services["frontend"].RPC.GRPCPort)
},
},
{
name: "template config uses defaults when env vars not set",
configContent: templateConfig,
loadOptions: func(configPath string) []loadOption {
return []loadOption{WithConfigDir(filepath.Dir(configPath))}
},
expectError: false,
validateConfig: func(t *testing.T, cfg *Config) {
require.Equal(t, "info", cfg.Log.Level)
require.Equal(t, int32(4), cfg.Persistence.NumHistoryShards)
require.Equal(t, 7233, cfg.Services["frontend"].RPC.GRPCPort)
},
},
{
name: "template config with file path uses system env vars",
configContent: templateConfig,
Expand Down
Loading
Loading