diff --git a/README.md b/README.md index fc53c75d..b0f79a22 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ View [our docs](https://coder.com/docs/setup/installation) for detailed installa | certs | object | Certificate that will be mounted inside Coder services. | `{"secret":{"key":"","name":""}}` | | certs.secret.key | string | Key pointing to a certificate in the secret. | `""` | | certs.secret.name | string | Name of the secret. | `""` | -| coderd | object | Primary service responsible for all things Coder! | `{"affinity":{"podAntiAffinity":{"preferredDuringSchedulingIgnoredDuringExecution":[{"podAffinityTerm":{"labelSelector":{"matchExpressions":[{"key":"app.kubernetes.io/name","operator":"In","values":["coderd"]}]},"topologyKey":"kubernetes.io/hostname"},"weight":1}]}},"builtinProviderServiceAccount":{"annotations":{},"labels":{}},"devurlsHost":"","extraLabels":{},"image":"","oidc":{"enableRefresh":false,"redirectOptions":{}},"podSecurityContext":{"runAsGroup":1000,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"replicas":1,"resources":{"limits":{"cpu":"250m","memory":"512Mi"},"requests":{"cpu":"250m","memory":"512Mi"}},"satellite":{"accessURL":"","enable":false,"primaryURL":""},"securityContext":{"allowPrivilegeEscalation":false,"readOnlyRootFilesystem":true,"runAsGroup":1000,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"serviceAnnotations":{},"serviceNodePorts":{"http":null,"https":null},"serviceSpec":{"externalTrafficPolicy":"Local","loadBalancerIP":"","loadBalancerSourceRanges":[],"type":"LoadBalancer"},"superAdmin":{"passwordSecret":{"key":"password","name":""}},"tls":{"devurlsHostSecretName":"","hostSecretName":""},"trustProxyIP":false}` | +| coderd | object | Primary service responsible for all things Coder! | `{"affinity":{"podAntiAffinity":{"preferredDuringSchedulingIgnoredDuringExecution":[{"podAffinityTerm":{"labelSelector":{"matchExpressions":[{"key":"app.kubernetes.io/name","operator":"In","values":["coderd"]}]},"topologyKey":"kubernetes.io/hostname"},"weight":1}]}},"builtinProviderServiceAccount":{"annotations":{},"labels":{}},"devurlsHost":"","extraLabels":{},"image":"","oidc":{"enableRefresh":false,"redirectOptions":{}},"podSecurityContext":{"runAsGroup":1000,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"proxy":{"exempt":"cluster.local","http":"","https":""},"replicas":1,"resources":{"limits":{"cpu":"250m","memory":"512Mi"},"requests":{"cpu":"250m","memory":"512Mi"}},"satellite":{"accessURL":"","enable":false,"primaryURL":""},"securityContext":{"allowPrivilegeEscalation":false,"readOnlyRootFilesystem":true,"runAsGroup":1000,"runAsNonRoot":true,"runAsUser":1000,"seccompProfile":{"type":"RuntimeDefault"}},"serviceAnnotations":{},"serviceNodePorts":{"http":null,"https":null},"serviceSpec":{"externalTrafficPolicy":"Local","loadBalancerIP":"","loadBalancerSourceRanges":[],"type":"LoadBalancer"},"superAdmin":{"passwordSecret":{"key":"password","name":""}},"tls":{"devurlsHostSecretName":"","hostSecretName":""},"trustProxyIP":false}` | | coderd.affinity | object | Allows specifying an affinity rule for the `coderd` deployment. The default rule prefers to schedule coderd pods on different nodes, which is only applicable if coderd.replicas is greater than 1. | `{"podAntiAffinity":{"preferredDuringSchedulingIgnoredDuringExecution":[{"podAffinityTerm":{"labelSelector":{"matchExpressions":[{"key":"app.kubernetes.io/name","operator":"In","values":["coderd"]}]},"topologyKey":"kubernetes.io/hostname"},"weight":1}]}}` | | coderd.builtinProviderServiceAccount | object | Customize the built-in Kubernetes provider service account. | `{"annotations":{},"labels":{}}` | | coderd.builtinProviderServiceAccount.annotations | object | A KV mapping of annotations. See: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ | `{}` | @@ -38,6 +38,10 @@ View [our docs](https://coder.com/docs/setup/installation) for detailed installa | coderd.podSecurityContext.runAsNonRoot | bool | Requires that containers in the pod run as an unprivileged user. If setting runAsUser to 0 (root), this will need to be set to false. | `true` | | coderd.podSecurityContext.runAsUser | int | Sets the user id of the pod. For security reasons, we recommend using a non-root user. | `1000` | | coderd.podSecurityContext.seccompProfile | object | Sets the seccomp profile for the pod. If set, the container security context setting will take precedence over this value. | `{"type":"RuntimeDefault"}` | +| coderd.proxy | object | Whether Coder should initiate outbound connections using a proxy. | `{"exempt":"cluster.local","http":"","https":""}` | +| coderd.proxy.exempt | string | Bypass the configured proxy rules for this comma-delimited list of hosts or prefixes. This corresponds to the no_proxy environment variable. | `"cluster.local"` | +| coderd.proxy.http | string | Proxy to use for HTTP connections. If unset, coderd will initiate HTTP connections directly. This corresponds to the http_proxy environment variable. | `""` | +| coderd.proxy.https | string | Proxy to use for HTTPS connections. If this is not set, coderd will use the HTTP proxy (if set), otherwise it will initiate HTTPS connections directly. This corresponds to the https_proxy environment variable. | `""` | | coderd.replicas | int | The number of Kubernetes Pod replicas. | `1` | | coderd.resources | object | Kubernetes resource specification for coderd pods. To unset a value, set it to "". To unset all values, set resources to nil. | `{"limits":{"cpu":"250m","memory":"512Mi"},"requests":{"cpu":"250m","memory":"512Mi"}}` | | coderd.satellite | object | Deploy a satellite to geodistribute access to workspaces for lower latency. | `{"accessURL":"","enable":false,"primaryURL":""}` | diff --git a/templates/coderd.yaml b/templates/coderd.yaml index fd9cb835..152e6ed9 100644 --- a/templates/coderd.yaml +++ b/templates/coderd.yaml @@ -71,6 +71,12 @@ spec: value: {{ .Values.coderd.devurlsHost | quote }} - name: VERBOSE value: "true" + - name: http_proxy + value: {{ .Values.coderd.proxy.http | quote }} + - name: https_proxy + value: {{ .Values.coderd.proxy.https | quote }} + - name: no_proxy + value: {{ .Values.coderd.proxy.exempt | quote }} {{- include "coder.postgres.env" . | indent 12 }} {{- include "coder.workspaces.configMapEnv" . | indent 12 }} command: @@ -158,13 +164,19 @@ spec: {{- end }} - name: OIDC_REDIRECT_OPTIONS value: {{ toJson .Values.coderd.oidc.redirectOptions | quote }} - {{- if ne .Values.coderd.superAdmin.passwordSecret.name "" }} + {{- if .Values.coderd.superAdmin.passwordSecret.name }} - name: SUPER_ADMIN_PASSWORD valueFrom: secretKeyRef: name: {{ .Values.coderd.superAdmin.passwordSecret.name | quote }} key: {{ .Values.coderd.superAdmin.passwordSecret.key | quote }} {{- end }} + - name: http_proxy + value: {{ .Values.coderd.proxy.http | quote }} + - name: https_proxy + value: {{ .Values.coderd.proxy.https | quote }} + - name: no_proxy + value: {{ .Values.coderd.proxy.exempt | quote }} {{- include "coder.workspaces.configMapEnv" . | indent 12 }} {{- include "coder.postgres.env" . | indent 12 }} command: diff --git a/tests/defaults_test.go b/tests/defaults_test.go index 5bcd178a..ed8b6328 100644 --- a/tests/defaults_test.go +++ b/tests/defaults_test.go @@ -41,7 +41,7 @@ func TestNamespace(t *testing.T) { t.Parallel() // Render the chart with default values - objs, err := chart.Render(chart.OriginalValues, &opts, nil) + objs, err := chart.Render(nil, &opts, nil) require.NoError(t, err, "chart render failed") // Verify that all objects are using the supplied namespace diff --git a/tests/deployment_test.go b/tests/deployment_test.go index 79fb00de..65b75d4a 100644 --- a/tests/deployment_test.go +++ b/tests/deployment_test.go @@ -9,6 +9,8 @@ import ( func TestDeployment(t *testing.T) { t.Parallel() + chart := LoadChart(t) + t.Run("Labels", func(t *testing.T) { var ( expectedLabels = map[string]string{ @@ -21,7 +23,7 @@ func TestDeployment(t *testing.T) { "foo": "bar", } - objs = LoadChart(t).MustRender(t, func(cv *CoderValues) { + objs = chart.MustRender(t, func(cv *CoderValues) { cv.Coderd.ExtraLabels = extraLabels }) coderd = MustFindDeployment(t, objs, "coderd") diff --git a/tests/ingress_test.go b/tests/ingress_test.go index cde897ac..5b9ff4ee 100644 --- a/tests/ingress_test.go +++ b/tests/ingress_test.go @@ -106,11 +106,7 @@ func TestIngress(t *testing.T) { copier.Copy(values, chart.OriginalValues) // Run function to perform test-specific modifications of defaults - test.ValuesFunc(values) - - // Verify the results using AssertFunc - objs, err := chart.Render(values, nil, nil) - require.NoError(t, err, "chart render failed") + objs := chart.MustRender(t, test.ValuesFunc) var found bool for _, obj := range objs { diff --git a/tests/proxy_test.go b/tests/proxy_test.go new file mode 100644 index 00000000..fddd6e6e --- /dev/null +++ b/tests/proxy_test.go @@ -0,0 +1,72 @@ +package tests + +import ( + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/utils/pointer" +) + +func TestProxy(t *testing.T) { + t.Parallel() + + chart := LoadChart(t) + + tests := []struct { + Name string + ValuesFunc func(v *CoderValues) + AssertFunc func(t testing.TB, spec *corev1.PodSpec) + }{ + { + Name: "default", + ValuesFunc: nil, + AssertFunc: func(t testing.TB, spec *corev1.PodSpec) { + require.Len(t, spec.Containers, 1, "pod spec should have 1 container") + vars := EnvVarsAsMap(spec.Containers[0].Env) + require.Empty(t, vars["https_proxy"], "https_proxy should be empty") + require.Empty(t, vars["http_proxy"], "http_proxy should be empty") + require.Equal(t, "cluster.local", vars["no_proxy"], "no_proxy did not match") + + require.Len(t, spec.InitContainers, 1, "pod spec should have 1 init container") + vars = EnvVarsAsMap(spec.InitContainers[0].Env) + require.Empty(t, vars["https_proxy"], "https_proxy should be empty") + require.Empty(t, vars["http_proxy"], "http_proxy should be empty") + require.Equal(t, "cluster.local", vars["no_proxy"], "no_proxy did not match") + }, + }, + { + Name: "all_proxy", + ValuesFunc: func(v *CoderValues) { + v.Coderd.Proxy.HTTPS = pointer.String("http://proxy.coder.com:3128") + v.Coderd.Proxy.HTTP = pointer.String("https://proxy.coder.com:8888") + v.Coderd.Proxy.Exempt = pointer.String("coder.com,coder.app") + }, + AssertFunc: func(t testing.TB, spec *corev1.PodSpec) { + require.Len(t, spec.Containers, 1, "pod spec should have 1 container") + vars := EnvVarsAsMap(spec.Containers[0].Env) + require.Equal(t, "http://proxy.coder.com:3128", vars["https_proxy"], "http_proxy did not match") + require.Equal(t, "https://proxy.coder.com:8888", vars["http_proxy"], "https_proxy did not match") + require.Equal(t, "coder.com,coder.app", vars["no_proxy"], "no_proxy did not match") + + require.Len(t, spec.InitContainers, 1, "pod spec should have 1 init container") + vars = EnvVarsAsMap(spec.InitContainers[0].Env) + require.Equal(t, "http://proxy.coder.com:3128", vars["https_proxy"], "http_proxy did not match") + require.Equal(t, "https://proxy.coder.com:8888", vars["http_proxy"], "https_proxy did not match") + require.Equal(t, "coder.com,coder.app", vars["no_proxy"], "no_proxy did not match") + }, + }, + } + + for _, test := range tests { + test := test + + t.Run(test.Name, func(t *testing.T) { + t.Parallel() + + objs := chart.MustRender(t, test.ValuesFunc) + deployment := MustFindDeployment(t, objs, "coderd") + test.AssertFunc(t, &deployment.Spec.Template.Spec) + }) + } +} diff --git a/tests/utils.go b/tests/utils.go new file mode 100644 index 00000000..b8b2f773 --- /dev/null +++ b/tests/utils.go @@ -0,0 +1,90 @@ +package tests + +import ( + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// MustFindDeployment finds a deployment in the given slice of objects with the +// given name, or fails the test. +func MustFindDeployment(t testing.TB, objs []runtime.Object, name string) *appsv1.Deployment { + names := []string{} + for _, obj := range objs { + if deployment, ok := obj.(*appsv1.Deployment); ok { + if deployment.Name == name { + return deployment + } + names = append(names, deployment.Name) + } + } + + t.Fatalf("failed to find deployment %q, found %v", name, names) + return nil +} + +// EnvVarsAsMap converts simple key/value environment variable pairs into a +// map, ignoring variables using a ConfigMap or Secret source. If a variable +// is defined multiple times, the last value will be returned. +func EnvVarsAsMap(variables []corev1.EnvVar) map[string]string { + values := map[string]string{} + + for _, v := range variables { + if v.ValueFrom != nil { + continue + } + + values[v.Name] = v.Value + } + + return values +} + +// AssertVolume asserts that a volume exists of the given name in the given +// slice of volumes. If it exists, it also runs fn against the named volume. +func AssertVolume(t testing.TB, vols []corev1.Volume, name string, fn func(t testing.TB, v corev1.Volume)) { + names := []string{} + for _, v := range vols { + if v.Name == name { + fn(t, v) + return + } + names = append(names, v.Name) + } + + t.Fatalf("failed to find volume %q, found %v", name, names) +} + +// AssertVolumeMount asserts that a volume mount exists of the given name in the +// given slice of volume mounts. If it exists, it also runs fn against the named +// volume mount. +func AssertVolumeMount(t testing.TB, vols []corev1.VolumeMount, name string, fn func(t testing.TB, v corev1.VolumeMount)) { + names := []string{} + for _, v := range vols { + if v.Name == name { + fn(t, v) + return + } + names = append(names, v.Name) + } + + t.Fatalf("failed to find volume mount %q, found %v", name, names) +} + +// AssertContainer asserts that a container exists of the given name in the +// given slice of containers. If it exists, it also runs fn against the named +// container. +func AssertContainer(t testing.TB, cnts []corev1.Container, name string, fn func(t testing.TB, v corev1.Container)) { + names := []string{} + for _, c := range cnts { + if c.Name == name { + fn(t, c) + return + } + names = append(names, c.Name) + } + + t.Fatalf("failed to find container %q, found %v", name, names) +} diff --git a/tests/values.go b/tests/values.go index 7c378bbf..abcda61d 100644 --- a/tests/values.go +++ b/tests/values.go @@ -10,13 +10,6 @@ import ( "strings" "testing" - "github.com/jinzhu/copier" - "github.com/stretchr/testify/require" - "golang.org/x/xerrors" - "helm.sh/helm/v3/pkg/chart" - "helm.sh/helm/v3/pkg/chart/loader" - "helm.sh/helm/v3/pkg/chartutil" - "helm.sh/helm/v3/pkg/engine" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" @@ -24,6 +17,15 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/yaml" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/engine" + + "github.com/jinzhu/copier" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" ) var _ = fmt.Stringer(CoderValues{}) @@ -90,6 +92,7 @@ type CoderdValues struct { SuperAdmin *CoderdSuperAdminValues `json:"superAdmin" yaml:"superAdmin"` Affinity *corev1.Affinity `json:"affinity" yaml:"affinity"` ExtraLabels map[string]string `json:"extraLabels" yaml:"extraLabels"` + Proxy *CoderdProxyValues `json:"proxy" yaml:"proxy"` } // CoderdServiceNodePortsValues reflect values from @@ -118,6 +121,13 @@ type CoderdTLSValues struct { DevURLsHostSecretName *string `json:"devurlsHostSecretName" yaml:"devurlsHostSecretName"` } +// CoderdProxyValues reflect values from coderd.proxy. +type CoderdProxyValues struct { + HTTP *string `json:"http" yaml:"http"` + HTTPS *string `json:"https" yaml:"https"` + Exempt *string `json:"exempt" yaml:"exempt"` +} + // CoderdBuiltinProviderServiceAccountValues reflect values from // coderd.builtinProviderServiceAccount. type CoderdBuiltinProviderServiceAccountValues struct { @@ -337,18 +347,14 @@ func (c *Chart) Validate() error { return c.chart.Validate() } -// Render applies the CoderValues to the chart, and returns a list -// of Kubernetes runtime objects, or an error. +// Render creates a copy of the default chart values, runs fn to +// modify those values, then applies those values to the chart, +// returning a list of Kubernetes runtime objects, or an error. // // values, options, and capabilities may be nil, in which case the // function will simulate a fresh install to the "coder" namespace // using the "coder" release, default values, and capabilities. -func (c *Chart) Render(values *CoderValues, options *chartutil.ReleaseOptions, capabilities *chartutil.Capabilities) ([]runtime.Object, error) { - vals, err := ConvertCoderValuesToMap(values) - if err != nil { - return nil, fmt.Errorf("failed to convert values to map: %w", err) - } - +func (c *Chart) Render(fn func(*CoderValues), options *chartutil.ReleaseOptions, capabilities *chartutil.Capabilities) ([]runtime.Object, error) { var opts chartutil.ReleaseOptions if options == nil { opts = DefaultReleaseOptions() @@ -360,6 +366,19 @@ func (c *Chart) Render(values *CoderValues, options *chartutil.ReleaseOptions, c capabilities = chartutil.DefaultCapabilities.Copy() } + values := c.OriginalValues + if fn != nil { + values = &CoderValues{} + copier.Copy(values, c.OriginalValues) + + fn(values) + } + + vals, err := ConvertCoderValuesToMap(values) + if err != nil { + return nil, fmt.Errorf("failed to convert CoderValues to map: %w", err) + } + vals, err = chartutil.ToRenderValues(c.chart, vals, opts, capabilities) if err != nil { return nil, fmt.Errorf("failed to create render values: %w", err) @@ -381,11 +400,7 @@ func (c *Chart) Render(values *CoderValues, options *chartutil.ReleaseOptions, c // MustRender renders a chart or fails the test. Use `fn` to modify the default // chart values. func (c *Chart) MustRender(t testing.TB, fn func(*CoderValues)) []runtime.Object { - values := &CoderValues{} - copier.Copy(values, c.OriginalValues) - fn(values) - - objs, err := c.Render(values, nil, nil) + objs, err := c.Render(fn, nil, nil) require.NoError(t, err, "render chart") return objs @@ -504,67 +519,3 @@ func ReadValuesFileAsMap(path string) (map[string]interface{}, error) { return values, nil } - -// MustFindDeployment finds a deployment in the given slice of objects with the -// given name, or fails the test. -func MustFindDeployment(t testing.TB, objs []runtime.Object, name string) *appsv1.Deployment { - names := []string{} - for _, obj := range objs { - if deployment, ok := obj.(*appsv1.Deployment); ok { - if deployment.Name == name { - return deployment - } - names = append(names, deployment.Name) - } - } - - t.Fatalf("failed to find deployment %q, found %v", name, names) - return nil -} - -// AssertVolume asserts that a volume exists of the given name in the given -// slice of volumes. If it exists, it also runs fn against the named volume. -func AssertVolume(t testing.TB, vols []corev1.Volume, name string, fn func(t testing.TB, v corev1.Volume)) { - names := []string{} - for _, v := range vols { - if v.Name == name { - fn(t, v) - return - } - names = append(names, v.Name) - } - - t.Fatalf("failed to find volume %q, found %v", name, names) -} - -// AssertVolumeMount asserts that a volume mount exists of the given name in the -// given slice of volume mounts. If it exists, it also runs fn against the named -// volume mount. -func AssertVolumeMount(t testing.TB, vols []corev1.VolumeMount, name string, fn func(t testing.TB, v corev1.VolumeMount)) { - names := []string{} - for _, v := range vols { - if v.Name == name { - fn(t, v) - return - } - names = append(names, v.Name) - } - - t.Fatalf("failed to find volume mount %q, found %v", name, names) -} - -// AssertContainer asserts that a container exists of the given name in the -// given slice of containers. If it exists, it also runs fn against the named -// container. -func AssertContainer(t testing.TB, cnts []corev1.Container, name string, fn func(t testing.TB, v corev1.Container)) { - names := []string{} - for _, c := range cnts { - if c.Name == name { - fn(t, c) - return - } - names = append(names, c.Name) - } - - t.Fatalf("failed to find container %q, found %v", name, names) -} diff --git a/values.yaml b/values.yaml index bf99871a..49616bab 100644 --- a/values.yaml +++ b/values.yaml @@ -54,6 +54,36 @@ coderd: # coderd.tls.devurlsHostSecretName -- The secret to use for DevURL TLS. devurlsHostSecretName: "" + # coderd.proxy -- Whether Coder should initiate outbound connections using + # a proxy. + proxy: + # coderd.proxy.http -- Proxy to use for HTTP connections. If unset, + # coderd will initiate HTTP connections directly. This corresponds to + # the http_proxy environment variable. + # + # Examples: + # - localhost:3128 - a HTTP proxy on localhost:3128 + # - socks5://localhost:1080 - a SOCKS5 proxy on localhost:1080 + http: "" + + # coderd.proxy.https -- Proxy to use for HTTPS connections. If this is + # not set, coderd will use the HTTP proxy (if set), otherwise it will + # initiate HTTPS connections directly. This corresponds to the + # https_proxy environment variable. + https: "" + + # coderd.proxy.exempt -- Bypass the configured proxy rules for this + # comma-delimited list of hosts or prefixes. This corresponds to the + # no_proxy environment variable. + # + # Examples: + # - host.example.com,abc.example.com:3100 - connect directly to + # host.example.com or its subdomains (any port), as well as + # abc.example.com:3100 or its subdomains (port 3100 only). + # - example.com - connect directly to example.com or any of its + # subdomains (any port) + exempt: "cluster.local" + # coderd.satellite -- Deploy a satellite to geodistribute access to # workspaces for lower latency. satellite: @@ -168,6 +198,7 @@ coderd: # coderd.extraLabels -- Allows specifying additional labels to pods in the # `coderd` deployment (.spec.template.metadata.labels). extraLabels: {} + # coderd.affinity -- Allows specifying an affinity rule for the `coderd` # deployment. The default rule prefers to schedule coderd pods on different # nodes, which is only applicable if coderd.replicas is greater than 1. @@ -183,6 +214,7 @@ coderd: - "coderd" topologyKey: kubernetes.io/hostname weight: 1 + # ingress -- Configure an Ingress to route traffic to Coder services. ingress: # ingress.enable -- A boolean controlling whether to create an Ingress.