Skip to content

Commit ef02452

Browse files
committed
helm tests added
1 parent 500d0d5 commit ef02452

54 files changed

Lines changed: 3239 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,31 @@ jobs:
7676
- name: Run integration tests
7777
run: go test -tags=integration -v -timeout=8m ./...
7878

79+
helm-test:
80+
runs-on: ubuntu-latest
81+
steps:
82+
- name: Checkout
83+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
84+
- name: Setup Go
85+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
86+
with:
87+
go-version: "~1.26"
88+
- name: Setup Helm
89+
uses: azure/setup-helm@f0accbfd55e3332a28f721b8202b1016cecf90d5 # v5
90+
with:
91+
version: "v3.18.3"
92+
- name: Run Helm chart tests
93+
run: go test ./helm/tests/...
94+
- name: Check for unstaged files
95+
run: ./scripts/check_unstaged.sh
96+
7997
required:
8098
runs-on: ubuntu-latest
8199
needs:
82100
- test
83101
- lint
84102
- integration-test
103+
- helm-test
85104
# Allow this job to run even if the needed jobs fail, are skipped or
86105
# cancelled.
87106
if: always()

helm/tests/chart_test.go

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
package tests // nolint: testpackage
2+
3+
import (
4+
"bytes"
5+
"flag"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"runtime"
10+
"testing"
11+
12+
"github.com/stretchr/testify/require"
13+
"golang.org/x/xerrors"
14+
)
15+
16+
// These tests run `helm template` with the values file specified in each test
17+
// and compare the output to the contents of the corresponding golden file.
18+
// All values and golden files are located in the `testdata` directory.
19+
// To update golden files, run `go test . -update`.
20+
21+
// updateGoldenFiles is a flag that can be set to update golden files.
22+
var updateGoldenFiles = flag.Bool("update", false, "Update golden files")
23+
24+
var namespaces = []string{
25+
"default",
26+
"coder",
27+
}
28+
29+
var testCases = []testCase{
30+
{
31+
name: "default_values",
32+
expectedError: "",
33+
},
34+
{
35+
name: "affinity",
36+
expectedError: "",
37+
},
38+
{
39+
name: "args",
40+
expectedError: "",
41+
},
42+
{
43+
name: "field_selector",
44+
expectedError: "",
45+
},
46+
{
47+
name: "image",
48+
expectedError: "",
49+
},
50+
{
51+
name: "label_selector",
52+
expectedError: "",
53+
},
54+
{
55+
name: "labels",
56+
expectedError: "",
57+
},
58+
{
59+
name: "metrics",
60+
expectedError: "",
61+
},
62+
{
63+
name: "namespaces",
64+
expectedError: "",
65+
},
66+
{
67+
name: "node_selector",
68+
expectedError: "",
69+
},
70+
{
71+
name: "pod_security_context",
72+
expectedError: "",
73+
},
74+
{
75+
name: "rbac",
76+
expectedError: "",
77+
},
78+
{
79+
name: "resources",
80+
expectedError: "",
81+
},
82+
{
83+
name: "security_context",
84+
expectedError: "",
85+
},
86+
{
87+
name: "service_account",
88+
expectedError: "",
89+
},
90+
{
91+
name: "tolerations",
92+
expectedError: "",
93+
},
94+
{
95+
name: "volumes",
96+
expectedError: "",
97+
},
98+
}
99+
100+
type testCase struct {
101+
name string // Name of the test case. This is used to control which values and golden file are used.
102+
namespace string // Namespace is the name of the namespace the resources should be generated within
103+
expectedError string // Expected error from running `helm template`.
104+
}
105+
106+
func (tc testCase) valuesFilePath() string {
107+
return filepath.Join("./testdata", tc.name+".yaml")
108+
}
109+
110+
func (tc testCase) goldenFilePath() string {
111+
if tc.namespace == "default" {
112+
return filepath.Join("./testdata", tc.name+".golden")
113+
}
114+
115+
return filepath.Join("./testdata", tc.name+"_"+tc.namespace+".golden")
116+
}
117+
118+
func inCI() bool { return os.Getenv("CI") != "" }
119+
120+
func TestRenderChart(t *testing.T) {
121+
t.Parallel()
122+
if *updateGoldenFiles {
123+
t.Skip("Golden files are being updated. Skipping test.")
124+
}
125+
if inCI() {
126+
switch runtime.GOOS {
127+
case "windows", "darwin":
128+
t.Skip("Skipping tests on Windows and macOS in CI")
129+
}
130+
}
131+
132+
// Ensure that Helm is available in $PATH
133+
helmPath := lookupHelm(t)
134+
err := updateHelmDependencies(t, helmPath, "..")
135+
require.NoError(t, err, "failed to build Helm dependencies")
136+
137+
for _, tc := range testCases {
138+
for _, ns := range namespaces {
139+
tc.namespace = ns
140+
141+
t.Run(tc.namespace+"/"+tc.name, func(t *testing.T) {
142+
t.Parallel()
143+
144+
// Ensure that the values file exists.
145+
valuesFilePath := tc.valuesFilePath()
146+
if _, err := os.Stat(valuesFilePath); os.IsNotExist(err) {
147+
t.Fatalf("values file %q does not exist", valuesFilePath)
148+
}
149+
150+
// Run helm template with the values file.
151+
templateOutput, err := runHelmTemplate(t, helmPath, "..", valuesFilePath, tc.namespace)
152+
if tc.expectedError != "" {
153+
require.Error(t, err, "helm template should have failed")
154+
require.Contains(t, templateOutput, tc.expectedError, "helm template output should contain expected error")
155+
} else {
156+
require.NoError(t, err, "helm template should not have failed")
157+
require.NotEmpty(t, templateOutput, "helm template output should not be empty")
158+
goldenFilePath := tc.goldenFilePath()
159+
goldenBytes, err := os.ReadFile(goldenFilePath)
160+
require.NoError(t, err, "failed to read golden file %q", goldenFilePath)
161+
162+
// Remove carriage returns to make tests pass on Windows.
163+
goldenBytes = bytes.ReplaceAll(goldenBytes, []byte("\r"), []byte(""))
164+
expected := string(goldenBytes)
165+
166+
require.NoError(t, err, "failed to load golden file %q")
167+
require.Equal(t, expected, templateOutput)
168+
}
169+
})
170+
}
171+
}
172+
}
173+
174+
func TestUpdateGoldenFiles(t *testing.T) {
175+
t.Parallel()
176+
if !*updateGoldenFiles {
177+
t.Skip("Run with -update to update golden files")
178+
}
179+
180+
helmPath := lookupHelm(t)
181+
err := updateHelmDependencies(t, helmPath, "..")
182+
require.NoError(t, err, "failed to build Helm dependencies")
183+
184+
for _, tc := range testCases {
185+
if tc.expectedError != "" {
186+
t.Logf("skipping test case %q with render error", tc.name)
187+
continue
188+
}
189+
190+
for _, ns := range namespaces {
191+
tc.namespace = ns
192+
193+
valuesPath := tc.valuesFilePath()
194+
templateOutput, err := runHelmTemplate(t, helmPath, "..", valuesPath, tc.namespace)
195+
if err != nil {
196+
t.Logf("error running `helm template -f %q`: %v", valuesPath, err)
197+
t.Logf("output: %s", templateOutput)
198+
}
199+
require.NoError(t, err, "failed to run `helm template -f %q`", valuesPath)
200+
201+
goldenFilePath := tc.goldenFilePath()
202+
err = os.WriteFile(goldenFilePath, []byte(templateOutput), 0o644) // nolint:gosec
203+
require.NoError(t, err, "failed to write golden file %q", goldenFilePath)
204+
}
205+
}
206+
t.Log("Golden files updated. Please review the changes and commit them.")
207+
}
208+
209+
// updateHelmDependencies runs `helm dependency update .` on the given chartDir.
210+
func updateHelmDependencies(t testing.TB, helmPath, chartDir string) error {
211+
// Remove charts/ from chartDir if it exists.
212+
err := os.RemoveAll(filepath.Join(chartDir, "charts"))
213+
if err != nil {
214+
return xerrors.Errorf("failed to remove charts/ directory: %w", err)
215+
}
216+
217+
// Regenerate the chart dependencies.
218+
cmd := exec.Command(helmPath, "dependency", "update", "--skip-refresh", ".")
219+
cmd.Dir = chartDir
220+
t.Logf("exec command: %v", cmd.Args)
221+
out, err := cmd.CombinedOutput()
222+
if err != nil {
223+
return xerrors.Errorf("failed to run `helm dependency build`: %w\noutput: %s", err, out)
224+
}
225+
226+
return nil
227+
}
228+
229+
// runHelmTemplate runs helm template on the given chart with the given values and
230+
// returns the raw output.
231+
func runHelmTemplate(t testing.TB, helmPath, chartDir, valuesFilePath, namespace string) (string, error) {
232+
// Ensure that valuesFilePath exists
233+
if _, err := os.Stat(valuesFilePath); err != nil {
234+
return "", xerrors.Errorf("values file %q does not exist: %w", valuesFilePath, err)
235+
}
236+
237+
cmd := exec.Command(helmPath, "template", chartDir, "-f", valuesFilePath, "--namespace", namespace)
238+
t.Logf("exec command: %v", cmd.Args)
239+
out, err := cmd.CombinedOutput()
240+
return string(out), err
241+
}
242+
243+
// lookupHelm ensures that Helm is available in $PATH and returns the path to the
244+
// Helm executable.
245+
func lookupHelm(t testing.TB) string {
246+
helmPath, err := exec.LookPath("helm")
247+
if err != nil {
248+
t.Fatalf("helm not found in $PATH: %v", err)
249+
return ""
250+
}
251+
t.Logf("Using helm at %q", helmPath)
252+
return helmPath
253+
}
254+
255+
func TestMain(m *testing.M) {
256+
flag.Parse()
257+
os.Exit(m.Run())
258+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
# Source: coder-logstream-kube/templates/service.yaml
3+
apiVersion: v1
4+
kind: ServiceAccount
5+
metadata:
6+
name: "coder-logstream-kube"
7+
annotations:
8+
{}
9+
labels:
10+
{}
11+
---
12+
# Source: coder-logstream-kube/templates/service.yaml
13+
apiVersion: rbac.authorization.k8s.io/v1
14+
kind: ClusterRole
15+
metadata:
16+
name: coder-logstream-kube-role
17+
rules:
18+
19+
- apiGroups: [""]
20+
resources: ["pods", "events"]
21+
verbs: ["get", "watch", "list"]
22+
- apiGroups: [""]
23+
resources: ["secrets"]
24+
verbs: ["get"]
25+
- apiGroups: ["apps"]
26+
resources: ["replicasets", "events"]
27+
verbs: ["get", "watch", "list"]
28+
---
29+
# Source: coder-logstream-kube/templates/service.yaml
30+
apiVersion: rbac.authorization.k8s.io/v1
31+
kind: ClusterRoleBinding
32+
metadata:
33+
name: coder-logstream-kube-rolebinding
34+
roleRef:
35+
apiGroup: rbac.authorization.k8s.io
36+
kind: ClusterRole
37+
name: coder-logstream-kube-role
38+
subjects:
39+
- kind: ServiceAccount
40+
name: "coder-logstream-kube"
41+
namespace: default
42+
---
43+
# Source: coder-logstream-kube/templates/service.yaml
44+
apiVersion: apps/v1
45+
kind: Deployment
46+
metadata:
47+
name: release-name
48+
spec:
49+
# This must remain at 1 otherwise duplicate logs can occur!
50+
replicas: 1
51+
selector:
52+
matchLabels:
53+
app.kubernetes.io/instance: release-name
54+
template:
55+
metadata:
56+
labels:
57+
app.kubernetes.io/instance: release-name
58+
spec:
59+
serviceAccountName: "coder-logstream-kube"
60+
restartPolicy: Always
61+
affinity:
62+
podAntiAffinity:
63+
preferredDuringSchedulingIgnoredDuringExecution:
64+
- podAffinityTerm:
65+
labelSelector:
66+
matchExpressions:
67+
- key: app.kubernetes.io/instance
68+
operator: In
69+
values:
70+
- coder-logstream-kube
71+
topologyKey: kubernetes.io/hostname
72+
weight: 1
73+
containers:
74+
- name: coder-logstream-kube
75+
image: "ghcr.io/coder/coder-logstream-kube:0.1.0"
76+
imagePullPolicy: IfNotPresent
77+
command:
78+
- /coder-logstream-kube
79+
resources:
80+
{}
81+
env:
82+
- name: CODER_URL
83+
value: http://coder.coder.svc.cluster.local
84+
- name: CODER_LOGSTREAM_METRICS_ADDR
85+
value: ""
86+
securityContext:
87+
allowPrivilegeEscalation: false
88+
runAsGroup: 65532
89+
runAsNonRoot: true
90+
runAsUser: 65532

helm/tests/testdata/affinity.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
url: "http://coder.coder.svc.cluster.local"
2+
affinity:
3+
podAntiAffinity:
4+
preferredDuringSchedulingIgnoredDuringExecution:
5+
- weight: 1
6+
podAffinityTerm:
7+
topologyKey: kubernetes.io/hostname
8+
labelSelector:
9+
matchExpressions:
10+
- key: app.kubernetes.io/instance
11+
operator: In
12+
values:
13+
- coder-logstream-kube

0 commit comments

Comments
 (0)