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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const (
otelCPURequestParameter = "otelCpuRequest"
otelCPULimitParameter = "otelCpuLimit"
prometheusPortParameter = "prometheusPort"
httpProxyParameter = "httpProxy"
httpsProxyParameter = "httpsProxy"
noProxyParameter = "noProxy"
)

// Configuration represents the plugin configuration parameters
Expand All @@ -54,6 +57,12 @@ type Configuration struct {
OTelCPURequest string
OTelCPULimit string
PrometheusPort int32
// Outbound proxy routing for the OTel Collector sidecar. Empty when the
// operator is not proxy-configured. The collector's Go runtime reads these
// via http.ProxyFromEnvironment.
HTTPProxy string
HTTPSProxy string
NoProxy string
}

// FromParameters builds a plugin configuration from the configuration parameters
Expand Down Expand Up @@ -161,6 +170,9 @@ func FromParameters(
OTelCPURequest: helper.Parameters[otelCPURequestParameter],
OTelCPULimit: helper.Parameters[otelCPULimitParameter],
PrometheusPort: prometheusPort,
HTTPProxy: helper.Parameters[httpProxyParameter],
HTTPSProxy: helper.Parameters[httpsProxyParameter],
NoProxy: helper.Parameters[noProxyParameter],
}

configuration.applyDefaults()
Expand Down Expand Up @@ -277,6 +289,9 @@ func (config *Configuration) ToParameters() (map[string]string, error) {
if config.PrometheusPort > 0 {
result[prometheusPortParameter] = strconv.FormatInt(int64(config.PrometheusPort), 10)
}
setIfNotEmpty(httpProxyParameter, config.HTTPProxy)
setIfNotEmpty(httpsProxyParameter, config.HTTPSProxy)
setIfNotEmpty(noProxyParameter, config.NoProxy)

return result, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ func TestToParametersRoundTrip(t *testing.T) {
OTelMemoryRequest: "64Mi",
OTelMemoryLimit: "128Mi",
OTelCPURequest: "100m",
HTTPProxy: "http://proxy.example.com:3128",
HTTPSProxy: "http://proxy.example.com:3128",
NoProxy: "localhost,.svc.cluster.local",
}
original.applyDefaults()

Expand Down Expand Up @@ -278,4 +281,13 @@ func TestToParametersRoundTrip(t *testing.T) {
if restored.OTelCPURequest != original.OTelCPURequest {
t.Errorf("round-trip otel cpu request = %q, want %q", restored.OTelCPURequest, original.OTelCPURequest)
}
if restored.HTTPProxy != original.HTTPProxy {
t.Errorf("round-trip HTTP proxy = %q, want %q", restored.HTTPProxy, original.HTTPProxy)
}
if restored.HTTPSProxy != original.HTTPSProxy {
t.Errorf("round-trip HTTPS proxy = %q, want %q", restored.HTTPSProxy, original.HTTPSProxy)
}
if restored.NoProxy != original.NoProxy {
t.Errorf("round-trip no proxy = %q, want %q", restored.NoProxy, original.NoProxy)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,9 @@ func (impl Implementation) reconcileMetadata(
if goMemLimitEnv, ok := buildGoMemLimitEnv(configuration.OTelMemoryLimit); ok {
otelSidecar.Env = append(otelSidecar.Env, goMemLimitEnv)
}
// Route the collector's OTLP exporter egress through the configured
// outbound proxy. The collector's Go runtime reads these natively.
otelSidecar.Env = append(otelSidecar.Env, proxyEnvVars(configuration)...)

// Expose Prometheus metrics port when configured
if configuration.PrometheusPort > 0 {
Expand Down Expand Up @@ -380,6 +383,37 @@ func hasResourceRequirements(resources corev1.ResourceRequirements) bool {
return len(resources.Requests) > 0 || len(resources.Limits) > 0
}

// proxyEnvVars returns the outbound proxy environment variables for the OTel
// Collector sidecar. Both the UPPERCASE and lowercase spellings are emitted:
// Go's http.ProxyFromEnvironment reads both and some libraries only check the
// lowercase form. Only non-empty values are included, so a pod gets no proxy
// env when the operator is not proxy-configured.
//
// The collector's OTLP exporter uses gRPC, which looks up the proxy with an
// https-scheme request and therefore reads only HTTPS_PROXY, even for plaintext
// connections. When only an HTTP proxy is configured, it is also used as the
// HTTPS proxy so the exporter does not silently bypass it.
func proxyEnvVars(cfg *config.Configuration) []corev1.EnvVar {
httpsProxy := cfg.HTTPSProxy
if httpsProxy == "" {
httpsProxy = cfg.HTTPProxy
}
env := []corev1.EnvVar{}
add := func(upper, lower, value string) {
if value == "" {
return
}
env = append(env,
corev1.EnvVar{Name: upper, Value: value},
corev1.EnvVar{Name: lower, Value: value},
)
}
add("HTTP_PROXY", "http_proxy", cfg.HTTPProxy)
add("HTTPS_PROXY", "https_proxy", httpsProxy)
add("NO_PROXY", "no_proxy", cfg.NoProxy)
return env
}

func buildGoMemLimitEnv(memoryLimit string) (corev1.EnvVar, bool) {
if memoryLimit == "" {
return corev1.EnvVar{}, false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/documentdb/cnpg-i-sidecar-injector/internal/config"
pluginmetadata "github.com/documentdb/cnpg-i-sidecar-injector/pkg/metadata"
)

Expand Down Expand Up @@ -435,3 +436,66 @@ func TestNewOtelCollectorSidecar_MonitorUser(t *testing.T) {
}
t.Fatal("missing PGUSER env var")
}

// TestProxyEnvVars asserts the OTel collector receives the outbound proxy
// routing env vars (both UPPER and lowercase spellings) only when the operator
// forwards them, and nothing when the operator is not proxy-configured.
func TestProxyEnvVars(t *testing.T) {
empty := proxyEnvVars(&config.Configuration{})
if len(empty) != 0 {
t.Fatalf("expected no proxy env when unconfigured, got %v", empty)
}

cfg := &config.Configuration{
HTTPProxy: "http://proxy.example.com:3128",
HTTPSProxy: "http://proxy.example.com:3128",
NoProxy: "localhost,.svc.cluster.local",
}
got := map[string]string{}
for _, e := range proxyEnvVars(cfg) {
got[e.Name] = e.Value
}
want := map[string]string{
"HTTP_PROXY": cfg.HTTPProxy,
"http_proxy": cfg.HTTPProxy,
"HTTPS_PROXY": cfg.HTTPSProxy,
"https_proxy": cfg.HTTPSProxy,
"NO_PROXY": cfg.NoProxy,
"no_proxy": cfg.NoProxy,
}
if len(got) != len(want) {
t.Fatalf("proxy env count = %d, want %d (%v)", len(got), len(want), got)
}
for name, value := range want {
if got[name] != value {
t.Errorf("proxy env %q = %q, want %q", name, got[name], value)
}
}

// Only HTTPS set → only the HTTPS pair is emitted.
partial := proxyEnvVars(&config.Configuration{HTTPSProxy: "http://p:3128"})
if len(partial) != 2 {
t.Fatalf("expected 2 env vars for HTTPS-only, got %d (%v)", len(partial), partial)
}

// HTTP only → HTTPS_PROXY falls back to it, because the gRPC exporter
// consults only HTTPS_PROXY.
httpOnly := map[string]string{}
for _, e := range proxyEnvVars(&config.Configuration{HTTPProxy: "http://p:3128"}) {
httpOnly[e.Name] = e.Value
}
for _, name := range []string{"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"} {
if httpOnly[name] != "http://p:3128" {
t.Errorf("HTTP-only: %s = %q, want the HTTP proxy", name, httpOnly[name])
}
}

// An explicit HTTPS proxy is never overridden by the HTTP fallback.
both := map[string]string{}
for _, e := range proxyEnvVars(&config.Configuration{HTTPProxy: "http://h:1", HTTPSProxy: "http://s:2"}) {
both[e.Name] = e.Value
}
if both["HTTPS_PROXY"] != "http://s:2" || both["HTTP_PROXY"] != "http://h:1" {
t.Errorf("explicit values changed: %v", both)
}
}
22 changes: 22 additions & 0 deletions operator/documentdb-helm-chart/templates/08_proxy_secret.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{{- $proxy := include "documentdb.proxy" . | fromJson }}
{{- if $proxy.isProxyEnabled }}
# Outbound proxy routing configuration. The keys are the exact environment
# variable names consumed by the operator (via envFrom) and, through plugin
# parameters, by the OTel Collector sidecar injected into each DocumentDB pod. The collector's Go
# runtime reads these natively (http.ProxyFromEnvironment), so the OTLP
# exporter's egress is routed through the proxy with no image change.
apiVersion: v1
kind: Secret
metadata:
name: {{ include "documentdb.proxySecretName" . }}
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
app.kubernetes.io/name: {{ include "documentdb-chart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: "Helm"
type: Opaque
stringData:
DOCUMENTDB_OTEL_HTTP_PROXY: {{ $proxy.httpProxy | quote }}
DOCUMENTDB_OTEL_HTTPS_PROXY: {{ $proxy.httpsProxy | quote }}
DOCUMENTDB_OTEL_NO_PROXY: {{ include "documentdb.noProxy" . | quote }}
{{- end }}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ spec:
metadata:
labels:
app: {{ .Release.Name }}
{{- if (include "documentdb.proxy" . | fromJson).isProxyEnabled }}
annotations:
# Roll the operator when the proxy configuration changes so the new
# DOCUMENTDB_OTEL_* proxy values take effect.
checksum/proxy-config: {{ printf "%s|%s|%s" ((include "documentdb.proxy" . | fromJson).httpProxy) ((include "documentdb.proxy" . | fromJson).httpsProxy) (include "documentdb.noProxy" .) | sha256sum }}
{{- end }}
spec:
serviceAccountName: {{ .Values.serviceAccount.name }}
{{- with .Values.imagePullSecrets }}
Expand Down Expand Up @@ -90,6 +96,14 @@ spec:
- mountPath: /tmp/k8s-webhook-server/serving-certs
name: webhook-cert
readOnly: true
{{- if (include "documentdb.proxy" . | fromJson).isProxyEnabled }}
# Provide the proxy values the operator forwards to the OTel Collector
# sidecar. The keys are DOCUMENTDB_OTEL_* (not the standard HTTP_PROXY
# names) so the operator's own Kubernetes API client is never proxied.
envFrom:
- secretRef:
name: {{ include "documentdb.proxySecretName" . }}
{{- end }}
env:
- name: GATEWAY_PORT
value: "10260"
Expand Down
63 changes: 62 additions & 1 deletion operator/documentdb-helm-chart/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,65 @@ Usage:
{{- end -}}
{{- .policy -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
documentdb.proxy returns the effective outbound proxy settings as JSON (decode
with fromJson). The documented OSS key is `proxy`. `Azure.proxySettings` is
accepted purely for Azure Arc extension compatibility (the Arc platform
injects that key). Only one of the two may be enabled; enabling both fails.
*/}}
{{- define "documentdb.proxy" -}}
{{- $p := .Values.proxy | default dict -}}
{{- $a := dig "proxySettings" dict (.Values.Azure | default dict) -}}
{{- if and $p.isProxyEnabled $a.isProxyEnabled -}}
{{- fail "Set either proxy or Azure.proxySettings, not both" -}}
{{- end -}}
{{- $src := ternary $a $p (default false $a.isProxyEnabled) -}}
{{- dict
"isProxyEnabled" (default false $src.isProxyEnabled)
"httpProxy" (default "" $src.httpProxy)
"httpsProxy" (default "" $src.httpsProxy)
"noProxy" (default "" $src.noProxy)
"bypassPrivateRanges" (ternary true (dig "bypassPrivateRanges" true $p) (default false $a.isProxyEnabled))
| toJson -}}
{{- end -}}

{{/*
documentdb.proxySecretName is the name of the Secret that holds the outbound
proxy routing configuration for the OTel Collector sidecar (DOCUMENTDB_OTEL_HTTP_PROXY /
DOCUMENTDB_OTEL_HTTPS_PROXY / DOCUMENTDB_OTEL_NO_PROXY). It is created
in the operator namespace when the proxy is enabled.
*/}}
{{- define "documentdb.proxySecretName" -}}
documentdb-proxy-config
{{- end -}}

{{/*
documentdb.noProxy composes the effective NO_PROXY value: the user-supplied
noProxy (optional) followed by a standard bypass list. The loopback and
cluster-local entries are always included. The RFC1918 private ranges are
included unless proxy.bypassPrivateRanges is false; Azure.proxySettings has no
such option and always keeps them. Duplicate/empty entries are dropped and the
result is comma-joined.
*/}}
{{- define "documentdb.noProxy" -}}
{{- $proxy := include "documentdb.proxy" . | fromJson -}}
{{- $defaults := list "localhost" "127.0.0.1" "::1" ".svc" ".svc.cluster.local" "kubernetes.default.svc" -}}
{{- if $proxy.bypassPrivateRanges -}}
{{- $defaults = concat $defaults (list "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16") -}}
{{- end -}}
{{- $user := list -}}
{{- with $proxy.noProxy -}}
{{- $user = regexSplit "\\s*,\\s*" (trim .) -1 -}}
{{- end -}}
{{- $seen := dict -}}
{{- $out := list -}}
{{- range (concat $user $defaults) -}}
{{- $e := trim . -}}
{{- if and $e (not (hasKey $seen $e)) -}}
{{- $_ := set $seen $e true -}}
{{- $out = append $out $e -}}
{{- end -}}
{{- end -}}
{{- join "," $out -}}
{{- end -}}
77 changes: 77 additions & 0 deletions operator/documentdb-helm-chart/tests/08_proxy_secret_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/helm-unittest/helm-unittest/main/schema/helm-testsuite.json
suite: proxy secret
templates:
- 08_proxy_secret.yaml
tests:
- it: should not render the proxy Secret when proxy is disabled
asserts:
- hasDocuments:
count: 0

- it: should render the proxy Secret with routing keys when enabled
set:
proxy.isProxyEnabled: true
proxy.httpProxy: "http://proxy.example.com:3128"
proxy.httpsProxy: "http://proxy.example.com:3128"
asserts:
- isKind:
of: Secret
- equal:
path: metadata.name
value: documentdb-proxy-config
- equal:
path: metadata.namespace
value: documentdb-operator
- equal:
path: stringData.DOCUMENTDB_OTEL_HTTP_PROXY
value: "http://proxy.example.com:3128"
- equal:
path: stringData.DOCUMENTDB_OTEL_HTTPS_PROXY
value: "http://proxy.example.com:3128"

- it: should always append in-cluster bypass entries to DOCUMENTDB_OTEL_NO_PROXY
set:
proxy.isProxyEnabled: true
proxy.noProxy: "example.internal"
asserts:
- matchRegex:
path: stringData.DOCUMENTDB_OTEL_NO_PROXY
pattern: "^example.internal,localhost,127.0.0.1,.*\\.svc\\.cluster\\.local.*192.168.0.0/16$"

- it: should drop the RFC1918 bypass ranges when bypassPrivateRanges is false
set:
proxy.isProxyEnabled: true
proxy.bypassPrivateRanges: false
asserts:
- equal:
path: stringData.DOCUMENTDB_OTEL_NO_PROXY
value: "localhost,127.0.0.1,::1,.svc,.svc.cluster.local,kubernetes.default.svc"

- it: should keep the RFC1918 bypass ranges for Azure.proxySettings regardless of proxy.bypassPrivateRanges
set:
proxy.bypassPrivateRanges: false
Azure.proxySettings.isProxyEnabled: true
Azure.proxySettings.httpsProxy: "http://arc.example.com:3128"
asserts:
- matchRegex:
path: stringData.DOCUMENTDB_OTEL_NO_PROXY
pattern: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16$"

- it: should honor the Azure.proxySettings Arc compatibility key
set:
Azure.proxySettings.isProxyEnabled: true
Azure.proxySettings.httpsProxy: "http://arc.example.com:3128"
asserts:
- equal:
path: stringData.DOCUMENTDB_OTEL_HTTPS_PROXY
value: "http://arc.example.com:3128"

- it: should fail when both proxy and Azure.proxySettings are enabled
set:
proxy.isProxyEnabled: true
proxy.httpsProxy: "http://oss.example.com:3128"
Azure.proxySettings.isProxyEnabled: true
Azure.proxySettings.httpsProxy: "http://arc.example.com:3128"
asserts:
- failedTemplate:
errorMessage: "Set either proxy or Azure.proxySettings, not both"
Loading
Loading