Skip to content
Open
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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ reaches a different upstream with no change to the Worker.
For rules neither covers, delegate the decision to an extension server you run. It is told what the call is addressing
(the gRPC method, and the Namespace the proxy resolved from the request rather than from anything the caller claims),
so it can decide per Namespace and per method rather than only whether the caller is who it says it is.
- **Prometheus metrics.** Expose request latency and counts, routing decisions, and encryption activity on `/metrics`.
The listen address and the namespace prefixed onto every metric are set under `metrics:` in the config.
- **Codec-transparent.** The gateway never parses payloads. It peeks the Namespace, picks an upstream, and relays raw
frames in both directions.
- **Multiple deployment options.** Ship as a Go binary, a container image, or a Helm chart.
Expand Down
14 changes: 0 additions & 14 deletions cmd/proxy/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,6 @@ func serve() *cli.Command {
Value: "info",
Sources: cli.EnvVars("LOG_LEVEL"),
},
&cli.StringFlag{
Name: "metrics-addr",
Usage: "The host:port on which to serve /metrics",
Value: ":9090",
Sources: cli.EnvVars("METRICS_ADDR"),
},
&cli.StringFlag{
Name: "metrics-namespace",
Usage: "The prometheus namespace for metrics",
Value: "tmprl_proxy",
Sources: cli.EnvVars("METRICS_NAMESPACE"),
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
log := logger.NewZeroLogger(os.Stderr, logger.ParseLevel(cmd.String("level")))
Expand All @@ -77,8 +65,6 @@ func serve() *cli.Command {
fx.Supply(
fx.Annotate(ctx, fx.As(new(context.Context))),
fx.Annotate(cmd.String("config"), config.ConfigFileTag),
fx.Annotate(cmd.String("metrics-addr"), metrics.AddrTag),
fx.Annotate(cmd.String("metrics-namespace"), metrics.NamespaceTag),
fx.Annotate(protoregistry.GlobalFiles, fx.As(new(protoutil.Files))),
fx.Annotate(protoregistry.GlobalTypes, fx.As(new(protoutil.Types))),
// Services whose request and response types have their namespace
Expand Down
15 changes: 13 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"cmp"
"errors"
"fmt"
"io"
Expand All @@ -17,17 +18,19 @@ type (
Config struct {
Listen ListenConfig `yaml:",inline"`
AllowedServices Services `yaml:"allowedServices"`
Auth *AuthConfig `yaml:"auth"`
Encryption Encryption `yaml:"encryption"`
ExtensionServers ExtensionServerList `yaml:"extensionServers"`
Metrics Metrics `yaml:"metrics"`
Routing Routing `yaml:"routing"`
Upstreams UpstreamList `yaml:"upstreams"`
Auth *AuthConfig `yaml:"auth"`
}
)

// Load reads and parses the YAML config specified in the Reader.
// Values of the form ${VAR} are replaced with the corresponding environment
// variable. A config that names no allowed services gets the default set.
// variable. A config that names no allowed services gets the default set, and
// one that leaves a metrics field empty gets that field's default.
func Load(r io.Reader) (*Config, error) {
data, err := io.ReadAll(r)
if err != nil {
Expand All @@ -46,6 +49,13 @@ func Load(r io.Reader) (*Config, error) {
// configs are written.
cfg.AllowedServices = cfg.AllowedServices.Allowed()

// Defaulted here for the same reason as the allowlist: an absent metrics
// block never reaches an unmarshaler, and most configs omit it entirely.
// The config is the only way to set these, so defaulting is what keeps
// /metrics served for a config that says nothing about it.
cfg.Metrics.HostPort = cmp.Or(cfg.Metrics.HostPort, ":9090")
cfg.Metrics.Namespace = cmp.Or(cfg.Metrics.Namespace, "tmprl_proxy")

return &cfg, nil
}

Expand Down Expand Up @@ -84,6 +94,7 @@ func (c *Config) Validate() error {
validation.Nested("", &c.AllowedServices),
validation.Nested("encryption", &c.Encryption),
validation.Nested("extensionServers", &c.ExtensionServers),
validation.Nested("metrics", &c.Metrics),
validation.Nested("routing", &c.Routing),
validation.WhenRules(func() bool { return c.Auth != nil }, validation.Nested("auth", c.Auth)),
validation.Nested("upstreams", &c.Upstreams),
Expand Down
37 changes: 30 additions & 7 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func TestLoad(t *testing.T) {
want: &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
AllowedServices: config.Services(services.Default()),
Metrics: defaultMetrics(),
},
},
{
Expand All @@ -43,7 +44,10 @@ func TestLoad(t *testing.T) {
{
name: "empty hostPort",
yaml: "hostPort: \"\"\n",
want: &config.Config{AllowedServices: config.Services(services.Default())},
want: &config.Config{
AllowedServices: config.Services(services.Default()),
Metrics: defaultMetrics(),
},
},
}

Expand Down Expand Up @@ -143,6 +147,7 @@ func TestLoadFile(t *testing.T) {
want: &config.Config{
Listen: config.ListenConfig{HostPort: ":7233"},
AllowedServices: config.Services(services.Default()),
Metrics: defaultMetrics(),
},
},
{
Expand Down Expand Up @@ -199,13 +204,15 @@ func TestConfig_Validate(t *testing.T) {
{
name: "valid hostPort, no TLS",
cfg: &config.Config{
Metrics: defaultMetrics(),
Listen: config.ListenConfig{HostPort: ":8080"},
Upstreams: validUpstreams,
},
},
{
name: "invalid hostPort surfaces from ListenConfig",
cfg: &config.Config{
Metrics: defaultMetrics(),
Listen: config.ListenConfig{HostPort: "localhost"},
Upstreams: validUpstreams,
},
Expand All @@ -214,6 +221,7 @@ func TestConfig_Validate(t *testing.T) {
{
name: "broken TLS surfaces with tls subject stamped by parent",
cfg: &config.Config{
Metrics: defaultMetrics(),
Listen: config.ListenConfig{
HostPort: ":8080",
TLS: &config.TLSConfig{}, // empty -> "a server certificate is required"
Expand All @@ -227,6 +235,7 @@ func TestConfig_Validate(t *testing.T) {
{
name: "hostPort and TLS failures aggregate",
cfg: &config.Config{
Metrics: defaultMetrics(),
Listen: config.ListenConfig{
HostPort: "localhost",
TLS: &config.TLSConfig{},
Expand All @@ -241,14 +250,16 @@ func TestConfig_Validate(t *testing.T) {
{
name: "no upstreams surfaces on the upstreams field",
cfg: &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
Metrics: defaultMetrics(),
Listen: config.ListenConfig{HostPort: ":8080"},
},
wantTuples: [][2]string{{"", "upstreams"}},
},
{
name: "missing upstream hostPort surfaces with indexed upstream subject",
cfg: &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
Metrics: defaultMetrics(),
Listen: config.ListenConfig{HostPort: ":8080"},
Upstreams: []config.Upstream{{
Name: "primary",
}},
Expand All @@ -258,7 +269,8 @@ func TestConfig_Validate(t *testing.T) {
{
name: "empty upstream name surfaces with indexed upstream subject",
cfg: &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
Metrics: defaultMetrics(),
Listen: config.ListenConfig{HostPort: ":8080"},
Upstreams: []config.Upstream{{
Listen: config.ListenConfig{HostPort: "127.0.0.1:7233"},
}},
Expand All @@ -268,7 +280,8 @@ func TestConfig_Validate(t *testing.T) {
{
name: "duplicate upstream names surface on the upstreams[name] field",
cfg: &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
Metrics: defaultMetrics(),
Listen: config.ListenConfig{HostPort: ":8080"},
Upstreams: []config.Upstream{
{Name: "dup", Listen: config.ListenConfig{HostPort: "127.0.0.1:7233"}},
{Name: "dup", Listen: config.ListenConfig{HostPort: "127.0.0.1:7234"}},
Expand All @@ -279,6 +292,7 @@ func TestConfig_Validate(t *testing.T) {
{
name: "enabled encryption without default surfaces with encryption subject",
cfg: &config.Config{
Metrics: defaultMetrics(),
Listen: config.ListenConfig{HostPort: ":8080"},
Encryption: config.Encryption{Enabled: true},
Upstreams: validUpstreams,
Expand All @@ -288,6 +302,7 @@ func TestConfig_Validate(t *testing.T) {
{
name: "invalid default policy surfaces with composed encryption.default subject",
cfg: &config.Config{
Metrics: defaultMetrics(),
Listen: config.ListenConfig{HostPort: ":8080"},
Encryption: config.Encryption{Default: &badPolicy},
Upstreams: validUpstreams,
Expand All @@ -297,7 +312,8 @@ func TestConfig_Validate(t *testing.T) {
{
name: "templated upstream hostPort is accepted",
cfg: &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
Metrics: defaultMetrics(),
Listen: config.ListenConfig{HostPort: ":8080"},
Upstreams: []config.Upstream{{
Name: "templated",
Listen: config.ListenConfig{HostPort: "{{ .RemoteNamespace }}.acme-cloud.tmprl.cloud:7233"},
Expand Down Expand Up @@ -334,6 +350,7 @@ func TestConfig_Validate_RoutingReferences(t *testing.T) {
base := func(r config.Routing) *config.Config {
return &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
Metrics: defaultMetrics(),
Routing: r,
Upstreams: []config.Upstream{
{Name: "primary", Listen: config.ListenConfig{HostPort: "127.0.0.1:7233"}},
Expand Down Expand Up @@ -413,6 +430,7 @@ func TestConfig_Validate_ExternalAuthReferences(t *testing.T) {
base := func(ext *config.ExternalAuthConfig) *config.Config {
return &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
Metrics: defaultMetrics(),
Upstreams: []config.Upstream{{Name: "primary", Listen: config.ListenConfig{HostPort: "127.0.0.1:7233"}}},
ExtensionServers: config.ExtensionServerList{
{Name: "policy", Listen: config.ListenConfig{HostPort: "127.0.0.1:9000"}},
Expand Down Expand Up @@ -457,7 +475,8 @@ func TestConfig_ValidateRejectsDuplicateHostPorts(t *testing.T) {
t.Parallel()

cfg := &config.Config{
Listen: config.ListenConfig{HostPort: "127.0.0.1:8443"},
Listen: config.ListenConfig{HostPort: "127.0.0.1:8443"},
Metrics: defaultMetrics(),
Upstreams: []config.Upstream{
{Name: "a", Listen: config.ListenConfig{HostPort: "127.0.0.1:7233"}},
{Name: "b", Listen: config.ListenConfig{HostPort: "127.0.0.1:7233"}},
Expand Down Expand Up @@ -490,6 +509,10 @@ func TestUpstream_IsTemplated(t *testing.T) {

func (e *errReader) Read(_ []byte) (int, error) { return 0, e.err }

func defaultMetrics() config.Metrics {
return config.Metrics{HostPort: ":9090", Namespace: "tmprl_proxy"}
}

func urlStrings(us []url.URL) []string {
out := make([]string, len(us))
for i := range us {
Expand Down
1 change: 1 addition & 0 deletions internal/config/encryption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ func TestConfig_ValidateExtensionKeyReferences(t *testing.T) {
return &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
Encryption: e,
Metrics: defaultMetrics(),
ExtensionServers: config.ExtensionServerList{
{Name: "audit", Listen: config.ListenConfig{HostPort: "127.0.0.1:9090"}},
},
Expand Down
1 change: 1 addition & 0 deletions internal/config/extensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ func TestConfig_Validate_ExtensionServers(t *testing.T) {
return &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
ExtensionServers: servers,
Metrics: defaultMetrics(),
Upstreams: config.UpstreamList{
{Name: "primary", Listen: config.ListenConfig{HostPort: "127.0.0.1:7233"}},
},
Expand Down
1 change: 1 addition & 0 deletions internal/config/fx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func TestModule_ProvidesConfig(t *testing.T) {
require.Equal(t, &config.Config{
Listen: config.ListenConfig{HostPort: ":7233"},
AllowedServices: config.Services(services.Default()),
Metrics: defaultMetrics(),
}, got)
}

Expand Down
23 changes: 23 additions & 0 deletions internal/config/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package config

import "github.com/temporalio/temporal-proxy/pkg/validation"

// Metrics configures the Prometheus endpoint. HostPort is the address the
// /metrics handler listens on, and Namespace is the prefix stamped onto every
// collector: a Prometheus namespace, unrelated to a Temporal namespace. Load
// defaults both, so neither is empty in a loaded config.
type Metrics struct {
HostPort string `yaml:"hostPort"`
Namespace string `yaml:"namespace"`
}

// Validate requires a valid host:port and a non-empty namespace. Load defaults
// both, so a namespace failure is only reachable for a Metrics built directly,
// and a hostPort failure only for a config that sets one that will not parse.
func (m *Metrics) Validate() error {
return validation.Validate(
"",
validation.Field("hostPort", m.HostPort, validation.IsHostPort()),
validation.Field("namespace", m.Namespace, validation.Required[string]()),
)
}
86 changes: 86 additions & 0 deletions internal/config/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package config_test

import (
"errors"
"strings"
"testing"

"github.com/stretchr/testify/require"

"github.com/temporalio/temporal-proxy/internal/config"
"github.com/temporalio/temporal-proxy/pkg/validation"
)

func TestMetrics_Validate(t *testing.T) {
t.Parallel()

tests := []struct {
name string
cfg *config.Metrics
wantErrs []validation.Error
}{}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

err := tt.cfg.Validate()
if len(tt.wantErrs) == 0 {
require.NoError(t, err)
return
}

var errs validation.Errors
require.True(t, errors.As(err, &errs), "expected validation.Errors, got %T", err)
require.ElementsMatch(t, tt.wantErrs, []validation.Error(errs))
})
}
}

func TestLoad_MetricsDefaults(t *testing.T) {
t.Parallel()

tests := []struct {
name string
yaml string
want config.Metrics
}{
{
name: "absent metrics block gets both defaults",
yaml: "hostPort: :8080\n",
want: config.Metrics{HostPort: ":9090", Namespace: "tmprl_proxy"},
},
{
name: "explicit values are preserved",
yaml: "metrics:\n hostPort: 127.0.0.1:8888\n namespace: acme\n",
want: config.Metrics{HostPort: "127.0.0.1:8888", Namespace: "acme"},
},
{
name: "each field defaults on its own",
yaml: "metrics:\n hostPort: :7070\n",
want: config.Metrics{HostPort: ":7070", Namespace: "tmprl_proxy"},
},
{
name: "namespace only",
yaml: "metrics:\n namespace: acme\n",
want: config.Metrics{HostPort: ":9090", Namespace: "acme"},
},
{
// cmp.Or cannot tell an explicit empty string from an absent key, so
// writing "" is not a way to opt out of the default.
name: "explicit empty strings still default",
yaml: "metrics:\n hostPort: \"\"\n namespace: \"\"\n",
want: config.Metrics{HostPort: ":9090", Namespace: "tmprl_proxy"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got, err := config.Load(strings.NewReader(tt.yaml))
require.NoError(t, err)
require.Equal(t, tt.want, got.Metrics)
})
}
}
1 change: 1 addition & 0 deletions internal/config/services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ func TestConfig_Validate_AllowedServices(t *testing.T) {
return &config.Config{
Listen: config.ListenConfig{HostPort: ":8080"},
AllowedServices: svcs,
Metrics: defaultMetrics(),
Upstreams: config.UpstreamList{
{Name: "primary", Listen: config.ListenConfig{HostPort: "127.0.0.1:7233"}},
},
Expand Down
Loading