End-to-end tests
Every end-to-end case runs the real thing: a kind cluster, the operator
built from the working tree, real custom resources, and — where the case needs it — a real OAP
server, UI, storage and Java application. They are driven by
skywalking-infra-e2e, configured by one
test/e2e/<case>/e2e.yaml per case, and each has a job in
.github/workflows/go.yml.
Run one locally:
make e2e-chart # runs test/e2e/chart/e2e.yaml
make e2e-test # runs every case; this takes a long time
The target name is the directory name, so there is no list to keep in step with the tree.
Anatomy of a case
setup:
env: kind # a kind cluster from test/e2e/kind.yaml
steps: # ordered; a failing step fails the case
- name: ...
command: ...
wait: ... # kubectl wait conditions, retried until the setup timeout
trigger: # optional: generate traffic, so there is telemetry to assert on
verify:
cases:
- query: ... # any shell command
expected: ... # a file the output must match, or contain
expected files live in test/e2e/verify/ and may use
{{- contains . }}, which asserts the output contains the listed entries rather than equals them.
Test images
Every image the suite deploys is pinned in test/e2e/env, one variable
per image. skywalking-infra-e2e loads it into every setup step via
setup.init-system-environment: ../env, and the manifests reference the variables directly, so
applying one is plain envsubst:
envsubst "$E2E_IMAGES" < test/e2e/skywalking-components.yaml | kubectl apply -f -
E2E_IMAGES is the list of every image variable, defined at the bottom of env. Passing it
restricts substitution to those names, so the regexes and shell-looking strings elsewhere in the
manifests — namespace: "^skywalking-system$", for one — are left alone. A bare envsubst would
mangle them.
The rules:
- Pin, never float.
apache/skywalking-banyandb:latestused to be in this suite: a moving target that silently changes what CI tests and breaks on someone else’s merge. - Prefer a GHCR commit id. Docker Hub applies anonymous pull rate limits that CI runners share with the rest of the internet, and every SkyWalking component publishes a per-commit image to GHCR on every push to its default branch.
- Except OAP and the UI, which are pinned to their Docker Hub release tags, matching
apache/skywalking-helm’stest/e2e/env. Those two are what the operator’s own webhook defaults point at, so CI validates the artifacts users are actually told to install.
The operator and the metrics adapter are not in that file — they are built from the working tree and side-loaded into kind, because e2e exists to test the code in the pull request.
Elasticsearch and the kind node image are third-party and stay on their own registries, version-pinned.
The UI is Horizon
horizon is the only spec.kind the operator accepts, and what every case here deploys. Horizon
shares the apache/skywalking-ui repository with the retired Booster UI and is told apart by the
horizon- tag prefix, so UI_IMAGE is apache/skywalking-ui:horizon-1.0.0 and a Horizon
spec.version is the Horizon version, not the OAP one.
Booster is gone: apache/skywalking removed its submodule in 11.0.0 and its master Makefile now
has DOCKER_TARGETS := docker.oap, so no 11.x image is built for it and
ghcr.io/apache/skywalking/ui stops at the last commit that produced one.
OAP, Horizon and BanyanDB move as one: OAP 11.0.0 accepts BanyanDB server API 0.11 only, and Horizon 1.0.0’s admin host and template store are OAP 11 additions. Bumping one alone produces a combination nobody supports.
Horizon does not proxy GraphQL – its BFF serves its own /api/* routes, behind RBAC – so the
swctl queries that used to go through http://<ui>/graphql now go straight to OAP, exactly as
apache/skywalking-helm’s e2e does.
The UI is checked two ways. ui-cases.yaml, shared by every case
that deploys a UI, calls /api/auth/health – the one route Horizon declares public – and asserts
the auth backend it reports. That proves the container is up on 8081 rather than Booster’s 8080 and
that the Service maps 80 to it. It deliberately claims no more than that: local is also Horizon’s
own default, so the assertion holds whether or not the operator’s HORIZON_* variables took effect.
What proves those is oap-ui-agent, which logs in and reads /api/oap/info and
/api/cluster/state back through the BFF – both report what Horizon resolved its OAP hosts to, so
they fail if the wiring did not arrive.
Configuration paths
Horizon takes its configuration from HORIZON_* environment variables against the tokenised config
baked into its image, and that is what almost every case runs: no spec.config, so no ConfigMap is
created and nothing is mounted over the baked file.
oap-ui-agent is the case that proves it — it logs in
and reads /api/oap/info and /api/cluster/state back through the BFF, both of which report what
Horizon resolved its OAP hosts to, so the case fails if the operator’s variables did not arrive.
Exactly one case takes the override path.
oap-ui-agent-oapserverconfig-oapserverdynamicconfig
uses skywalking-components-config-override.yaml,
where the UI carries a whole horizon.yaml in spec.config — mounted as a ConfigMap over the
baked one — while its OAPServerConfig overlays a static file on the OAP through file:. It is the
case that proves a user can take configuration over from the operator on both sides.
Logging in needs a user, and Horizon ships auth.local.users empty, so a UI refuses every login
until one is seeded. On the env path that comes from a Secret through spec.envFrom, which is
also what proves envFrom reaches the container: if it did not, there would be no user and the
login would fail. On the override path the user is in the horizon.yaml itself;
test/tools/horizon.sh then logs in, keeps the horizon_sid
session cookie and calls an RBAC-protected route, mirroring
apache/skywalking-helm’s test/e2e/script/horizon.sh. oap-ui-agent does this, which also makes
it the only case that exercises spec.config at all.
Expectations that name an image
verify/swagent-initcontainer.yaml and its
configmap sibling assert that the injector injected exactly the image the SwAgent CR asked for,
so they carry the same variable. The case runs the same envsubst over them into
*.rendered.yaml — gitignored — in the step that creates the workload, and points expected: at
the rendered file.
Two images are not in any manifest, and both used to slip past the pins: the sidecar the Java agent
injector adds, whose default is hardcoded in
annotations.yaml,
and the EventExporter image, which the operator derives from spec.version. demo.yaml now sets
sidecar.skywalking.apache.org/initcontainer.Image and the EventExporter CR sets image, so both
come from env like everything else.
How the operator gets installed
Ten of the twelve cases install the operator with the Helm chart:
helm install skywalking-swck chart/skywalking-swck \
--namespace skywalking-swck-system --create-namespace \
--set operator.image.repository=controller \
--set operator.image.tag=latest \
--set operator.image.pullPolicy=IfNotPresent \
--wait --timeout 10m
This is deliberate. Before, every case ran make -C operator install && make -C operator deploy,
which installs the CRDs and the operator with kustomize. Swapping that one step turned the whole
existing suite into chart coverage — real custom resources, cert-manager, webhooks, RBAC — without
writing a single new assertion.
test/e2e/oap-ui-agent is deliberately left on
kustomize. operator/config is still a supported install path and ships in the binary release as
operator-bundle.yaml, so something has to keep exercising it.
Coverage
| Case | Install | What it proves |
|---|---|---|
chart |
chart, packaged tarball | The chart’s own lifecycle — see below |
oap-ui-agent |
kustomize | The kustomize install path; OAPServer + UI + Java agent injection end to end |
oap-ui-swagent |
chart | SwAgent drives the injected sidecar |
oap-ui-swagent-configmap |
chart | SwAgent mounting an agent config from a ConfigMap |
oap-ui-agent-internal-storage |
chart | Storage provisioning Elasticsearch inside the cluster |
oap-ui-agent-external-storage |
chart | Storage pointing at an Elasticsearch the operator does not own |
banyandb |
chart | BanyanDB as the OAP storage |
oap-ui-agent-satellite |
chart | Satellite in front of OAP |
oap-agent-adapter-hpa |
chart, adapter on | HPA scales a deployment on a SkyWalking metric |
oap-satellite-adapter-hpa |
chart, adapter on | The same, with Satellite in the path |
oap-ui-agent-oapserverconfig-oapserverdynamicconfig |
chart | OAPServerConfig and OAPServerDynamicConfig reach a running OAP |
oap-eventexporter |
chart | EventExporter exports operator events into OAP |
Per feature:
| Feature | Covered by |
|---|---|
| Operator deployment, kustomize | oap-ui-agent |
| Operator deployment, Helm chart | every other case |
| Chart install / upgrade / uninstall | chart |
| CRDs installed and reconciled | every case; counted explicitly in chart |
| Admission webhooks and CA injection | every case implicitly; asserted in chart |
| Java agent sidecar injection | oap-ui-agent, oap-ui-swagent, oap-ui-swagent-configmap, chart |
SwAgent / JavaAgent |
oap-ui-swagent, oap-ui-swagent-configmap |
OAPServer, UI |
oap-ui-agent and most others |
Storage |
oap-ui-agent-internal-storage, oap-ui-agent-external-storage |
BanyanDB |
banyandb |
Satellite |
oap-ui-agent-satellite, oap-satellite-adapter-hpa |
OAPServerConfig, OAPServerDynamicConfig |
oap-ui-agent-oapserverconfig-oapserverdynamicconfig |
EventExporter |
oap-eventexporter |
| Custom metrics adapter, HPA | oap-agent-adapter-hpa, oap-satellite-adapter-hpa, chart |
| Operator metrics behind kube-rbac-proxy | chart |
Fetcher has no case of its own; it is documented by the
Istio control plane example. Adding one is the clearest gap in
this suite.
The chart case
test/e2e/chart is the only case that tests the chart
rather than the operator. Everything it does happens in one step,
lifecycle.sh, because part of what it asserts is what
survives helm uninstall — which has to run last. The step writes a report that the verify phase
compares against verify/chart-lifecycle.yaml.
It installs the packaged tarball, not the source directory, because the tarball is what is released and what users install. It asserts:
- the packaged chart installs, with the adapter enabled;
- it brings one CRD per file in
operator/config/crd/bases— the count is read off the generated sources at run time, so adding a CRD does not need the expectations edited; - cert-manager injects the CA into both webhook configurations. Without it the API server cannot reach the webhook and every custom resource is rejected — while the chart still installs cleanly and the operator still reports ready, so nothing else would notice;
- the webhook configurations cover every kind the operator serves (12 mutating, 10 validating);
- the operator reconciles an
OAPServerinto a Deployment and a Service, which needs the CRD, both webhooks, the ClusterRole and the service account binding all to be right; - the Java agent injector mutates a pod in an opted-in namespace;
- the metrics adapter owns and serves
external.metrics.k8s.io; - the operator’s metrics endpoint is served through the kube-rbac-proxy sidecar;
helm upgraderemoves the adapter and puts it back, APIService and all;helm uninstallleaves the CRDs — and therefore every custom resource in the cluster — in place, and leaves nothing cluster-scoped behind for the next install to collide with.
The chart is also checked without a cluster, on every PR, by make chart-check and
make chart-lint:
chart-checkregenerates the CRDs, the manager ClusterRole and the webhook configurations from the operator sources and fails on any difference. This is why the chart lives in this repository: it cannot drift from the operator it ships beside.chart-lintrenders the chart under the values that change what it produces — adapter on and off, webhooks off, metrics off, GHCR images, externally managed service accounts, a maximum-length release name — and asserts on the output.helm lintandhelm templateboth exit 0 on a chart that renders nothing, so neither proves anything on its own.
Adding a case
mkdir test/e2e/<case>and writee2e.yaml. Copy the closest existing case; the setup steps up to and including the operator install are the same everywhere.- Put expectations in
test/e2e/verify/. - Add a job to
.github/workflows/go.ymland list it under thechecksjob’sneeds, otherwise a failure will not block the merge. - Add the case to
E2E_CASESin theMakefileand to the table above.