Skip to content

Commit 2f8e2ba

Browse files
Add end-to-end tests (#94)
This pull request adds a small pod to the Cortex helm chart, which can be used to perform commands, such as end-to-end tests. This pod will have access to the database and also the Cortex configuration, so that it can make OpenStack calls, making the implementation of e2e tests significantly easier. In our deployment pipeline, we can use the new pod to run `kubectl exec -it cortex-cli -- /usr/bin/cortex checks` to execute the tests. This tooling was also added to the Tilt setup, with a new command "Run E2E Tests".
1 parent ca0d685 commit 2f8e2ba

File tree

6 files changed

+171
-3
lines changed

6 files changed

+171
-3
lines changed

.golangci.yaml

-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ linters:
9191
- emptyStringTest
9292
- evalOrder
9393
- httpNoBody
94-
- importShadow
9594
- initClause
9695
- methodExprCall
9796
- paramTypeCombine

Tiltfile

+11
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ k8s_resource('cortex-mqtt', port_forwards=[
5656
port_forward(8004, 8080), # Websocket connection
5757
], labels=['Core-Services'])
5858

59+
########### Cortex Commands
60+
k8s_resource('cortex-cli', labels=['Commands'])
61+
local_resource(
62+
'Run E2E Tests',
63+
'kubectl exec -it cortex-cli -- /usr/bin/cortex checks',
64+
deps=['./internal/checks'],
65+
labels=['Commands'],
66+
trigger_mode=TRIGGER_MODE_MANUAL,
67+
auto_init=False,
68+
)
69+
5970
########### Postgres DB for Cortex Core Service
6071
k8s_yaml(helm('./helm/postgres', name='cortex-postgres'))
6172
k8s_resource('cortex-postgresql', port_forwards=[

helm/cortex/Chart.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type: application
1818
# This is the chart version. This version number should be incremented each time you make changes
1919
# to the chart and its templates, including the app version.
2020
# Versions are expected to follow Semantic Versioning (https://semver.org/)
21-
version: 0.9.0
21+
version: 0.10.0
2222

2323
# This is the version number of the application being deployed. This version number should be
2424
# incremented each time you make changes to the application. Versions are not expected to

helm/cortex/templates/cli.yaml

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Copyright 2025 SAP SE
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
apiVersion: v1
5+
kind: Pod
6+
metadata:
7+
name: {{ $.Chart.Name }}-cli
8+
labels:
9+
app: {{ $.Chart.Name }}-cli
10+
{{- include "cortex.labels" $ | nindent 4 }}
11+
spec:
12+
{{- with $.Values.imagePullSecrets }}
13+
imagePullSecrets:
14+
{{- toYaml . | nindent 4 }}
15+
{{- end }}
16+
securityContext:
17+
{{- toYaml $.Values.podSecurityContext | nindent 4 }}
18+
containers:
19+
- name: {{ $.Chart.Name }}-cli
20+
command:
21+
- "/bin/sh"
22+
- "-c"
23+
- "echo 'Waiting for commands...' && sleep infinity"
24+
securityContext:
25+
{{- toYaml $.Values.securityContext | nindent 8 }}
26+
image: "{{ $.Values.image.repository }}:{{ $.Values.image.tag | default $.Chart.AppVersion }}"
27+
imagePullPolicy: {{ $.Values.image.pullPolicy }}
28+
resources:
29+
{{- toYaml $.Values.resources | nindent 8 }}
30+
volumeMounts:
31+
- name: {{ include "cortex.fullname" $ }}-config-volume
32+
mountPath: /etc/config
33+
{{- with $.Values.volumeMounts }}
34+
{{- toYaml . | nindent 8 }}
35+
{{- end }}
36+
volumes:
37+
- name: {{ include "cortex.fullname" $ }}-config-volume
38+
configMap:
39+
name: {{ include "cortex.fullname" $ }}-config
40+
{{- with $.Values.volumes }}
41+
{{- toYaml . | nindent 4 }}
42+
{{- end }}
43+
{{- with $.Values.nodeSelector }}
44+
nodeSelector:
45+
{{- toYaml . | nindent 4 }}
46+
{{- end }}
47+
{{- with $.Values.affinity }}
48+
affinity:
49+
{{- toYaml . | nindent 4 }}
50+
{{- end }}
51+
{{- with $.Values.tolerations }}
52+
tolerations:
53+
{{- toYaml . | nindent 4 }}
54+
{{- end }}

internal/commands/checks/checks.go

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright 2025 SAP SE
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package checks
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"encoding/json"
10+
"log/slog"
11+
"net/http"
12+
"strconv"
13+
14+
"github.com/cobaltcore-dev/cortex/internal/conf"
15+
httpapi "github.com/cobaltcore-dev/cortex/internal/scheduler/api/http"
16+
cortexopenstack "github.com/cobaltcore-dev/cortex/internal/sync/openstack"
17+
"github.com/gophercloud/gophercloud/v2"
18+
"github.com/gophercloud/gophercloud/v2/openstack"
19+
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/hypervisors"
20+
"github.com/sapcc/go-bits/must"
21+
)
22+
23+
// Run all checks.
24+
func RunChecks(ctx context.Context, config conf.Config) {
25+
checkSchedulerReturnsValidHosts(ctx, config)
26+
}
27+
28+
// Check that the scheduler returns a valid set of hosts.
29+
func checkSchedulerReturnsValidHosts(ctx context.Context, config conf.Config) {
30+
osConf := config.GetSyncConfig().OpenStack
31+
slog.Info("authenticating against openstack", "url", osConf.Keystone.URL)
32+
authOptions := gophercloud.AuthOptions{
33+
IdentityEndpoint: osConf.Keystone.URL,
34+
Username: osConf.Keystone.OSUsername,
35+
DomainName: osConf.Keystone.OSUserDomainName,
36+
Password: osConf.Keystone.OSPassword,
37+
AllowReauth: true,
38+
Scope: &gophercloud.AuthScope{
39+
ProjectName: osConf.Keystone.OSProjectName,
40+
DomainName: osConf.Keystone.OSProjectDomainName,
41+
},
42+
}
43+
pc := must.Return(openstack.NewClient(authOptions.IdentityEndpoint))
44+
must.Succeed(openstack.Authenticate(ctx, pc, authOptions))
45+
url := must.Return(pc.EndpointLocator(gophercloud.EndpointOpts{
46+
Type: "compute",
47+
Availability: gophercloud.Availability(osConf.Nova.Availability),
48+
}))
49+
sc := &gophercloud.ServiceClient{ProviderClient: pc, Endpoint: url, Type: "compute"}
50+
slog.Info("authenticated against openstack", "url", url)
51+
slog.Info("listing hypervisors")
52+
pages := must.Return(hypervisors.List(sc, hypervisors.ListOpts{}).AllPages(ctx))
53+
var data = &struct {
54+
Hypervisors []cortexopenstack.Hypervisor `json:"hypervisors"`
55+
}{}
56+
must.Succeed(pages.(hypervisors.HypervisorPage).ExtractInto(data))
57+
if len(data.Hypervisors) == 0 {
58+
panic("no hypervisors found")
59+
}
60+
slog.Info("found hypervisors", "count", len(data.Hypervisors))
61+
62+
var hosts []httpapi.ExternalSchedulerHost
63+
weights := make(map[string]float64)
64+
for _, h := range data.Hypervisors {
65+
weights[h.ServiceHost] = 1.0
66+
hosts = append(hosts, httpapi.ExternalSchedulerHost{
67+
ComputeHost: h.ServiceHost,
68+
HypervisorHostname: h.Hostname,
69+
})
70+
}
71+
request := httpapi.ExternalSchedulerRequest{
72+
Hosts: hosts,
73+
Weights: weights,
74+
}
75+
port := strconv.Itoa(config.GetSchedulerConfig().API.Port)
76+
apiURL := "http://cortex-scheduler:" + port + "/scheduler/nova/external"
77+
slog.Info("sending request to external scheduler", "apiURL", apiURL)
78+
79+
requestBody := must.Return(json.Marshal(request))
80+
buf := bytes.NewBuffer(requestBody)
81+
req := must.Return(http.NewRequestWithContext(ctx, http.MethodPost, apiURL, buf))
82+
req.Header.Set("Content-Type", "application/json")
83+
//nolint:bodyclose // We don't care about the body here.
84+
respRaw := must.Return(http.DefaultClient.Do(req))
85+
defer respRaw.Body.Close()
86+
if respRaw.StatusCode != http.StatusOK {
87+
panic("external scheduler API returned non-200 status code")
88+
}
89+
var resp httpapi.ExternalSchedulerResponse
90+
must.Succeed(json.NewDecoder(respRaw.Body).Decode(&resp))
91+
if len(resp.Hosts) == 0 {
92+
panic("no hosts found in response")
93+
}
94+
slog.Info("check successful, got hosts", "count", len(resp.Hosts))
95+
}

main.go

+10-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
gosync "sync"
1313
"time"
1414

15+
"github.com/cobaltcore-dev/cortex/internal/commands/checks"
1516
"github.com/cobaltcore-dev/cortex/internal/conf"
1617
"github.com/cobaltcore-dev/cortex/internal/db"
1718
"github.com/cobaltcore-dev/cortex/internal/features"
@@ -131,7 +132,7 @@ func main() {
131132
taskName = os.Args[1]
132133
bininfo.SetTaskName(taskName)
133134
} else {
134-
panic(fmt.Sprintf("usage: %s [syncer | extractor | scheduler]", os.Args[0]))
135+
panic(fmt.Sprintf("usage: %s [checks | syncer | extractor | scheduler]", os.Args[0]))
135136
}
136137

137138
dbInstance := db.NewPostgresDB(config.GetDBConfig())
@@ -140,6 +141,14 @@ func main() {
140141
migrater := db.NewMigrater(dbInstance)
141142
migrater.Migrate(true)
142143

144+
// If we're running one-off tasks (commands), don't setup the monitoring server.
145+
//nolint:gocritic // We may add more tasks in the future.
146+
switch taskName {
147+
case "checks":
148+
checks.RunChecks(ctx, config)
149+
return
150+
}
151+
143152
monitoringConfig := config.GetMonitoringConfig()
144153
registry := monitoring.NewRegistry(monitoringConfig)
145154
go runMonitoringServer(ctx, registry, monitoringConfig)

0 commit comments

Comments
 (0)