diff --git a/.github/microshift-ci/Dockerfile b/.github/microshift-ci/Dockerfile new file mode 100644 index 000000000..df50da856 --- /dev/null +++ b/.github/microshift-ci/Dockerfile @@ -0,0 +1,149 @@ +# syntax=docker/dockerfile:1 +# +# MicroShift in a privileged container for CI. +# +# This image bundles upstream MicroShift on top of CentOS Stream 9 and +# runs systemd as PID 1. Intended to be launched with: +# +# docker run -d --privileged --cgroupns=host \ +# -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ +# -v /lib/modules:/lib/modules:ro \ +# --tmpfs /run --tmpfs /tmp \ +# -p 6443:6443 -p 80:80 -p 443:443 \ +# microshift-ci:local +# +# See .github/microshift-ci/start.sh for the host-side wrapper used in CI. +# Used by .github/workflows/e2e-tests-openshift.yml. +# +# Notes / constraints: +# - The `@redhat-et/microshift` Copr is dead (last build 2021). +# - The `@microshift/` Copr group on Fedora Copr is empty (no projects, +# no builds), so any `@microshift/microshift-X.Y` reference fails at +# `dnf copr enable`. +# - The verified working RPM source is the official OpenShift mirror +# at `mirror.openshift.com`, which serves the same MicroShift bits +# Red Hat ships under subscription, on two anonymous yum repos. +# No GPG keys are published alongside, so `gpgcheck=0` is required. +# - Reproducibility: pin to a specific RC tag (e.g. `4.18.0-rc.10`), +# NOT the moving `latest-4.18` symlink. +# - microshift shells out to `/bin/hostname` at startup; the package +# must be installed explicitly because it is not in the minimal +# base nor pulled transitively by any microshift-* dep. +# - cri-o reads `/etc/crio/openshift-pull-secret` to authenticate to +# `quay.io/openshift-release-dev/ocp-v4.0-art-dev`. The path is +# bind-mounted from the runner by `start.sh`; without it the +# OVN/DNS/router pods fail to pull and the node never goes Ready. +# - Docker's default `private` mount propagation on `/` blocks the +# `rshared` bind mounts that OVN-Kubernetes (and other openshift-* +# system pods) need. We wrap /sbin/init with entrypoint.sh which +# runs `mount --make-rshared /` first. +# - The runner's Docker mounts `/run` as tmpfs with `noexec`. cri-o +# validates a CNI config by exec'ing the plugin binary; OVN drops +# `/run/cni/bin/ovn-k8s-cni-overlay` there, the exec returns +# EACCES, and cri-o falls back to crio-bridge + loopback (node +# never goes Ready). entrypoint.sh remounts /run with `exec` to +# fix this. + +ARG BASE_IMAGE=quay.io/centos/centos:stream9 +# MicroShift release tag to pin (e.g. 4.18.0-rc.10). Browse +# https://mirror.openshift.com/pub/openshift-v4/x86_64/microshift/ocp/ +# for available RC directories. There are no GA dirs in the public +# mirror; only `*-rc.*` and (in `…/ocp-dev-preview/…`) `*-ec.*`. +ARG MICROSHIFT_RELEASE=4.18.0-rc.10 +# Minor line corresponding to MICROSHIFT_RELEASE. Used both in the local +# repo `[id]` and in the dependencies-repo URL. +ARG MICROSHIFT_LINE=4.18 + +FROM ${BASE_IMAGE} + +ARG MICROSHIFT_RELEASE +ARG MICROSHIFT_LINE + +# Base packages: systemd as PID 1, plus userspace required by +# microshift's startup checks. +# +# - `hostname` is mandatory: microshift shells +# out to /bin/hostname during config defaulting; without it the +# daemon dies in <1 s. The package is NOT in CentOS Stream 9's +# minimal base image and is NOT pulled transitively by any +# microshift-* dep. +# - `iputils`, `iproute`, `procps-ng` are listed explicitly even +# though they happen to be pulled by microshift-networking today. +# Same class of silent breakage as `hostname` if a future minor +# changes its deps; cheap to defend against. +RUN dnf -y install \ + systemd \ + ca-certificates \ + hostname \ + iputils \ + iproute \ + procps-ng && \ + dnf clean all + +# Mask units that are not relevant inside a container and tend to fail +# noisily, polluting `journalctl` output. +RUN systemctl mask \ + systemd-firstboot.service \ + systemd-udevd.service \ + systemd-udev-trigger.service \ + systemd-hwdb-update.service \ + systemd-tmpfiles-setup.service \ + systemd-tmpfiles-setup-dev.service \ + systemd-tmpfiles-clean.service \ + systemd-tmpfiles-clean.timer \ + getty.target \ + getty@.service \ + console-getty.service + +# Configure the two anonymous mirror.openshift.com yum repos that ship +# MicroShift and its dependencies (cri-o, openshift-clients, openvswitch3, +# etc.). Shell-level `:=` defaults guard against an empty `--build-arg` +# from the workflow (defense in depth — with literal mirror URLs an +# empty value would surface as a 404, but pinning is too important to +# rely on that alone). +RUN : "${MICROSHIFT_RELEASE:=4.18.0-rc.10}" && \ + : "${MICROSHIFT_LINE:=4.18}" && \ + cat > /etc/yum.repos.d/microshift.repo </dev/null 2>&1; then + log "(no container to inspect)" + return + fi + log "--- container status ---" + docker ps -a --filter "name=^/${NAME}$" || true + log "--- container logs (tail) ---" + docker logs --tail 200 "$NAME" 2>&1 || true + log "--- microshift journal (tail) ---" + docker exec "$NAME" journalctl -u microshift --no-pager 2>&1 | tail -200 || true + log "--- crio journal (tail) ---" + docker exec "$NAME" journalctl -u crio --no-pager 2>&1 | tail -200 || true +} + +trap 'rc=$?; if [ "$rc" -ne 0 ]; then dump_diagnostics; fi; exit "$rc"' EXIT + +# Remove any stale container from a previous run. +if docker inspect "$NAME" >/dev/null 2>&1; then + log "Removing existing container $NAME" + docker rm -f "$NAME" >/dev/null +fi + +log "Starting MicroShift container ($IMAGE)" +log " pull secret: $PULL_SECRET_PATH" +docker run -d \ + --name "$NAME" \ + --privileged \ + --hostname microshift \ + --cgroupns=host \ + -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ + -v /lib/modules:/lib/modules:ro \ + -v "${PULL_SECRET_PATH}:/etc/crio/openshift-pull-secret:ro" \ + --tmpfs /run \ + --tmpfs /tmp \ + -p 6443:6443 \ + -p 80:80 \ + -p 443:443 \ + "$IMAGE" >/dev/null + +deadline=$(( $(date +%s) + WAIT_TIMEOUT )) + +# systemd rate-limits unit restarts after 5 failures in 10 seconds and +# gives up. Polling for a kubeconfig that will never appear past that +# point just burns the full WAIT_TIMEOUT. Check the unit state on every +# iteration and exit early on `failed`. +check_microshift_failed() { + local state + state=$(docker exec "$NAME" systemctl is-failed microshift.service 2>/dev/null || true) + if [ "$state" = "failed" ]; then + log "microshift.service entered 'failed' state — exiting early" + log "--- microshift unit status ---" + docker exec "$NAME" systemctl status --no-pager microshift.service 2>&1 || true + log "--- microshift + crio journal (current boot) ---" + docker exec "$NAME" journalctl -u microshift -u crio --no-pager -b 2>&1 || true + return 1 + fi + return 0 +} + +log "Waiting for kubeconfig to appear inside the container" +while ! docker exec "$NAME" test -f /var/lib/microshift/resources/kubeadmin/kubeconfig 2>/dev/null; do + if ! check_microshift_failed; then + exit 1 + fi + if (( $(date +%s) > deadline )); then + log "Timed out waiting for kubeconfig (${WAIT_TIMEOUT}s)" + exit 1 + fi + sleep 5 +done + +mkdir -p "$(dirname "$KUBECONFIG_OUT")" +docker cp "${NAME}:/var/lib/microshift/resources/kubeadmin/kubeconfig" "$KUBECONFIG_OUT" +chmod 600 "$KUBECONFIG_OUT" + +# Point the kubeconfig at the published port on the runner host. The +# server certificate is signed for in-cluster names, so TLS verification +# is disabled after rewriting the server to 127.0.0.1. +cluster_name=$(kubectl --kubeconfig "$KUBECONFIG_OUT" config view --raw -o jsonpath='{.clusters[0].name}') +if [ -z "$cluster_name" ]; then + log "Could not determine cluster name from kubeconfig" + cat "$KUBECONFIG_OUT" + exit 1 +fi +kubectl --kubeconfig "$KUBECONFIG_OUT" config set-cluster "$cluster_name" \ + --server=https://127.0.0.1:6443 >/dev/null +kubectl --kubeconfig "$KUBECONFIG_OUT" config set \ + "clusters.${cluster_name}.insecure-skip-tls-verify" true >/dev/null +kubectl --kubeconfig "$KUBECONFIG_OUT" config unset \ + "clusters.${cluster_name}.certificate-authority-data" >/dev/null 2>&1 || true + +log "Wrote kubeconfig to $KUBECONFIG_OUT" + +# Detect the "node registered but stuck NotReady" failure mode and +# dump the diagnostics that have surfaced real failures in the past: +# +# - subscription-gated image pulls -> visible in `describe pods` +# - rshared mount-propagation error -> visible in pod events +# - OVS modprobe / OVN setup hang -> visible in OVN pod logs, +# OVS journals, and the +# missing CNI config file +# - cri-o EACCES exec'ing OVN CNI -> visible in /run mount +# binary (because /run is noexec) options, ls of the binary, +# and a direct --help exec +# +# Filtering on `phase != Running` misses the case where OVN pods are +# Running-but-not-functional, so we describe ALL pods (capped to +# head -200 to keep the workflow log readable). +dump_node_diagnostics() { + log "--- pods (all namespaces) ---" + kubectl --kubeconfig "$KUBECONFIG_OUT" get pods -A -o wide || true + + log "--- describe pods -A (head -200) ---" + kubectl --kubeconfig "$KUBECONFIG_OUT" describe pods -A 2>&1 | head -200 || true + + log "--- ovnkube-node logs (all containers, tail 200) ---" + kubectl --kubeconfig "$KUBECONFIG_OUT" logs \ + -n openshift-ovn-kubernetes -l app=ovnkube-node \ + --all-containers --tail=200 2>&1 || true + + log "--- ovnkube-master logs (all containers, tail 200) ---" + kubectl --kubeconfig "$KUBECONFIG_OUT" logs \ + -n openshift-ovn-kubernetes -l app=ovnkube-master \ + --all-containers --tail=200 2>&1 || true + + log "--- openvswitch / microshift-ovs-init unit status ---" + docker exec "$NAME" systemctl status --no-pager \ + openvswitch microshift-ovs-init 2>&1 || true + + log "--- openvswitch / microshift-ovs-init journal (tail 100) ---" + docker exec "$NAME" journalctl -u openvswitch -u microshift-ovs-init \ + --no-pager 2>&1 | tail -100 || true + + log "--- /etc/cni/net.d/ (CNI handoff marker) ---" + docker exec "$NAME" ls -la /etc/cni/net.d/ 2>&1 || true + + # cri-o exec'ing the OVN CNI plugin returns EACCES if /run is + # noexec. These dumps distinguish between (a) /run still mounted + # noexec (entrypoint remount didn't take effect), (b) the binary + # missing or not executable (OVN setup incomplete), and (c) AppArmor + # blocking exec from /run despite --privileged. + log "--- /run/cni/bin/ contents ---" + docker exec "$NAME" ls -la /run/cni/bin/ 2>&1 || true + + log "--- /run mount options (looking for noexec) ---" + docker exec "$NAME" sh -c "cat /proc/mounts | grep -E ' /run( |\b)'" 2>&1 || true + + log "--- direct exec of OVN CNI binary (--help) ---" + docker exec "$NAME" /run/cni/bin/ovn-k8s-cni-overlay --help 2>&1 | head -10 || true + + log "--- SELinux mode (getenforce) ---" + docker exec "$NAME" getenforce 2>&1 || true + + log "--- OVN CNI binary stat (mode/owner) ---" + docker exec "$NAME" stat -c '%A %u %g %n' /run/cni/bin/ovn-k8s-cni-overlay 2>&1 || true +} + +log "Waiting for node to report Ready" +node_first_seen_at=0 +diagnostics_dumped=0 +while true; do + if kubectl --kubeconfig "$KUBECONFIG_OUT" get nodes 2>/dev/null \ + | awk 'NR>1 && $2=="Ready" {found=1} END {exit !found}'; then + break + fi + + # Track the moment the node first appears in `get nodes` output, so + # we can measure how long it has been registered-but-NotReady. + if [ "$node_first_seen_at" = "0" ]; then + if kubectl --kubeconfig "$KUBECONFIG_OUT" get nodes 2>/dev/null \ + | awk 'NR>1' | grep -q .; then + node_first_seen_at=$(date +%s) + log "Node registered, waiting for Ready" + fi + fi + + if [ "$diagnostics_dumped" = "0" ] && [ "$node_first_seen_at" != "0" ]; then + if (( $(date +%s) - node_first_seen_at > NODE_NOT_READY_GRACE )); then + log "Node has been registered for >${NODE_NOT_READY_GRACE}s but is still NotReady" + log "Dumping node diagnostics to surface the underlying error" + dump_node_diagnostics + diagnostics_dumped=1 + fi + fi + + if ! check_microshift_failed; then + exit 1 + fi + if (( $(date +%s) > deadline )); then + log "Timed out waiting for Ready node (${WAIT_TIMEOUT}s)" + kubectl --kubeconfig "$KUBECONFIG_OUT" get nodes -o wide || true + # Final node dump if we haven't already. + if [ "$diagnostics_dumped" = "0" ]; then + dump_node_diagnostics + fi + exit 1 + fi + sleep 5 +done + +log "MicroShift is up:" +kubectl --kubeconfig "$KUBECONFIG_OUT" get nodes -o wide + +# Expose the kubeconfig to subsequent steps when running under GitHub Actions. +if [ -n "${GITHUB_ENV:-}" ]; then + echo "KUBECONFIG=$KUBECONFIG_OUT" >> "$GITHUB_ENV" +fi diff --git a/.github/scripts/build-theia-cloud-images.sh b/.github/scripts/build-theia-cloud-images.sh new file mode 100644 index 000000000..fa6fe46c9 --- /dev/null +++ b/.github/scripts/build-theia-cloud-images.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# +# Shared image-build helper for the e2e CI workflows. +# +# Builds the 8 Theia Cloud Docker images that the e2e Playwright suite +# depends on, tags them under a configurable registry prefix and tag, +# and (optionally) runs a post-build hook on each image. The hook is +# how the OpenShift workflow transfers each image into MicroShift's +# CRI-O containers-storage via skopeo without standing up a registry. +# +# Usage: +# build-theia-cloud-images.sh [options] +# +# Options: +# --tag Image tag (e.g. "minikube-ci-e2e", +# "microshift-ci-e2e"). Required. +# --registry-prefix Prefix prepended to the per-image name +# (e.g. "theiacloud/theia-cloud-" or +# "localhost/theia-cloud-"). Required. +# --no-cache Pass --no-cache to docker build. +# --post-build Shell command run after each successful +# build. Receives LOCAL_TAG in env (the +# fully-qualified image reference). The +# command runs in the working directory +# of this script's caller. +# +# Must be invoked from the theia-cloud repository root (Dockerfiles use +# repo-root build contexts). + +set -euo pipefail + +TAG="" +REGISTRY_PREFIX="" +NO_CACHE="" +POST_BUILD="" + +while [ $# -gt 0 ]; do + case "$1" in + --tag) + TAG="$2" + shift 2 + ;; + --registry-prefix) + REGISTRY_PREFIX="$2" + shift 2 + ;; + --no-cache) + NO_CACHE="--no-cache" + shift + ;; + --post-build) + POST_BUILD="$2" + shift 2 + ;; + *) + echo "::error::unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +if [ -z "$TAG" ]; then + echo "::error::--tag is required" >&2 + exit 2 +fi +if [ -z "$REGISTRY_PREFIX" ]; then + echo "::error::--registry-prefix is required" >&2 + exit 2 +fi + +build_image() { + local name=$1 + local dockerfile=$2 + local context=$3 + local local_tag="${REGISTRY_PREFIX}${name}:${TAG}" + + echo "::group::Build ${local_tag}" + # shellcheck disable=SC2086 + docker build $NO_CACHE -t "${local_tag}" -f "${dockerfile}" "${context}" + echo "::endgroup::" + + if [ -n "$POST_BUILD" ]; then + echo "::group::Post-build hook for ${local_tag}" + LOCAL_TAG="${local_tag}" bash -c "${POST_BUILD}" + echo "::endgroup::" + fi +} + +# Core Theia Cloud images (deployments under the theia-cloud namespace). +build_image service dockerfiles/service/Dockerfile . +build_image operator dockerfiles/operator/Dockerfile . +build_image landing-page dockerfiles/landing-page/Dockerfile . +build_image wondershaper dockerfiles/wondershaper/Dockerfile . +build_image conversion-webhook dockerfiles/conversion-webhook/Dockerfile . + +# Demo images (referenced by AppDefinitions). +build_image demo demo/dockerfiles/demo-theia-docker/Dockerfile demo/dockerfiles/demo-theia-docker/. +build_image activity-demo demo/dockerfiles/demo-theia-monitor-vscode/Dockerfile demo/dockerfiles/demo-theia-monitor-vscode/. +build_image activity-demo-theia demo/dockerfiles/demo-theia-monitor-theia/Dockerfile . diff --git a/.github/workflows/e2e-tests-openshift.yml b/.github/workflows/e2e-tests-openshift.yml new file mode 100644 index 000000000..4f45d9f23 --- /dev/null +++ b/.github/workflows/e2e-tests-openshift.yml @@ -0,0 +1,652 @@ +name: "[E2E Tests] OpenShift" + +# OpenShift e2e tests. Peer of e2e-tests.yml (minikube). Exercises the +# `cloudProvider=OPENSHIFT` code path of the helm chart + the +# OpenShift-specific Route templates + the operator's SCC RoleBinding +# logic, against MicroShift running inside a privileged Docker container +# on a stock GitHub-hosted ubuntu-22.04 runner. +# +# The MicroShift container approach: the runner builds a +# CentOS Stream 9 + systemd-as-PID-1 image (.github/microshift-ci/), +# starts MicroShift inside it (start.sh), then runs Theia Cloud terraform +# / e2e tests against the running cluster from the runner host. RPMs +# come from mirror.openshift.com (community/anonymous, no Red Hat +# subscription); the only secret needed is the Red Hat pull secret for +# pulling the OpenShift control-plane container images +# (REDHAT_PULL_SECRET, see Step 1 prereq below). +# +# CHECKOUT LAYOUT: same as e2e-tests.yml -- both repos in named +# subdirectories (path: ./theia-cloud + path: ./theia-cloud-helm) +# because 5-02_openshift_ci/theia_cloud.tf references the helm +# chart by relative path (../../../../theia-cloud-helm/charts/...). +# +# Prereqs the workflow assumes: +# 1. `REDHAT_PULL_SECRET` GitHub Actions secret on the repo. Obtain +# from console.redhat.com/openshift/install/pull-secret. Note that +# GH does NOT expose secrets to PRs from forks, so this workflow +# only runs on push/schedule events from the upstream repo. +# 2. The MicroShift release tag in the Dockerfile default must be +# served by mirror.openshift.com. + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + microshift_release: + description: "MicroShift release tag (e.g. 4.18.0-rc.10). Leave empty to use the Dockerfile default." + type: string + default: "" + microshift_line: + description: "MicroShift minor line (e.g. 4.18). Leave empty to use the Dockerfile default." + type: string + default: "" + theia-cloud-helm-branch: + description: "Branch of theia-cloud-helm to check out." + type: string + default: "main" + schedule: + - cron: "0 13 * * 0" + +permissions: + contents: read + +concurrency: + group: e2e-tests-openshift-${{ github.ref }} + cancel-in-progress: true + +env: + CI_IMAGE_TAG: microshift-ci-e2e + +jobs: + runtests: + name: "Run on MicroShift (Keycloak: ${{ matrix.keycloak }}, Ephemeral: ${{ matrix.ephemeral }}, EagerStart: ${{ matrix.eagerStart }})" + runs-on: ubuntu-22.04 + # Image build is the dominant cost (8 Theia Cloud images, including + # ~3 Theia-based ones at ~10 min each), plus MicroShift boot ~5 min, + # terraform applies ~10 min total, transfers ~5 min, tests ~3 min. + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + keycloak: [true, false] + ephemeral: [true, false] + eagerStart: [true, false] + exclude: + # Non-ephemeral sessions only make sense with keycloak (matches + # the minikube e2e workflow's exclusion). + - ephemeral: false + keycloak: false + # Eager start does not support persistent storage (workspaces). + - ephemeral: false + eagerStart: true + + steps: + - name: Set Helm Branch + run: echo "INPUT_THEIA_CLOUD_HELM_BRANCH=${{ inputs.theia-cloud-helm-branch || 'main' }}" >> $GITHUB_ENV + + - name: Checkout Theia Cloud + uses: actions/checkout@v4 + with: + path: ./theia-cloud + + - name: Checkout Theia Cloud Helm + # Required by 5-02_openshift_ci/theia_cloud.tf which loads + # the chart via `chart = "../../../../theia-cloud-helm/charts/...`. + uses: actions/checkout@v4 + with: + repository: eclipse-theia/theia-cloud-helm + ref: ${{ env.INPUT_THEIA_CLOUD_HELM_BRANCH }} + path: ./theia-cloud-helm + + - name: Show host kernel / cgroup info + run: | + uname -a + cat /proc/1/cgroup || true + stat -fc %T /sys/fs/cgroup || true + mount | grep cgroup || true + + - name: Build MicroShift CI image + # Only forward --build-arg when the input is non-empty. An empty + # `--build-arg X=` would silently override the Dockerfile default + # with an empty string. + env: + MICROSHIFT_RELEASE_INPUT: ${{ inputs.microshift_release }} + MICROSHIFT_LINE_INPUT: ${{ inputs.microshift_line }} + run: | + args=(--progress=plain) + if [ -n "${MICROSHIFT_RELEASE_INPUT:-}" ]; then + args+=(--build-arg "MICROSHIFT_RELEASE=${MICROSHIFT_RELEASE_INPUT}") + fi + if [ -n "${MICROSHIFT_LINE_INPUT:-}" ]; then + args+=(--build-arg "MICROSHIFT_LINE=${MICROSHIFT_LINE_INPUT}") + fi + DOCKER_BUILDKIT=1 docker build "${args[@]}" -t microshift-ci:local theia-cloud/.github/microshift-ci + + - name: Write Red Hat pull secret + # MicroShift's bundled OVN, DNS, router, and pause images live + # in the subscription-gated quay.io/openshift-release-dev/ + # ocp-v4.0-art-dev registry. Same secret as the local CRC docs + # prescribe (terraform/test-configurations/openshift.md). Obtain + # from console.redhat.com/openshift/install/pull-secret and + # store as the REDHAT_PULL_SECRET GitHub Actions secret. + env: + REDHAT_PULL_SECRET: ${{ secrets.REDHAT_PULL_SECRET }} + run: | + if [ -z "${REDHAT_PULL_SECRET:-}" ]; then + echo "::error::REDHAT_PULL_SECRET is not set on this repository." + echo "Obtain a pull secret from console.redhat.com/openshift/install/pull-secret" + echo "and add it as the REDHAT_PULL_SECRET GitHub Actions secret." + echo "Note: GitHub Actions does not expose secrets to PRs from forks," + echo "so this workflow cannot run from forked branches." + exit 1 + fi + umask 077 + printf '%s' "${REDHAT_PULL_SECRET}" > "${RUNNER_TEMP}/openshift-pull-secret" + chmod 600 "${RUNNER_TEMP}/openshift-pull-secret" + if [ ! -s "${RUNNER_TEMP}/openshift-pull-secret" ]; then + echo "::error::Pull secret file is empty after write" + exit 1 + fi + echo "Pull secret written ($(wc -c < "${RUNNER_TEMP}/openshift-pull-secret") bytes)" + + - name: Start MicroShift + run: | + chmod +x theia-cloud/.github/microshift-ci/start.sh + theia-cloud/.github/microshift-ci/start.sh \ + "${RUNNER_TEMP}/openshift-pull-secret" \ + microshift-ci:local + + - name: oc get nodes + run: kubectl get nodes -o wide + + - name: oc get pods (all namespaces) + run: kubectl get pods -A -o wide + + - name: Verify OpenShift API groups present + run: | + api_resources=$(kubectl api-resources) + echo "$api_resources" | grep -q 'route\.openshift\.io' || \ + { echo "::error::route.openshift.io API group not found"; exit 1; } + echo "$api_resources" | grep -q 'security\.openshift\.io' || \ + { echo "::error::security.openshift.io API group not found"; exit 1; } + echo "OK: route.openshift.io and security.openshift.io are present" + + - name: List Security Context Constraints + # `scc` (singular) is the registered shortname; `sccs` is not. + run: kubectl get scc -o wide + + - name: Wait for OpenShift router (Available) + run: | + kubectl -n openshift-ingress wait deployment/router-default \ + --for=condition=Available --timeout=180s + + - name: Resolve container IP + run: | + # Bridge-network IP of the microshift container is the + # apps-domain target. Persist for later steps. + CONTAINER_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' microshift) + echo "microshift container IP: ${CONTAINER_IP}" + if [ -z "$CONTAINER_IP" ]; then + echo "::error::Could not resolve microshift container IP" + docker inspect microshift + exit 1 + fi + echo "MICROSHIFT_CONTAINER_IP=${CONTAINER_IP}" >> "$GITHUB_ENV" + + - name: Configure dnsmasq for *.apps-microshift.testing + run: | + # Wildcard-resolve *.apps-microshift.testing on the runner so + # session-specific hostnames (ws-.apps-microshift.testing) + # generated at test time route to the MicroShift container + # without per-host /etc/hosts entries. + # + # The runner's /etc/resolv.conf points at systemd-resolved on + # 127.0.0.53; the real upstream resolvers are in + # /run/systemd/resolve/resolv.conf. Capture them BEFORE we + # stop systemd-resolved so dnsmasq can still forward general + # DNS lookups (e.g. raw.githubusercontent.com) upstream. + UPSTREAM_DNS=$(awk '/^nameserver/ {print $2}' \ + /run/systemd/resolve/resolv.conf 2>/dev/null | head -2) + if [ -z "${UPSTREAM_DNS}" ]; then + UPSTREAM_DNS="8.8.8.8 1.1.1.1" + fi + echo "Upstream DNS for dnsmasq: ${UPSTREAM_DNS}" + + sudo apt-get update + sudo apt-get install -y dnsmasq + sudo systemctl stop systemd-resolved || true + { + echo "no-resolv" + for s in ${UPSTREAM_DNS}; do + echo "server=${s}" + done + echo "address=/.apps-microshift.testing/${MICROSHIFT_CONTAINER_IP}" + } | sudo tee /etc/dnsmasq.d/microshift.conf + sudo systemctl restart dnsmasq + echo "nameserver 127.0.0.1" | sudo tee /etc/resolv.conf + + # Validate both wildcard and upstream resolution work. + getent hosts try.apps-microshift.testing + getent hosts ws-anything.apps-microshift.testing + getent hosts raw.githubusercontent.com + + - name: Configure cluster CoreDNS for *.apps-microshift.testing + run: | + # Pods inside MicroShift need to resolve + # *.apps-microshift.testing to the in-cluster router so the + # Theia Cloud `service` pod can reach Keycloak for OIDC + # discovery. The runner host already gets this resolution + # via dnsmasq above; this step is the pod-network + # counterpart. Patches the cluster DNS configmap to add a + # wildcard zone pointing at the router-default ClusterIP. + set -e + ROUTER_IP=$(kubectl -n openshift-ingress get svc router-default \ + -o jsonpath='{.spec.clusterIP}') + if [ -z "${ROUTER_IP}" ]; then + echo "::error::router-default ClusterIP not found" + kubectl -n openshift-ingress get svc -o wide || true + exit 1 + fi + echo "router-default ClusterIP: ${ROUTER_IP}" + + # Locate the CoreDNS configmap. MicroShift uses + # openshift-dns/dns-default; stock K8s uses kube-system/coredns. + DNS_NS="" + DNS_CM="" + for ns in openshift-dns kube-system; do + for cm in dns-default coredns; do + if kubectl -n "$ns" get configmap "$cm" >/dev/null 2>&1; then + DNS_NS="$ns"; DNS_CM="$cm"; break 2 + fi + done + done + if [ -z "${DNS_NS}" ]; then + echo "::error::Could not locate CoreDNS configmap" + kubectl get cm -A | grep -iE 'coredns|dns-default' || true + exit 1 + fi + echo "Patching CoreDNS configmap: ${DNS_NS}/${DNS_CM}" + + # Extract the bind port from the existing Corefile (OpenShift / + # MicroShift uses :5353; stock CoreDNS uses :53). + CURRENT=$(kubectl -n "$DNS_NS" get configmap "$DNS_CM" \ + -o jsonpath='{.data.Corefile}') + PORT=$(printf '%s' "$CURRENT" \ + | grep -oE '\.:[0-9]+' | head -1 | grep -oE '[0-9]+$' || true) + PORT="${PORT:-53}" + echo "CoreDNS bind port: ${PORT}" + + # Append a wildcard zone that synthesizes every FQDN under + # apps-microshift.testing. to ROUTER_IP via the template + # plugin (standard CoreDNS plugin, available in all distros). + # Synthesize both A and empty AAAA responses. Without the AAAA + # block, Java / Vert.x OIDC discovery logs CoreDNS errors like + # `AAAA: plugin/template: no next plugin found` and the service + # pod reports `OIDC Server is not available` when it resolves + # keycloak.apps-microshift.testing. + # Patch with jq so we preserve every other configmap field + # (ownership labels, annotations) the operator may reconcile. + NEW_COREFILE="${CURRENT} + apps-microshift.testing:${PORT} { + errors + cache 60 + template IN A apps-microshift.testing { + answer \"{{ .Name }} 60 IN A ${ROUTER_IP}\" + } + template IN AAAA apps-microshift.testing { + rcode NOERROR + } + } + " + kubectl -n "$DNS_NS" get configmap "$DNS_CM" -o json \ + | jq --arg cf "${NEW_COREFILE}" '.data.Corefile = $cf' \ + | kubectl apply -f - + + # Restart the DNS workload to pick up the new config. + if kubectl -n "$DNS_NS" get daemonset/dns-default >/dev/null 2>&1; then + kubectl -n "$DNS_NS" rollout restart daemonset/dns-default + kubectl -n "$DNS_NS" rollout status daemonset/dns-default --timeout=90s + elif kubectl -n "$DNS_NS" get deployment/coredns >/dev/null 2>&1; then + kubectl -n "$DNS_NS" rollout restart deployment/coredns + kubectl -n "$DNS_NS" rollout status deployment/coredns --timeout=90s + fi + + # Best-effort verification from a pod. Does NOT gate the step + # on success because Keycloak isn't deployed yet; the lookup + # will still synthesize DNS records (the connection itself + # only works after the keycloak Route is created later). + kubectl run dns-probe --image=busybox:1.36 --restart=Never \ + --command -- sh -c 'nslookup keycloak.apps-microshift.testing; nslookup -type=AAAA keycloak.apps-microshift.testing || true; sleep 2' + for i in $(seq 1 15); do + phase=$(kubectl get pod dns-probe -o jsonpath='{.status.phase}' 2>/dev/null || true) + case "$phase" in Succeeded|Failed) break ;; esac + sleep 2 + done + kubectl logs dns-probe || true + kubectl delete pod dns-probe --ignore-not-found --wait=false + + # ---------------------------------------------------------------- + # OpenShift base install: terraform 4_openshift-setup + # ---------------------------------------------------------------- + + - name: Install local-path-provisioner StorageClass + run: | + # MicroShift's default StorageClass (topolvm) needs an LVM + # volume group, which doesn't exist in our privileged + # container. Install rancher's local-path-provisioner as a + # shim. (The terraform module remains CRC-compatible because + # this lives in the workflow, not in the module.) + kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.30/deploy/local-path-storage.yaml + + # The provisioner pod and its on-demand helper pods bind-mount + # hostPath, which the restricted SCC blocks. Grant `privileged` + # SCC to both: the provisioner SA and the default SA in the + # same namespace (helper pods run under default). + for sa in local-path-provisioner-service-account default; do + kubectl create clusterrolebinding "local-path-${sa}-privileged" \ + --clusterrole=system:openshift:scc:privileged \ + --serviceaccount="local-path-storage:${sa}" \ + --dry-run=client -o yaml | kubectl apply -f - + done + + # Mark local-path as default; un-default any other currently- + # default SC so PVCs without an explicit className land on it. + kubectl patch storageclass local-path \ + -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' + for sc in $(kubectl get sc -o jsonpath='{.items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")].metadata.name}'); do + if [ "$sc" != "local-path" ]; then + kubectl patch storageclass "$sc" \ + -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}' + fi + done + + kubectl -n local-path-storage wait deployment/local-path-provisioner \ + --for=condition=Available --timeout=120s + + - name: Mint cluster-admin bearer token + run: | + # MicroShift's kubeadmin kubeconfig uses client-certificate + # auth, not bearer tokens, so `oc whoami -t` returns empty. + # The 4_openshift-setup module's kubernetes/helm/kubectl + # providers all expect a bearer token. Mint one via the + # TokenRequest API. + kubectl create namespace tf-bootstrap \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl create serviceaccount tf-admin -n tf-bootstrap \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl create clusterrolebinding tf-admin \ + --clusterrole=cluster-admin \ + --serviceaccount=tf-bootstrap:tf-admin \ + --dry-run=client -o yaml | kubectl apply -f - + + TOKEN=$(kubectl create token tf-admin -n tf-bootstrap --duration=24h) + if [ -z "$TOKEN" ]; then + echo "::error::Failed to mint token for tf-admin" + exit 1 + fi + echo "::add-mask::${TOKEN}" + echo "OPENSHIFT_TOKEN=${TOKEN}" >> "$GITHUB_ENV" + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "1.13.0" + terraform_wrapper: false + + - name: Terraform apply (4_openshift-setup) + working-directory: theia-cloud/terraform/test-configurations/4_openshift-setup + env: + TF_VAR_openshift_server: https://127.0.0.1:6443 + TF_VAR_openshift_token: ${{ env.OPENSHIFT_TOKEN }} + TF_VAR_apps_domain: apps-microshift.testing + run: | + rm -f terraform.tfstate terraform.tfstate.backup + terraform init + terraform apply -auto-approve + + - name: Verify Keycloak pods Ready + run: | + kubectl get pods -n keycloak -o wide + kubectl wait --for=condition=Ready pods --all -n keycloak --timeout=180s + + - name: Verify TheiaCloud realm metadata + run: | + URL="https://keycloak.apps-microshift.testing/realms/TheiaCloud/.well-known/openid-configuration" + for i in $(seq 1 12); do + if curl -sk --max-time 10 -o /tmp/oidc.json "$URL"; then + if jq -e '.issuer' /tmp/oidc.json >/dev/null 2>&1; then + echo "OK: TheiaCloud realm reachable" + jq '{issuer, authorization_endpoint, token_endpoint}' /tmp/oidc.json + exit 0 + fi + fi + echo "Attempt $i: waiting for realm metadata..." + sleep 5 + done + echo "::error::TheiaCloud realm metadata not reachable after 60s" + kubectl get pods -n keycloak -o wide || true + kubectl logs -n keycloak deployment/keycloak --tail=100 || true + exit 1 + + # ---------------------------------------------------------------- + # Theia Cloud install: build images + terraform 5-02_openshift_ci + # ---------------------------------------------------------------- + + - name: Build and transfer Theia Cloud images + # Build, transfer, and immediately prune each image to keep + # peak disk usage manageable on the 14 GB runner. After each + # iteration the image lives in MicroShift's CRI-O containers- + # storage but no longer in Docker's graph driver. + working-directory: theia-cloud + run: | + chmod +x .github/scripts/build-theia-cloud-images.sh + # docker save | docker exec -i microshift skopeo copy is the + # registry-free transport: the docker daemon socket is NOT + # shared with the microshift container, so we pipe a + # docker-archive tarball through stdin. + .github/scripts/build-theia-cloud-images.sh \ + --tag "${CI_IMAGE_TAG}" \ + --registry-prefix localhost/theia-cloud- \ + --post-build ' + docker save "$LOCAL_TAG" | docker exec -i microshift \ + skopeo copy --dest-tls-verify=false \ + docker-archive:/dev/stdin \ + "containers-storage:$LOCAL_TAG" + docker rmi "$LOCAL_TAG" || true + docker system df + df -h / + ' + + docker exec microshift crictl images | grep theia-cloud || true + + - name: Terraform apply (5-02_openshift_ci) + # Loads valuesE2ECI-base.yaml + valuesE2ECI-openshift.yaml from + # within the module; matrix axes are forwarded via TF_VAR_*. + working-directory: theia-cloud/terraform/test-configurations/5-02_openshift_ci + env: + TF_VAR_enable_keycloak: ${{ matrix.keycloak }} + TF_VAR_use_ephemeral_storage: ${{ matrix.ephemeral }} + TF_VAR_eager_start: ${{ matrix.eagerStart }} + run: | + rm -f terraform.tfstate terraform.tfstate.backup + terraform init + terraform apply -auto-approve + + - name: Verify theia-cloud deployments Available + run: | + kubectl get pods -n theia-cloud -o wide + kubectl -n theia-cloud wait \ + --for=condition=Available deployment --all --timeout=180s + + - name: Verify routes exist + run: | + kubectl get routes -n theia-cloud -o wide + routes=$(kubectl get routes -n theia-cloud -o jsonpath='{.items[*].spec.host}') + echo "Found routes: $routes" + echo "$routes" | grep -q 'try\.apps-microshift\.testing' || \ + { echo "::error::try.apps-microshift.testing route missing"; exit 1; } + echo "$routes" | grep -q 'service\.apps-microshift\.testing' || \ + { echo "::error::service.apps-microshift.testing route missing"; exit 1; } + echo "OK: required theia-cloud routes registered" + + - name: Verify try.apps-microshift.testing reachable + run: | + # With keycloak.enable=true: redirect (3xx) to Keycloak login. + # With keycloak.enable=false: 200 (anonymous landing page). + # Either is success. + for i in $(seq 1 12); do + code=$(curl -ksS -o /dev/null -w '%{http_code}' --max-time 10 \ + https://try.apps-microshift.testing || echo "000") + case "$code" in + 2*|3*) + echo "OK: try.apps-microshift.testing reachable (HTTP $code)" + exit 0 + ;; + *) + echo "Attempt $i: HTTP $code" + ;; + esac + sleep 5 + done + echo "::error::try.apps-microshift.testing not reachable after 60s" + kubectl -n theia-cloud describe routes + kubectl -n theia-cloud logs deployment/landing-page-deployment --tail=100 || true + exit 1 + + # ---------------------------------------------------------------- + # Playwright tests + # ---------------------------------------------------------------- + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies and run e2e tests + working-directory: theia-cloud/node + env: + MATRIX_CLOUD_PROVIDER: OPENSHIFT + APPS_DOMAIN: apps-microshift.testing + MATRIX_KEYCLOAK: ${{ matrix.keycloak }} + MATRIX_EPHEMERAL: ${{ matrix.ephemeral }} + run: | + npm ci + npm run build -w e2e-tests + npm run ui-tests -w e2e-tests + + - name: Upload Playwright artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-failure-keycloak-${{ matrix.keycloak }}-ephemeral-${{ matrix.ephemeral }}-eagerstart-${{ matrix.eagerStart }} + path: theia-cloud/node/e2e-tests/test-results/**/*.png + retention-days: 7 + + # ---------------------------------------------------------------- + # Diagnostics + cleanup + # ---------------------------------------------------------------- + + - name: MicroShift logs + if: always() + run: | + echo "--- microshift journal ---" + docker exec microshift journalctl -u microshift --no-pager || true + echo "" + echo "--- crio journal (tail) ---" + docker exec microshift journalctl -u crio --no-pager | tail -300 || true + + - name: Cluster diagnostics on failure + # Catches every class of failure that has surfaced in this + # workflow: + # - microshift userspace deps -> microshift journal + # - subscription-gated image pulls -> describe pods -A + # - rshared mount-propagation errors -> describe pods -A (events) + # - OVS modprobe / OVN setup hang -> OVN logs + OVS journal + CNI dir + # - cri-o EACCES exec'ing OVN binary -> /run mount opts + binary exec + SELinux probes + # - missing/misconfigured Theia Cloud CRs and Routes -> AppDefinitions / Sessions / Workspaces / Routes / router logs + if: failure() + run: | + echo "=== combined microshift + crio journal ===" + docker exec microshift journalctl -u microshift -u crio --no-pager || true + echo "" + echo "=== pods (all namespaces) ===" + kubectl get pods -A -o wide || true + echo "" + echo "=== describe pods -A (head -200) ===" + kubectl describe pods -A 2>&1 | head -200 || true + echo "" + echo "=== routes (all namespaces) ===" + kubectl get routes -A -o wide || true + echo "" + echo "=== describe routes (all namespaces) ===" + kubectl describe routes -A || true + echo "" + echo "=== AppDefinitions in theia-cloud ===" + kubectl get appdefinitions -n theia-cloud -o yaml || true + echo "" + echo "=== Sessions in theia-cloud ===" + kubectl get sessions -n theia-cloud -o yaml || true + echo "" + echo "=== Workspaces in theia-cloud ===" + kubectl get workspaces -n theia-cloud -o yaml || true + echo "" + echo "=== router-default logs (tail 200) ===" + kubectl logs -n openshift-ingress deployment/router-default --tail=200 || true + echo "" + echo "=== CoreDNS configmap and pod logs ===" + kubectl -n openshift-dns get configmap dns-default -o yaml 2>/dev/null \ + || kubectl -n kube-system get configmap coredns -o yaml 2>/dev/null \ + || true + kubectl -n openshift-dns logs -l dns.operator.openshift.io/daemonset-dns=default --tail=100 2>/dev/null \ + || kubectl -n kube-system logs -l k8s-app=kube-dns --tail=100 2>/dev/null \ + || true + echo "" + echo "=== theia-cloud pod logs (tail 100 each) ===" + for d in operator-deployment service-deployment landing-page-deployment conversion-webhook; do + echo "--- $d ---" + kubectl logs -n theia-cloud deployment/$d --tail=100 2>&1 || true + done + echo "" + echo "=== ovnkube-node logs ===" + kubectl logs -n openshift-ovn-kubernetes -l app=ovnkube-node \ + --all-containers --tail=200 2>&1 || true + echo "" + echo "=== ovnkube-master logs ===" + kubectl logs -n openshift-ovn-kubernetes -l app=ovnkube-master \ + --all-containers --tail=200 2>&1 || true + echo "" + echo "=== openvswitch / microshift-ovs-init unit status ===" + docker exec microshift systemctl status --no-pager \ + openvswitch microshift-ovs-init 2>&1 || true + echo "" + echo "=== openvswitch / microshift-ovs-init journal (tail 100) ===" + docker exec microshift journalctl -u openvswitch -u microshift-ovs-init \ + --no-pager 2>&1 | tail -100 || true + echo "" + echo "=== /etc/cni/net.d/ (CNI handoff marker) ===" + docker exec microshift ls -la /etc/cni/net.d/ 2>&1 || true + echo "" + echo "=== /run/cni/bin/ contents ===" + docker exec microshift ls -la /run/cni/bin/ 2>&1 || true + echo "" + echo "=== /run mount options (looking for noexec) ===" + docker exec microshift sh -c "cat /proc/mounts | grep -E ' /run( |\b)'" 2>&1 || true + echo "" + echo "=== direct exec of OVN CNI binary (--help) ===" + docker exec microshift /run/cni/bin/ovn-k8s-cni-overlay --help 2>&1 | head -10 || true + echo "" + echo "=== SELinux mode (getenforce) ===" + docker exec microshift getenforce 2>&1 || true + echo "" + echo "=== OVN CNI binary stat (mode/owner) ===" + docker exec microshift stat -c '%A %u %g %n' /run/cni/bin/ovn-k8s-cni-overlay 2>&1 || true + + - name: Stop MicroShift + if: always() + run: docker rm -f microshift || true + + - name: Remove pull secret + if: always() + run: rm -f "${RUNNER_TEMP}/openshift-pull-secret" || true diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3f2143838..5a13f642f 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -24,6 +24,7 @@ jobs: runtests: name: "Run Tests on Minikube (K8s: ${{ matrix.kubernetes }}, Ingress: ${{ matrix.ingress }}, Paths: ${{ matrix.paths }}, Ephemeral: ${{ matrix.ephemeral }}, Keycloak: ${{ matrix.keycloak }}, EagerStart: ${{ matrix.eagerStart }})" runs-on: ubuntu-22.04 + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -105,17 +106,13 @@ jobs: # we use the none driver, so minikube should see the same images on the host - name: Build Theia Cloud Images + working-directory: theia-cloud run: | - cd theia-cloud - docker build --no-cache -t theiacloud/theia-cloud-service:minikube-ci-e2e -f dockerfiles/service/Dockerfile . - docker build --no-cache -t theiacloud/theia-cloud-operator:minikube-ci-e2e -f dockerfiles/operator/Dockerfile . - docker build --no-cache -t theiacloud/theia-cloud-landing-page:minikube-ci-e2e -f dockerfiles/landing-page/Dockerfile . - docker build --no-cache -t theiacloud/theia-cloud-wondershaper:minikube-ci-e2e -f dockerfiles/wondershaper/Dockerfile . - docker build --no-cache -t theiacloud/theia-cloud-conversion-webhook:minikube-ci-e2e -f dockerfiles/conversion-webhook/Dockerfile . - docker build --no-cache -t theiacloud/theia-cloud-demo:latest -f demo/dockerfiles/demo-theia-docker/Dockerfile demo/dockerfiles/demo-theia-docker/. - docker tag theiacloud/theia-cloud-demo:latest theiacloud/theia-cloud-demo:minikube-ci-e2e - docker build --no-cache -t theiacloud/theia-cloud-activity-demo:minikube-ci-e2e -f demo/dockerfiles/demo-theia-monitor-vscode/Dockerfile demo/dockerfiles/demo-theia-monitor-vscode/. - docker build --no-cache -t theiacloud/theia-cloud-activity-demo-theia:minikube-ci-e2e -f demo/dockerfiles/demo-theia-monitor-theia/Dockerfile . + chmod +x .github/scripts/build-theia-cloud-images.sh + .github/scripts/build-theia-cloud-images.sh \ + --tag minikube-ci-e2e \ + --registry-prefix theiacloud/theia-cloud- \ + --no-cache - name: Get Ingress Controller Host id: ingress_info diff --git a/.gitignore b/.gitignore index 8b3b2df01..32ad970ad 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,8 @@ openapitools.json .DS_Store node/e2e-tests/test-results/.last-run.json -.prompts/task-contexts \ No newline at end of file +.prompts/task-contexts +**/terraform.tfstate +**/terraform.tfstate.backup +**/.terraform/ +**/.terraform.lock.hcl diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4496c79..46ad7cc7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.3.0] - unreleased + +- [java/operator] Add OpenShift support with Route-based session routing [#486](https://github.com/eclipse-theia/theia-cloud/pull/486) + +### Breaking Changes in 1.3.0 + +- [java/operator] Ingress and session URL logic extracted from `LazySessionHandler` and `EagerSessionHandler` into the new `SessionRoutingStrategy` interface. Custom operator extensions that override or extend these handlers may need to inject `SessionRoutingStrategy` instead of directly using `IngressPathProvider` and `TheiaCloudIngressUtil`. +- [java/operator] `TheiaCloudDeploymentUtil.getSessionURL()` methods (which took `IngressPathProvider`) have been removed. Use `SessionRoutingStrategy.getSessionURL()` instead. A new `TheiaCloudDeploymentUtil.extractHost()` utility method is provided. + ## [1.2.0] - 2026-04-09 - [java/operator] Fix ingress rules not being fully removed on session deletion [#456](https://github.com/eclipse-theia/theia-cloud/pull/456) diff --git a/dockerfiles/operator/Dockerfile b/dockerfiles/operator/Dockerfile index b2ea7b045..925dd101d 100644 --- a/dockerfiles/operator/Dockerfile +++ b/dockerfiles/operator/Dockerfile @@ -17,7 +17,7 @@ RUN mkdir /templates WORKDIR /log-config COPY java/operator/org.eclipse.theia.cloud.defaultoperator/log4j2.xml . WORKDIR /operator -COPY --from=builder /operator/operator/org.eclipse.theia.cloud.defaultoperator/target/defaultoperator-1.3.0-SNAPSHOT-jar-with-dependencies.jar . +COPY --from=builder /operator/operator/org.eclipse.theia.cloud.defaultoperator/target/defaultoperator-1.3.0-SNAPSHOT.jar . # to get more debug information from the kubernetes client itself, add -Dorg.slf4j.simpleLogger.defaultLogLevel=DEBUG below -ENTRYPOINT [ "java", "-Dlog4j2.configurationFile=/log-config/log4j2.xml", "-jar", "./defaultoperator-1.3.0-SNAPSHOT-jar-with-dependencies.jar" ] +ENTRYPOINT [ "java", "-Dlog4j2.configurationFile=/log-config/log4j2.xml", "-jar", "./defaultoperator-1.3.0-SNAPSHOT.jar" ] CMD [ "" ] diff --git a/java/common/maven-conf/pom.xml b/java/common/maven-conf/pom.xml index 0e17a77e2..a770fca94 100644 --- a/java/common/maven-conf/pom.xml +++ b/java/common/maven-conf/pom.xml @@ -18,7 +18,7 @@ io.quarkus.platform 7.0.0 2.25.1 - 3.7.1 + 3.6.0 3.14.0 3.5.3 2.2.5 diff --git a/java/operator/org.eclipse.theia.cloud.defaultoperator/pom.xml b/java/operator/org.eclipse.theia.cloud.defaultoperator/pom.xml index e821f4a03..61418a504 100644 --- a/java/operator/org.eclipse.theia.cloud.defaultoperator/pom.xml +++ b/java/operator/org.eclipse.theia.cloud.defaultoperator/pom.xml @@ -46,6 +46,11 @@ kubernetes-httpclient-okhttp ${kubernetes-client.version} + + io.fabric8 + openshift-client + ${kubernetes-client.version} + org.apache.logging.log4j log4j-api @@ -83,26 +88,36 @@ - maven-assembly-plugin - ${maven-assembly-plugin.version} - - - - - org.eclipse.theia.cloud.defaultoperator.DefaultTheiaCloudOperatorLauncher - - - - jar-with-dependencies - - + org.apache.maven.plugins + maven-shade-plugin + ${maven-shade-plugin.version} - make-assembly package - single + shade + + + + org.eclipse.theia.cloud.defaultoperator.DefaultTheiaCloudOperatorLauncher + + + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + false + diff --git a/java/operator/org.eclipse.theia.cloud.operator/pom.xml b/java/operator/org.eclipse.theia.cloud.operator/pom.xml index e47a6ebdc..b83c744b9 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/pom.xml +++ b/java/operator/org.eclipse.theia.cloud.operator/pom.xml @@ -41,6 +41,11 @@ kubernetes-httpclient-okhttp ${kubernetes-client.version} + + io.fabric8 + openshift-client + ${kubernetes-client.version} + org.apache.logging.log4j log4j-api @@ -64,6 +69,13 @@ ${slf4j.version} + + org.junit.jupiter + junit-jupiter + ${junit-jupiter.version} + test + + @@ -76,6 +88,17 @@ 21 + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire-plugin.version} + + + **/*Test.java + **/*Tests.java + + + diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/TheiaCloudOperatorArguments.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/TheiaCloudOperatorArguments.java index bc120bd75..0d473047b 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/TheiaCloudOperatorArguments.java +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/TheiaCloudOperatorArguments.java @@ -22,7 +22,7 @@ public class TheiaCloudOperatorArguments { public enum CloudProvider { - K8S, MINIKUBE + K8S, MINIKUBE, OPENSHIFT } public enum BandwidthLimiter { diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/di/AbstractTheiaCloudOperatorModule.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/di/AbstractTheiaCloudOperatorModule.java index 5d0a3354e..4a2f48c15 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/di/AbstractTheiaCloudOperatorModule.java +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/di/AbstractTheiaCloudOperatorModule.java @@ -45,6 +45,9 @@ import org.eclipse.theia.cloud.operator.pv.DefaultPersistentVolumeCreator; import org.eclipse.theia.cloud.operator.pv.MinikubePersistentVolumeCreator; import org.eclipse.theia.cloud.operator.pv.PersistentVolumeCreator; +import org.eclipse.theia.cloud.operator.routing.IngressRoutingStrategy; +import org.eclipse.theia.cloud.operator.routing.OpenShiftRouteRoutingStrategy; +import org.eclipse.theia.cloud.operator.routing.SessionRoutingStrategy; import org.eclipse.theia.cloud.operator.replacements.DefaultDeploymentTemplateReplacements; import org.eclipse.theia.cloud.operator.replacements.DefaultPersistentVolumeTemplateReplacements; import org.eclipse.theia.cloud.operator.replacements.DeploymentTemplateReplacements; @@ -70,6 +73,7 @@ protected void configure() { bind(BandwidthLimiter.class).to(bindBandwidthLimiter()).in(Singleton.class); bind(PersistentVolumeCreator.class).to(bindPersistentVolumeHandler()).in(Singleton.class); + bind(SessionRoutingStrategy.class).to(bindSessionRoutingStrategy()).in(Singleton.class); bind(IngressPathProvider.class).to(bindIngressPathProvider()).in(Singleton.class); bind(DeploymentTemplateReplacements.class).to(bindDeploymentTemplateReplacements()).in(Singleton.class); bind(PersistentVolumeTemplateReplacements.class).to(bindPersistentVolumeTemplateReplacements()) @@ -105,6 +109,17 @@ protected Class bindPersistentVolumeHandler() } } + protected Class bindSessionRoutingStrategy() { + switch (arguments.getCloudProvider()) { + case OPENSHIFT: + return OpenShiftRouteRoutingStrategy.class; + case K8S: + case MINIKUBE: + default: + return IngressRoutingStrategy.class; + } + } + protected Class bindIngressPathProvider() { return IngressPathProviderImpl.class; } diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/AddedHandlerUtil.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/AddedHandlerUtil.java index 50c3dea73..78fbcaead 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/AddedHandlerUtil.java +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/AddedHandlerUtil.java @@ -18,10 +18,11 @@ import static org.eclipse.theia.cloud.common.util.LogMessageUtil.formatLogMessage; import static org.eclipse.theia.cloud.common.util.LogMessageUtil.formatMetric; -import static org.eclipse.theia.cloud.operator.util.TheiaCloudDeploymentUtil.HOST_PROTOCOL; +import static org.eclipse.theia.cloud.operator.util.TheiaCloudDeploymentUtil.normalizeExternalBaseUrl; import java.io.IOException; -import java.net.URL; +import java.net.HttpURLConnection; +import java.net.URI; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; @@ -82,7 +83,7 @@ public final class AddedHandlerUtil { public static final String OAUTH2_PROXY_CONFIGMAP_NAME = "oauth2-proxy-config"; - public static final String CONFIGMAP_DATA_PLACEHOLDER_HOST = "https://placeholder"; + public static final String CONFIGMAP_DATA_PLACEHOLDER_BASE_URL = "placeholder-url"; public static final String CONFIGMAP_DATA_PLACEHOLDER_PORT = "placeholder-port"; public static final String FILENAME_AUTHENTICATED_EMAILS_LIST = "authenticated-emails-list"; @@ -125,12 +126,12 @@ private AddedHandlerUtil() { } public static void updateProxyConfigMap(NamespacedKubernetesClient client, String namespace, ConfigMap configMap, - String host, int port) { + String externalBaseUrl, int port) { ConfigMap templateConfigMap = client.configMaps().inNamespace(namespace).withName(OAUTH2_PROXY_CONFIGMAP_NAME) .get(); Map data = new LinkedHashMap<>(templateConfigMap.getData()); data.put(OAUTH2_PROXY_CFG, data.get(OAUTH2_PROXY_CFG)// - .replace(CONFIGMAP_DATA_PLACEHOLDER_HOST, HOST_PROTOCOL + host)// + .replace(CONFIGMAP_DATA_PLACEHOLDER_BASE_URL, normalizeExternalBaseUrl(externalBaseUrl))// .replace(CONFIGMAP_DATA_PLACEHOLDER_PORT, String.valueOf(port))); configMap.setData(data); } @@ -160,21 +161,22 @@ public static void updateSessionURLAsync(SessionResourceClient sessions, Session /* silent */ } - HttpsURLConnection connection; + HttpURLConnection connection; try { - connection = (HttpsURLConnection) new URL(HOST_PROTOCOL + url).openConnection(); - } catch (IOException e) { + connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); + } catch (IllegalArgumentException | IOException e) { LOGGER.error(formatLogMessage(correlationId, "Error while checking session availability."), e); continue; } int code; try { - connection.setHostnameVerifier(ALL_GOOD_HOSTNAME_VERIFIER); - SSLContext sc = SSLContext.getInstance("SSL"); - sc.init(null, new TrustManager[] { TRUST_ALL_MANAGER }, new java.security.SecureRandom()); - HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); - connection.setSSLSocketFactory(sc.getSocketFactory()); + if (connection instanceof HttpsURLConnection httpsConn) { + httpsConn.setHostnameVerifier(ALL_GOOD_HOSTNAME_VERIFIER); + SSLContext sc = SSLContext.getInstance("SSL"); + sc.init(null, new TrustManager[] { TRUST_ALL_MANAGER }, new java.security.SecureRandom()); + httpsConn.setSSLSocketFactory(sc.getSocketFactory()); + } connection.connect(); code = connection.getResponseCode(); } catch (IOException e) { diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/appdef/EagerStartAppDefinitionAddedHandler.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/appdef/EagerStartAppDefinitionAddedHandler.java index 97db81666..57a67061e 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/appdef/EagerStartAppDefinitionAddedHandler.java +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/appdef/EagerStartAppDefinitionAddedHandler.java @@ -34,13 +34,12 @@ import org.eclipse.theia.cloud.operator.TheiaCloudOperatorArguments; import org.eclipse.theia.cloud.operator.bandwidth.BandwidthLimiter; import org.eclipse.theia.cloud.operator.handler.AddedHandlerUtil; -import org.eclipse.theia.cloud.operator.ingress.IngressPathProvider; import org.eclipse.theia.cloud.operator.replacements.DeploymentTemplateReplacements; +import org.eclipse.theia.cloud.operator.routing.SessionRoutingStrategy; import org.eclipse.theia.cloud.operator.util.JavaResourceUtil; import org.eclipse.theia.cloud.operator.util.K8sUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudConfigMapUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudDeploymentUtil; -import org.eclipse.theia.cloud.operator.util.TheiaCloudIngressUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudServiceUtil; import com.google.inject.Inject; @@ -69,7 +68,7 @@ public class EagerStartAppDefinitionAddedHandler implements AppDefinitionHandler protected TheiaCloudOperatorArguments arguments; @Inject - protected IngressPathProvider ingressPathProvider; + protected SessionRoutingStrategy routingStrategy; @Inject protected BandwidthLimiter bandwidthLimiter; @@ -86,15 +85,14 @@ public boolean appDefinitionAdded(AppDefinition appDefinition, String correlatio String appDefinitionResourceUID = appDefinition.getMetadata().getUid(); int instances = spec.getMinInstances(); - /* Create ingress if not existing */ - if (!TheiaCloudIngressUtil.checkForExistingIngressAndAddOwnerReferencesIfMissing(client.kubernetes(), - client.namespace(), appDefinition, correlationId)) { + /* Check routing resource exists */ + if (!routingStrategy.ensureRoutingResourceExists(appDefinition, correlationId)) { LOGGER.error(formatLogMessage(correlationId, - "Expected ingress '" + spec.getIngressname() + "' for app definition '" + appDefinitionResourceName + "Expected routing resource '" + spec.getIngressname() + "' for app definition '" + appDefinitionResourceName + "' does not exist. Abort handling app definition.")); return false; } else { - LOGGER.trace(formatLogMessage(correlationId, "Ingress available already")); + LOGGER.trace(formatLogMessage(correlationId, "Routing resource available already")); } /* Get existing services for this app definition */ @@ -249,9 +247,9 @@ protected void createAndApplyProxyConfigMap(NamespacedKubernetesClient client, S K8sUtil.loadAndCreateConfigMapWithOwnerReference(client, namespace, correlationId, configMapYaml, AppDefinition.API, AppDefinition.KIND, appDefinitionResourceName, appDefinitionResourceUID, 0, labelsToAdd, configMap -> { - String host = arguments.getInstancesHost() + ingressPathProvider.getPath(appDefinition, instance); + String sessionUrl = routingStrategy.getSessionURL(appDefinition, instance); int port = appDefinition.getSpec().getPort(); - AddedHandlerUtil.updateProxyConfigMap(client, namespace, configMap, host, port); + AddedHandlerUtil.updateProxyConfigMap(client, namespace, configMap, sessionUrl, port); }); } diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/appdef/LazyStartAppDefinitionHandler.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/appdef/LazyStartAppDefinitionHandler.java index 1597d0e4e..ae3132ed1 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/appdef/LazyStartAppDefinitionHandler.java +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/appdef/LazyStartAppDefinitionHandler.java @@ -28,8 +28,7 @@ import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinition; import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinitionSpec; import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinitionStatus; -import org.eclipse.theia.cloud.operator.ingress.IngressPathProvider; -import org.eclipse.theia.cloud.operator.util.TheiaCloudIngressUtil; +import org.eclipse.theia.cloud.operator.routing.SessionRoutingStrategy; import com.google.inject.Inject; @@ -41,7 +40,7 @@ public class LazyStartAppDefinitionHandler implements AppDefinitionHandler { protected TheiaCloudClient client; @Inject - protected IngressPathProvider ingressPathProvider; + protected SessionRoutingStrategy routingStrategy; @Override public boolean appDefinitionAdded(AppDefinition appDefinition, String correlationId) { @@ -95,19 +94,18 @@ protected boolean doAppDefinitionAdded(AppDefinition appDefinition, String corre AppDefinitionSpec spec = appDefinition.getSpec(); String appDefinitionResourceName = appDefinition.getMetadata().getName(); - /* Create ingress if not existing */ - if (!TheiaCloudIngressUtil.checkForExistingIngressAndAddOwnerReferencesIfMissing(client.kubernetes(), - client.namespace(), appDefinition, correlationId)) { + /* Check routing resource exists */ + if (!routingStrategy.ensureRoutingResourceExists(appDefinition, correlationId)) { LOGGER.error(formatLogMessage(correlationId, - "Expected ingress '" + spec.getIngressname() + "' for app definition '" + appDefinitionResourceName + "Expected routing resource '" + spec.getIngressname() + "' for app definition '" + appDefinitionResourceName + "' does not exist. Abort handling app definition.")); client.appDefinitions().updateStatus(correlationId, appDefinition, s -> { s.setOperatorStatus(OperatorStatus.ERROR); - s.setOperatorMessage("Ingress does not exist."); + s.setOperatorMessage("Routing resource does not exist."); }); return false; } else { - LOGGER.trace(formatLogMessage(correlationId, "Ingress available already")); + LOGGER.trace(formatLogMessage(correlationId, "Routing resource available already")); } client.appDefinitions().updateStatus(correlationId, appDefinition, s -> { diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/session/EagerSessionHandler.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/session/EagerSessionHandler.java index fb9b5924a..8b6b86ef2 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/session/EagerSessionHandler.java +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/session/EagerSessionHandler.java @@ -37,7 +37,7 @@ import org.eclipse.theia.cloud.common.util.LabelsUtil; import org.eclipse.theia.cloud.operator.TheiaCloudOperatorArguments; import org.eclipse.theia.cloud.operator.handler.AddedHandlerUtil; -import org.eclipse.theia.cloud.operator.ingress.IngressPathProvider; +import org.eclipse.theia.cloud.operator.routing.SessionRoutingStrategy; import org.eclipse.theia.cloud.operator.util.K8sUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudConfigMapUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudDeploymentUtil; @@ -49,13 +49,6 @@ import io.fabric8.kubernetes.api.model.Pod; import io.fabric8.kubernetes.api.model.Service; import io.fabric8.kubernetes.api.model.ServiceList; -import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressPath; -import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressRuleValue; -import io.fabric8.kubernetes.api.model.networking.v1.Ingress; -import io.fabric8.kubernetes.api.model.networking.v1.IngressBackend; -import io.fabric8.kubernetes.api.model.networking.v1.IngressRule; -import io.fabric8.kubernetes.api.model.networking.v1.IngressServiceBackend; -import io.fabric8.kubernetes.api.model.networking.v1.ServiceBackendPort; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.NamespacedKubernetesClient; import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable; @@ -76,10 +69,10 @@ public class EagerSessionHandler implements SessionHandler { private TheiaCloudClient client; @Inject - protected IngressPathProvider ingressPathProvider; + protected TheiaCloudOperatorArguments arguments; @Inject - protected TheiaCloudOperatorArguments arguments; + protected SessionRoutingStrategy routingStrategy; @Override public boolean sessionAdded(Session session, String correlationId) { @@ -101,14 +94,11 @@ public boolean sessionAdded(Session session, String correlationId) { String appDefinitionResourceName = appDefinition.get().getMetadata().getName(); String appDefinitionResourceUID = appDefinition.get().getMetadata().getUid(); - int port = appDefinition.get().getSpec().getPort(); - /* find ingress */ - Optional ingress = K8sUtil.getExistingIngress(client.kubernetes(), client.namespace(), - appDefinitionResourceName, appDefinitionResourceUID); - if (ingress.isEmpty()) { - LOGGER.error( - formatLogMessage(correlationId, "No Ingress for app definition " + appDefinitionID + " found.")); + /* check routing resource exists */ + if (!routingStrategy.ensureRoutingResourceExists(appDefinition.get(), correlationId)) { + LOGGER.error(formatLogMessage(correlationId, + "No routing resource for app definition " + appDefinitionID + " found.")); return false; } @@ -248,14 +238,19 @@ public boolean sessionAdded(Session session, String correlationId) { } } - /* adjust the ingress */ + /* adjust the routing */ String host; try { - host = updateIngress(ingress, serviceToUse, appDefinitionID, instance, port, appDefinition.get(), + host = routingStrategy.addSessionRouting(session, appDefinition.get(), serviceToUse.get(), instance, correlationId); } catch (KubernetesClientException e) { LOGGER.error(formatLogMessage(correlationId, - "Error while editing ingress " + ingress.get().getMetadata().getName()), e); + "Error while updating routing for session " + session.getMetadata().getName()), e); + return false; + } + if (host == null) { + LOGGER.error(formatLogMessage(correlationId, + "Failed to add routing for session " + session.getMetadata().getName())); return false; } @@ -342,43 +337,6 @@ protected synchronized Entry, Boolean> reserveInternalService( return JavaUtil.tuple(serviceToUse, false); } - protected synchronized String updateIngress(Optional ingress, Optional serviceToUse, - String appDefinitionID, int instance, int port, AppDefinition appDefinition, String correlationId) { - final String host = arguments.getInstancesHost(); - String path = ingressPathProvider.getPath(appDefinition, instance); - client.ingresses().edit(correlationId, ingress.get().getMetadata().getName(), - ingressToUpdate -> addIngressRule(ingressToUpdate, serviceToUse.get(), host, port, path)); - return host + path + "/"; - } - - protected Ingress addIngressRule(Ingress ingress, Service serviceToUse, String host, int port, String path) { - IngressRule ingressRule = new IngressRule(); - ingress.getSpec().getRules().add(ingressRule); - - ingressRule.setHost(host); - - HTTPIngressRuleValue http = new HTTPIngressRuleValue(); - ingressRule.setHttp(http); - - HTTPIngressPath httpIngressPath = new HTTPIngressPath(); - http.getPaths().add(httpIngressPath); - httpIngressPath.setPath(path + arguments.getIngressPathSuffix()); - httpIngressPath.setPathType(AddedHandlerUtil.INGRESS_PATH_TYPE); - - IngressBackend ingressBackend = new IngressBackend(); - httpIngressPath.setBackend(ingressBackend); - - IngressServiceBackend ingressServiceBackend = new IngressServiceBackend(); - ingressBackend.setService(ingressServiceBackend); - ingressServiceBackend.setName(serviceToUse.getMetadata().getName()); - - ServiceBackendPort serviceBackendPort = new ServiceBackendPort(); - ingressServiceBackend.setPort(serviceBackendPort); - serviceBackendPort.setNumber(port); - - return ingress; - } - @Override public boolean sessionDeleted(Session session, String correlationId) { SessionSpec spec = session.getSpec(); @@ -500,29 +458,26 @@ public boolean sessionDeleted(Session session, String correlationId) { } } Integer instance = TheiaCloudServiceUtil.getId(correlationId, appDefinition.get(), cleanedService); - - // Cleanup ingress rule to prevent further traffic to the session pod - Optional ingress = K8sUtil.getExistingIngress(client.kubernetes(), client.namespace(), - appDefinition.get().getMetadata().getName(), appDefinition.get().getMetadata().getUid()); - if (ingress.isEmpty()) { - LOGGER.error( - formatLogMessage(correlationId, "No Ingress for app definition " + appDefinitionID + " found.")); + if (instance == null) { + LOGGER.error(formatLogMessage(correlationId, "Error while getting instance from Service")); return false; } - // Remove ingress rule + + // Cleanup routing rule to prevent further traffic to the session pod try { - removeIngressRule(correlationId, appDefinition.get(), ingress.get(), instance); + boolean routingCleanupSuccess = routingStrategy.removeSessionRouting(session, appDefinition.get(), instance, + correlationId); + if (!routingCleanupSuccess) { + LOGGER.error(formatLogMessage(correlationId, "Failed to remove routing for session " + spec.getName())); + return false; + } } catch (KubernetesClientException e) { - LOGGER.error(formatLogMessage(correlationId, - "Error while editing ingress " + ingress.get().getMetadata().getName()), e); + LOGGER.error(formatLogMessage(correlationId, "Error while removing routing for session " + spec.getName()), + e); return false; } // Remove owner reference from deployment - if (instance == null) { - LOGGER.error(formatLogMessage(correlationId, "Error while getting instance from Service")); - return false; - } final String deploymentName = TheiaCloudDeploymentUtil.getDeploymentName(appDefinition.get(), instance); try { client.kubernetes().apps().deployments().withName(deploymentName).edit(deployment -> TheiaCloudHandlerUtil @@ -564,20 +519,4 @@ public boolean sessionDeleted(Session session, String correlationId) { return true; } - protected synchronized void removeIngressRule(String correlationId, AppDefinition appDefinition, Ingress ingress, - Integer instance) throws KubernetesClientException { - final String ruleHttpPath = ingressPathProvider.getPath(appDefinition, instance) - + arguments.getIngressPathSuffix(); - client.ingresses().resource(ingress.getMetadata().getName()).edit(ingressToUpdate -> { - ingressToUpdate.getSpec().getRules().removeIf(rule -> { - if (rule.getHttp() == null) { - LOGGER.warn(formatLogMessage(correlationId, - "Error while removing ingress rule: The rule's HTTP block is null")); - return false; - } - return rule.getHttp().getPaths().stream().anyMatch(httpPath -> ruleHttpPath.equals(httpPath.getPath())); - }); - return ingressToUpdate; - }); - } } diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/session/LazySessionHandler.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/session/LazySessionHandler.java index 35898740b..fd8b50441 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/session/LazySessionHandler.java +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/handler/session/LazySessionHandler.java @@ -22,7 +22,6 @@ import java.io.IOException; import java.net.URISyntaxException; import java.time.Instant; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -46,12 +45,11 @@ import org.eclipse.theia.cloud.operator.TheiaCloudOperatorArguments; import org.eclipse.theia.cloud.operator.bandwidth.BandwidthLimiter; import org.eclipse.theia.cloud.operator.handler.AddedHandlerUtil; -import org.eclipse.theia.cloud.operator.ingress.IngressPathProvider; import org.eclipse.theia.cloud.operator.replacements.DeploymentTemplateReplacements; +import org.eclipse.theia.cloud.operator.routing.SessionRoutingStrategy; import org.eclipse.theia.cloud.operator.util.JavaResourceUtil; import org.eclipse.theia.cloud.operator.util.K8sUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudConfigMapUtil; -import org.eclipse.theia.cloud.operator.util.TheiaCloudIngressUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudK8sUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudPersistentVolumeUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudServiceUtil; @@ -66,13 +64,6 @@ import io.fabric8.kubernetes.api.model.Volume; import io.fabric8.kubernetes.api.model.VolumeMount; import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressPath; -import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressRuleValue; -import io.fabric8.kubernetes.api.model.networking.v1.Ingress; -import io.fabric8.kubernetes.api.model.networking.v1.IngressBackend; -import io.fabric8.kubernetes.api.model.networking.v1.IngressRule; -import io.fabric8.kubernetes.api.model.networking.v1.IngressServiceBackend; -import io.fabric8.kubernetes.api.model.networking.v1.ServiceBackendPort; import io.fabric8.kubernetes.client.KubernetesClientException; public class LazySessionHandler implements SessionHandler { @@ -80,14 +71,14 @@ public class LazySessionHandler implements SessionHandler { private static final Logger LOGGER = LogManager.getLogger(LazySessionHandler.class); protected static final String USER_DATA = "user-data"; - @Inject - protected IngressPathProvider ingressPathProvider; @Inject protected TheiaCloudOperatorArguments arguments; @Inject protected BandwidthLimiter bandwidthLimiter; @Inject protected DeploymentTemplateReplacements deploymentReplacements; + @Inject + protected SessionRoutingStrategy routingStrategy; @Inject protected TheiaCloudClient client; @@ -178,11 +169,10 @@ protected boolean doSessionAdded(Session session, String correlationId) { return false; } - Optional ingress = getIngress(appDefinition, correlationId); - if (ingress.isEmpty()) { + if (!routingStrategy.ensureRoutingResourceExists(appDefinition, correlationId)) { client.sessions().updateStatus(correlationId, session, s -> { s.setOperatorStatus(OperatorStatus.ERROR); - s.setOperatorMessage("Ingress not available."); + s.setOperatorMessage("Routing resource not available."); }); return false; } @@ -268,16 +258,25 @@ protected boolean doSessionAdded(Session session, String correlationId) { createAndApplyDeployment(correlationId, sessionResourceName, sessionResourceUID, session, appDefinition, storageName, arguments.isUseKeycloak(), labelsToAdd); - /* adjust the ingress */ + /* adjust the routing */ String host; try { - host = updateIngress(ingress, serviceToUse, session, appDefinition, correlationId); + host = routingStrategy.addSessionRouting(session, appDefinition, serviceToUse.get(), correlationId); } catch (KubernetesClientException e) { LOGGER.error(formatLogMessage(correlationId, - "Error while editing ingress " + ingress.get().getMetadata().getName()), e); + "Error while updating routing for session " + session.getMetadata().getName()), e); client.sessions().updateStatus(correlationId, session, s -> { s.setOperatorStatus(OperatorStatus.ERROR); - s.setOperatorMessage("Failed to edit ingress"); + s.setOperatorMessage("Failed to update routing"); + }); + return false; + } + if (host == null) { + LOGGER.error(formatLogMessage(correlationId, + "Failed to add routing for session " + session.getMetadata().getName())); + client.sessions().updateStatus(correlationId, session, s -> { + s.setOperatorStatus(OperatorStatus.ERROR); + s.setOperatorMessage("Failed to update routing"); }); return false; } @@ -349,18 +348,6 @@ protected boolean hasMaxSessionsReached(Session session, String correlationId) { return false; } - protected Optional getIngress(AppDefinition appDefinition, String correlationId) { - String appDefinitionResourceName = appDefinition.getMetadata().getName(); - String appDefinitionResourceUID = appDefinition.getMetadata().getUid(); - Optional ingress = K8sUtil.getExistingIngress(client.kubernetes(), client.namespace(), - appDefinitionResourceName, appDefinitionResourceUID); - if (ingress.isEmpty()) { - LOGGER.error(formatLogMessage(correlationId, - "No Ingress for app definition " + appDefinition.getSpec().getName() + " found.")); - } - return ingress; - } - protected Optional getStorageName(Session session, String correlationId) { if (session.getSpec().isEphemeral()) { return Optional.empty(); @@ -461,9 +448,9 @@ protected void createAndApplyProxyConfigMap(String correlationId, String session K8sUtil.loadAndCreateConfigMapWithOwnerReference(client.kubernetes(), client.namespace(), correlationId, configMapYaml, Session.API, Session.KIND, sessionResourceName, sessionResourceUID, 0, labelsToAdd, configMap -> { - String host = arguments.getInstancesHost() + ingressPathProvider.getPath(appDefinition, session); + String sessionUrl = routingStrategy.getSessionURL(appDefinition, session); int port = appDefinition.getSpec().getPort(); - AddedHandlerUtil.updateProxyConfigMap(client.kubernetes(), client.namespace(), configMap, host, + AddedHandlerUtil.updateProxyConfigMap(client.kubernetes(), client.namespace(), configMap, sessionUrl, port); }); } @@ -528,49 +515,6 @@ protected void addVolumeClaim(Deployment deployment, String pvcName, AppDefiniti volumeMount.setMountPath(TheiaCloudPersistentVolumeUtil.getMountPath(appDefinition)); } - protected synchronized String updateIngress(Optional ingress, Optional serviceToUse, - Session session, AppDefinition appDefinition, String correlationId) { - List hostsToAdd = new ArrayList<>(); - final String instancesHost = arguments.getInstancesHost(); - hostsToAdd.add(instancesHost); - List ingressHostnamePrefixes = appDefinition.getSpec().getIngressHostnamePrefixes() != null - ? appDefinition.getSpec().getIngressHostnamePrefixes() - : Collections.emptyList(); - for (String prefix : ingressHostnamePrefixes) { - hostsToAdd.add(prefix + instancesHost); - } - String path = ingressPathProvider.getPath(appDefinition, session); - client.ingresses().edit(correlationId, ingress.get().getMetadata().getName(), ingressToUpdate -> { - for (String host : hostsToAdd) { - IngressRule ingressRule = new IngressRule(); - ingressToUpdate.getSpec().getRules().add(ingressRule); - - ingressRule.setHost(host); - - HTTPIngressRuleValue http = new HTTPIngressRuleValue(); - ingressRule.setHttp(http); - - HTTPIngressPath httpIngressPath = new HTTPIngressPath(); - http.getPaths().add(httpIngressPath); - httpIngressPath.setPath(path + arguments.getIngressPathSuffix()); - httpIngressPath.setPathType(AddedHandlerUtil.INGRESS_PATH_TYPE); - - IngressBackend ingressBackend = new IngressBackend(); - httpIngressPath.setBackend(ingressBackend); - - IngressServiceBackend ingressServiceBackend = new IngressServiceBackend(); - ingressBackend.setService(ingressServiceBackend); - ingressServiceBackend.setName(serviceToUse.get().getMetadata().getName()); - - ServiceBackendPort serviceBackendPort = new ServiceBackendPort(); - ingressServiceBackend.setPort(serviceBackendPort); - serviceBackendPort.setNumber(appDefinition.getSpec().getPort()); - } - - }); - return instancesHost + path + "/"; - } - @Override public synchronized boolean sessionDeleted(Session session, String correlationId) { try { @@ -602,42 +546,16 @@ protected boolean doSessionDeleted(Session session, String correlationId) { AppDefinition appDefinition = optionalAppDefinition.get(); - /* find ingress */ - String appDefinitionResourceName = appDefinition.getMetadata().getName(); - String appDefinitionResourceUID = appDefinition.getMetadata().getUid(); - Optional ingress = K8sUtil.getExistingIngress(client.kubernetes(), client.namespace(), - appDefinitionResourceName, appDefinitionResourceUID); - if (ingress.isEmpty()) { - LOGGER.error( - formatLogMessage(correlationId, "No Ingress for app definition " + appDefinitionID + " found.")); - return false; - } - - String path = ingressPathProvider.getPath(appDefinition, session); - - // Build list of all hosts that were used during session creation - List hostsToClean = new ArrayList<>(); - final String instancesHost = arguments.getInstancesHost(); - hostsToClean.add(instancesHost); - List ingressHostnamePrefixes = appDefinition.getSpec().getIngressHostnamePrefixes(); - if (ingressHostnamePrefixes != null) { - for (String prefix : ingressHostnamePrefixes) { - hostsToClean.add(prefix + instancesHost); - } - } - - // Remove ingress rules for all hosts - boolean cleanupSuccess = TheiaCloudIngressUtil.removeIngressRules(client.kubernetes(), - client.namespace(), ingress.get(), path, arguments.getIngressPathSuffix(), hostsToClean, correlationId); + boolean cleanupSuccess = routingStrategy.removeSessionRouting(session, appDefinition, correlationId); if (!cleanupSuccess) { - LOGGER.error(formatLogMessage(correlationId, - "Failed to remove ingress rules for session " + sessionSpec.getName())); + LOGGER.error( + formatLogMessage(correlationId, "Failed to remove routing for session " + sessionSpec.getName())); return false; } LOGGER.info(formatLogMessage(correlationId, - "Successfully cleaned up ingress rules for session " + sessionSpec.getName())); + "Successfully cleaned up routing for session " + sessionSpec.getName())); return true; } } diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/replacements/DefaultDeploymentTemplateReplacements.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/replacements/DefaultDeploymentTemplateReplacements.java index 33368bc8c..d74ee3a19 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/replacements/DefaultDeploymentTemplateReplacements.java +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/replacements/DefaultDeploymentTemplateReplacements.java @@ -28,7 +28,7 @@ import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinition; import org.eclipse.theia.cloud.common.k8s.resource.session.Session; import org.eclipse.theia.cloud.operator.TheiaCloudOperatorArguments; -import org.eclipse.theia.cloud.operator.ingress.IngressPathProvider; +import org.eclipse.theia.cloud.operator.routing.SessionRoutingStrategy; import org.eclipse.theia.cloud.operator.util.TheiaCloudConfigMapUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudDeploymentUtil; import org.eclipse.theia.cloud.operator.util.TheiaCloudHandlerUtil; @@ -73,7 +73,7 @@ public class DefaultDeploymentTemplateReplacements implements DeploymentTemplate protected TheiaCloudOperatorArguments arguments; @Inject - protected IngressPathProvider ingressPathProvider; + protected SessionRoutingStrategy routingStrategy; @Override public Map getReplacements(String namespace, AppDefinition appDefinition, int instance) { @@ -114,15 +114,15 @@ protected Map getAppDefinitionData(AppDefinition appDefinition) protected Map getEnvironmentVariables(AppDefinition appDefinition, Session session) { Map environmentVariables = getEnvironmentVariables(appDefinition, Optional.of(session)); - environmentVariables.put(PLACEHOLDER_ENV_SESSION_URL, TheiaCloudDeploymentUtil - .getSessionURL(arguments.getInstancesHost(), ingressPathProvider, appDefinition, session)); + environmentVariables.put(PLACEHOLDER_ENV_SESSION_URL, + routingStrategy.getSessionURL(appDefinition, session)); return environmentVariables; } protected Map getEnvironmentVariables(AppDefinition appDefinition, int instance) { Map environmentVariables = getEnvironmentVariables(appDefinition, Optional.empty()); - environmentVariables.put(PLACEHOLDER_ENV_SESSION_URL, TheiaCloudDeploymentUtil - .getSessionURL(arguments.getInstancesHost(), ingressPathProvider, appDefinition, instance)); + environmentVariables.put(PLACEHOLDER_ENV_SESSION_URL, + routingStrategy.getSessionURL(appDefinition, instance)); return environmentVariables; } diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/routing/IngressRoutingStrategy.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/routing/IngressRoutingStrategy.java new file mode 100644 index 000000000..6488e7e5c --- /dev/null +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/routing/IngressRoutingStrategy.java @@ -0,0 +1,232 @@ +/******************************************************************************** + * Copyright (C) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +package org.eclipse.theia.cloud.operator.routing; + +import static org.eclipse.theia.cloud.common.util.LogMessageUtil.formatLogMessage; +import static org.eclipse.theia.cloud.operator.util.TheiaCloudDeploymentUtil.HOST_PROTOCOL; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.theia.cloud.common.k8s.client.TheiaCloudClient; +import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinition; +import org.eclipse.theia.cloud.common.k8s.resource.session.Session; +import org.eclipse.theia.cloud.operator.TheiaCloudOperatorArguments; +import org.eclipse.theia.cloud.operator.handler.AddedHandlerUtil; +import org.eclipse.theia.cloud.operator.ingress.IngressPathProvider; +import org.eclipse.theia.cloud.operator.util.K8sUtil; +import org.eclipse.theia.cloud.operator.util.TheiaCloudIngressUtil; + +import com.google.inject.Inject; + +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressPath; +import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressRuleValue; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.api.model.networking.v1.IngressBackend; +import io.fabric8.kubernetes.api.model.networking.v1.IngressRule; +import io.fabric8.kubernetes.api.model.networking.v1.IngressServiceBackend; +import io.fabric8.kubernetes.api.model.networking.v1.ServiceBackendPort; + +/** + * Ingress-based implementation of {@link SessionRoutingStrategy} for vanilla Kubernetes clusters. + *

+ * This strategy manages IngressRules on a shared Ingress resource. Rules are added when sessions start and removed when + * sessions are deleted. + */ +public class IngressRoutingStrategy implements SessionRoutingStrategy { + + private static final Logger LOGGER = LogManager.getLogger(IngressRoutingStrategy.class); + + @Inject + private TheiaCloudClient client; + + @Inject + private IngressPathProvider ingressPathProvider; + + @Inject + private TheiaCloudOperatorArguments arguments; + + @Override + public boolean ensureRoutingResourceExists(AppDefinition appDefinition, String correlationId) { + return TheiaCloudIngressUtil.checkForExistingIngressAndAddOwnerReferencesIfMissing(client.kubernetes(), + client.namespace(), appDefinition, correlationId); + } + + @Override + public synchronized String addSessionRouting(Session session, AppDefinition appDefinition, Service service, + String correlationId) { + Optional ingress = getIngress(appDefinition, correlationId); + if (ingress.isEmpty()) { + LOGGER.error(formatLogMessage(correlationId, + "No Ingress for app definition " + appDefinition.getSpec().getName() + " found.")); + return null; + } + String path = ingressPathProvider.getPath(appDefinition, session); + return addIngressRulesForSession(ingress.get(), service, appDefinition, path, correlationId); + } + + @Override + public synchronized String addSessionRouting(Session session, AppDefinition appDefinition, Service service, + int instance, String correlationId) { + Optional ingress = getIngress(appDefinition, correlationId); + if (ingress.isEmpty()) { + LOGGER.error(formatLogMessage(correlationId, + "No Ingress for app definition " + appDefinition.getSpec().getName() + " found.")); + return null; + } + String path = ingressPathProvider.getPath(appDefinition, instance); + addSingleIngressRule(ingress.get(), service, appDefinition.getSpec().getPort(), path, correlationId); + return HOST_PROTOCOL + arguments.getInstancesHost() + path + "/"; + } + + @Override + public synchronized boolean removeSessionRouting(Session session, AppDefinition appDefinition, + String correlationId) { + Optional ingress = getIngress(appDefinition, correlationId); + if (ingress.isEmpty()) { + LOGGER.error(formatLogMessage(correlationId, + "No Ingress for app definition " + appDefinition.getSpec().getName() + " found.")); + return false; + } + + String path = ingressPathProvider.getPath(appDefinition, session); + + List hostsToClean = new ArrayList<>(); + final String instancesHost = arguments.getInstancesHost(); + hostsToClean.add(instancesHost); + List ingressHostnamePrefixes = appDefinition.getSpec().getIngressHostnamePrefixes(); + if (ingressHostnamePrefixes != null) { + for (String prefix : ingressHostnamePrefixes) { + hostsToClean.add(prefix + instancesHost); + } + } + + boolean cleanupSuccess = TheiaCloudIngressUtil.removeIngressRules(client.kubernetes(), client.namespace(), + ingress.get(), path, arguments.getIngressPathSuffix(), hostsToClean, correlationId); + + if (!cleanupSuccess) { + LOGGER.error(formatLogMessage(correlationId, + "Failed to remove ingress rules for session " + session.getSpec().getName())); + } + + return cleanupSuccess; + } + + @Override + public synchronized boolean removeSessionRouting(Session session, AppDefinition appDefinition, int instance, + String correlationId) { + Optional ingress = getIngress(appDefinition, correlationId); + if (ingress.isEmpty()) { + LOGGER.error(formatLogMessage(correlationId, + "No Ingress for app definition " + appDefinition.getSpec().getName() + " found.")); + return false; + } + + String ruleHttpPath = ingressPathProvider.getPath(appDefinition, instance) + arguments.getIngressPathSuffix(); + client.ingresses().resource(ingress.get().getMetadata().getName()).edit(ingressToUpdate -> { + ingressToUpdate.getSpec().getRules().removeIf(rule -> { + if (rule.getHttp() == null) { + LOGGER.warn(formatLogMessage(correlationId, + "Error while removing ingress rule: The rule's HTTP block is null")); + return false; + } + return rule.getHttp().getPaths().stream().anyMatch(httpPath -> ruleHttpPath.equals(httpPath.getPath())); + }); + return ingressToUpdate; + }); + + return true; + } + + private Optional getIngress(AppDefinition appDefinition, String correlationId) { + String appDefinitionResourceName = appDefinition.getMetadata().getName(); + String appDefinitionResourceUID = appDefinition.getMetadata().getUid(); + return K8sUtil.getExistingIngress(client.kubernetes(), client.namespace(), appDefinitionResourceName, + appDefinitionResourceUID); + } + + private String addIngressRulesForSession(Ingress ingress, Service service, AppDefinition appDefinition, String path, + String correlationId) { + List hostsToAdd = new ArrayList<>(); + final String instancesHost = arguments.getInstancesHost(); + hostsToAdd.add(instancesHost); + List ingressHostnamePrefixes = appDefinition.getSpec().getIngressHostnamePrefixes() != null + ? appDefinition.getSpec().getIngressHostnamePrefixes() + : Collections.emptyList(); + for (String prefix : ingressHostnamePrefixes) { + hostsToAdd.add(prefix + instancesHost); + } + + int port = appDefinition.getSpec().getPort(); + String serviceName = service.getMetadata().getName(); + client.ingresses().edit(correlationId, ingress.getMetadata().getName(), ingressToUpdate -> { + for (String host : hostsToAdd) { + addIngressRule(ingressToUpdate, host, serviceName, port, path); + } + }); + + return HOST_PROTOCOL + instancesHost + path + "/"; + } + + private void addSingleIngressRule(Ingress ingress, Service service, int port, String path, String correlationId) { + final String host = arguments.getInstancesHost(); + String serviceName = service.getMetadata().getName(); + client.ingresses().edit(correlationId, ingress.getMetadata().getName(), + ingressToUpdate -> addIngressRule(ingressToUpdate, host, serviceName, port, path)); + } + + private void addIngressRule(Ingress ingress, String host, String serviceName, int port, String path) { + IngressRule ingressRule = new IngressRule(); + ingress.getSpec().getRules().add(ingressRule); + ingressRule.setHost(host); + + HTTPIngressRuleValue http = new HTTPIngressRuleValue(); + ingressRule.setHttp(http); + + HTTPIngressPath httpIngressPath = new HTTPIngressPath(); + http.getPaths().add(httpIngressPath); + httpIngressPath.setPath(path + arguments.getIngressPathSuffix()); + httpIngressPath.setPathType(AddedHandlerUtil.INGRESS_PATH_TYPE); + + IngressBackend ingressBackend = new IngressBackend(); + httpIngressPath.setBackend(ingressBackend); + + IngressServiceBackend ingressServiceBackend = new IngressServiceBackend(); + ingressBackend.setService(ingressServiceBackend); + ingressServiceBackend.setName(serviceName); + + ServiceBackendPort serviceBackendPort = new ServiceBackendPort(); + ingressServiceBackend.setPort(serviceBackendPort); + serviceBackendPort.setNumber(port); + } + + @Override + public String getSessionURL(AppDefinition appDefinition, Session session) { + String path = ingressPathProvider.getPath(appDefinition, session); + return HOST_PROTOCOL + arguments.getInstancesHost() + path + "/"; + } + + @Override + public String getSessionURL(AppDefinition appDefinition, int instance) { + String path = ingressPathProvider.getPath(appDefinition, instance); + return HOST_PROTOCOL + arguments.getInstancesHost() + path + "/"; + } +} diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/routing/OpenShiftRouteRoutingStrategy.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/routing/OpenShiftRouteRoutingStrategy.java new file mode 100644 index 000000000..26438a89b --- /dev/null +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/routing/OpenShiftRouteRoutingStrategy.java @@ -0,0 +1,290 @@ +/******************************************************************************** + * Copyright (C) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +package org.eclipse.theia.cloud.operator.routing; + +import static org.eclipse.theia.cloud.common.util.LogMessageUtil.formatLogMessage; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.theia.cloud.common.k8s.client.TheiaCloudClient; +import org.eclipse.theia.cloud.common.k8s.resource.ResourceEdit; +import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinition; +import org.eclipse.theia.cloud.common.k8s.resource.session.Session; +import org.eclipse.theia.cloud.common.util.LabelsUtil; +import org.eclipse.theia.cloud.common.util.NamingUtil; +import org.eclipse.theia.cloud.operator.TheiaCloudOperatorArguments; +import org.eclipse.theia.cloud.operator.util.JavaResourceUtil; + +import com.google.inject.Inject; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.client.utils.Serialization; +import io.fabric8.openshift.api.model.Route; +import io.fabric8.openshift.api.model.TLSConfigBuilder; +import io.fabric8.openshift.client.OpenShiftClient; + +/** + * OpenShift Route-based implementation of {@link SessionRoutingStrategy}. + *

+ * Instead of managing IngressRules on a shared Ingress resource, this strategy creates individual OpenShift Route + * objects for each session from a classpath YAML template ({@code templateRoute.yaml}). Routes are deleted when + * sessions end. + *

+ * TLS settings and custom annotations are read from the {@code openshift-route-config} ConfigMap deployed by the Helm + * chart. + *

+ * Only subdomain-based routing ({@code usePaths: false}) is supported on OpenShift. + */ +public class OpenShiftRouteRoutingStrategy implements SessionRoutingStrategy { + + private static final Logger LOGGER = LogManager.getLogger(OpenShiftRouteRoutingStrategy.class); + + private static final String TEMPLATE_ROUTE_YAML = "/templateRoute.yaml"; + private static final String ROUTE_CONFIG_CM_NAME = "openshift-route-config"; + + @Inject + private TheiaCloudClient client; + + @Inject + private TheiaCloudOperatorArguments arguments; + + private boolean initialized; + private OpenShiftClient osClient; + private boolean useTls; + private Map routeAnnotations; + + /** Package-private constructor for unit tests. */ + OpenShiftRouteRoutingStrategy(TheiaCloudOperatorArguments arguments) { + this.arguments = arguments; + } + + OpenShiftRouteRoutingStrategy() { + } + + private synchronized void ensureInitialized() { + if (initialized) { + return; + } + osClient = client.kubernetes().adapt(OpenShiftClient.class); + String namespace = client.namespace(); + ConfigMap cm = client.kubernetes().configMaps().inNamespace(namespace).withName(ROUTE_CONFIG_CM_NAME).get(); + if (cm == null) { + LOGGER.warn(formatLogMessage("INIT", "ConfigMap '" + ROUTE_CONFIG_CM_NAME + "' not found in namespace " + + namespace + ". Using defaults (no TLS, no annotations).")); + this.useTls = false; + this.routeAnnotations = Map.of(); + this.initialized = true; + return; + } + this.useTls = Boolean.parseBoolean(cm.getData().getOrDefault("useTls", "false")); + String annotationsYaml = cm.getData().get("annotations"); + if (annotationsYaml != null && !annotationsYaml.isBlank()) { + try { + @SuppressWarnings("unchecked") + Map parsed = Serialization.unmarshal(annotationsYaml, Map.class); + this.routeAnnotations = parsed != null ? parsed : Map.of(); + } catch (Exception e) { + LOGGER.warn(formatLogMessage("INIT", "Failed to parse annotations from ConfigMap '" + + ROUTE_CONFIG_CM_NAME + "'. Using empty annotations."), e); + this.routeAnnotations = Map.of(); + } + } else { + this.routeAnnotations = Map.of(); + } + this.initialized = true; + } + + private String protocol() { + return useTls ? "https://" : "http://"; + } + + @Override + public boolean ensureRoutingResourceExists(AppDefinition appDefinition, String correlationId) { + ensureInitialized(); + return true; + } + + @Override + public synchronized String addSessionRouting(Session session, AppDefinition appDefinition, Service service, + String correlationId) { + ensureInitialized(); + String routeName = NamingUtil.createNameWithSuffix(session, "route"); + String hostname = computeSessionHostname(session); + return createRoute(routeName, hostname, session, appDefinition, service, correlationId); + } + + @Override + public synchronized String addSessionRouting(Session session, AppDefinition appDefinition, Service service, + int instance, String correlationId) { + ensureInitialized(); + String routeName = computeInstanceRouteName(appDefinition, instance); + String hostname = computeInstanceHostname(appDefinition, instance); + return createRoute(routeName, hostname, session, appDefinition, service, correlationId); + } + + @Override + public synchronized boolean removeSessionRouting(Session session, AppDefinition appDefinition, + String correlationId) { + ensureInitialized(); + String routeName = NamingUtil.createNameWithSuffix(session, "route"); + return deleteRoute(routeName, correlationId); + } + + @Override + public synchronized boolean removeSessionRouting(Session session, AppDefinition appDefinition, int instance, + String correlationId) { + ensureInitialized(); + String routeName = computeInstanceRouteName(appDefinition, instance); + return deleteRoute(routeName, correlationId); + } + + @Override + public synchronized String getSessionURL(AppDefinition appDefinition, Session session) { + ensureInitialized(); + return protocol() + computeSessionHostname(session) + "/"; + } + + @Override + public synchronized String getSessionURL(AppDefinition appDefinition, int instance) { + ensureInitialized(); + return protocol() + computeInstanceHostname(appDefinition, instance) + "/"; + } + + private String createRoute(String routeName, String hostname, Session session, AppDefinition appDefinition, + Service service, String correlationId) { + String namespace = client.namespace(); + String serviceName = service.getMetadata().getName(); + + Route existingRoute = osClient.routes().inNamespace(namespace).withName(routeName).get(); + if (existingRoute != null) { + LOGGER.info(formatLogMessage(correlationId, "Route '" + routeName + "' already exists with host '" + + existingRoute.getSpec().getHost() + "'")); + return protocol() + existingRoute.getSpec().getHost() + "/"; + } + + Map replacements = new HashMap<>(); + replacements.put("placeholder-routename", routeName); + replacements.put("placeholder-namespace", namespace); + replacements.put("placeholder-hostname", hostname); + replacements.put("placeholder-servicename", serviceName); + + String routeYaml; + try { + routeYaml = JavaResourceUtil.readResourceAndReplacePlaceholders(TEMPLATE_ROUTE_YAML, replacements, + correlationId); + } catch (IOException | URISyntaxException e) { + LOGGER.error(formatLogMessage(correlationId, + "Error while loading Route template for route '" + routeName + "'"), e); + return null; + } + + Route route; + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(routeYaml.getBytes(StandardCharsets.UTF_8))) { + route = osClient.routes().load(inputStream).item(); + } catch (IOException e) { + LOGGER.error(formatLogMessage(correlationId, + "Error while parsing Route YAML for route '" + routeName + "'"), e); + return null; + } + if (route == null) { + LOGGER.error(formatLogMessage(correlationId, "Parsed Route is null for route '" + routeName + "'")); + return null; + } + + Map labels = new HashMap<>(LabelsUtil.createSessionLabels(session, appDefinition)); + labels.put("app", serviceName); + route.getMetadata().setLabels(labels); + + ResourceEdit. updateOwnerReference(0, Session.API, Session.KIND, session.getMetadata().getName(), + session.getMetadata().getUid(), correlationId).accept(route); + + if (useTls) { + route.getSpec().setTls(new TLSConfigBuilder().withTermination("edge").build()); + } + + if (routeAnnotations != null && !routeAnnotations.isEmpty()) { + Map annotations = route.getMetadata().getAnnotations(); + if (annotations == null) { + annotations = new HashMap<>(); + route.getMetadata().setAnnotations(annotations); + } + annotations.putAll(routeAnnotations); + } + + try { + osClient.routes().inNamespace(namespace).resource(route).create(); + LOGGER.info(formatLogMessage(correlationId, + "Created Route '" + routeName + "' with host '" + hostname + "'")); + } catch (Exception e) { + LOGGER.error(formatLogMessage(correlationId, "Failed to create Route '" + routeName + "'"), e); + return null; + } + + return protocol() + hostname + "/"; + } + + private boolean deleteRoute(String routeName, String correlationId) { + String namespace = client.namespace(); + + Route existingRoute = osClient.routes().inNamespace(namespace).withName(routeName).get(); + if (existingRoute == null) { + LOGGER.info(formatLogMessage(correlationId, + "Route '" + routeName + "' not found -- may have been cleaned up by owner reference GC.")); + return true; + } + + try { + osClient.routes().inNamespace(namespace).withName(routeName).delete(); + LOGGER.info(formatLogMessage(correlationId, "Deleted Route '" + routeName + "'")); + } catch (Exception e) { + LOGGER.error(formatLogMessage(correlationId, "Failed to delete Route '" + routeName + "'"), e); + return false; + } + + return true; + } + + /** + * Compute the hostname for a session Route. Uses the full session UID to create a unique subdomain under the + * instances host. + *

+ * For example: {@code .ws.apps-crc.testing} + */ + String computeSessionHostname(Session session) { + String instancesHost = arguments.getInstancesHost(); + String uid = session.getMetadata().getUid(); + return uid + "." + instancesHost; + } + + String computeInstanceHostname(AppDefinition appDefinition, int instance) { + String instancesHost = arguments.getInstancesHost(); + String subdomainLabel = NamingUtil.asValidName( + appDefinition.getSpec().getName() + "-" + instance, 63); + return subdomainLabel + "." + instancesHost; + } + + String computeInstanceRouteName(AppDefinition appDefinition, int instance) { + return NamingUtil.createNameWithSuffix(appDefinition, instance, "route"); + } +} diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/routing/SessionRoutingStrategy.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/routing/SessionRoutingStrategy.java new file mode 100644 index 000000000..e6544d6c0 --- /dev/null +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/routing/SessionRoutingStrategy.java @@ -0,0 +1,116 @@ +/******************************************************************************** + * Copyright (C) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +package org.eclipse.theia.cloud.operator.routing; + +import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinition; +import org.eclipse.theia.cloud.common.k8s.resource.session.Session; + +import io.fabric8.kubernetes.api.model.Service; + +/** + * Strategy interface that abstracts session routing resource management. + *

+ * Implementations are selected via the cloud provider configuration. + */ +public interface SessionRoutingStrategy { + + /** + * Check if the base routing resource exists for an AppDefinition and add owner + * references if missing. + * + * @param appDefinition the AppDefinition to check + * @param correlationId the correlation ID for logging + * @return true if the routing resource exists, false otherwise + */ + boolean ensureRoutingResourceExists(AppDefinition appDefinition, String correlationId); + + /** + * Create or update routing for a new session (lazy start). The routing path is + * derived from the session's UID. + * + * @param session the session being added + * @param appDefinition the AppDefinition for the session + * @param service the Service created for the session + * @param correlationId the correlation ID for logging + * @return the full session URL with protocol (e.g. + * {@code https://host/path/} or {@code http://uid.host/}), or + * {@code null} if routing could not be established + */ + String addSessionRouting(Session session, AppDefinition appDefinition, Service service, String correlationId); + + /** + * Create or update routing for a new session (eager start). The routing path is + * derived from the pre-allocated instance number. + * + * @param session the session being added + * @param appDefinition the AppDefinition for the session + * @param service the Service to route to + * @param instance the instance number for path computation + * @param correlationId the correlation ID for logging + * @return the full session URL with protocol (e.g. + * {@code https://host/path/} or {@code http://appname-0.host/}), or + * {@code null} if routing could not be established + */ + String addSessionRouting(Session session, AppDefinition appDefinition, Service service, int instance, + String correlationId); + + /** + * Clean up routing when a session is deleted (lazy start). The routing path is + * derived from the session. + * + * @param session the session being deleted + * @param appDefinition the AppDefinition for the session + * @param correlationId the correlation ID for logging + * @return true if cleanup succeeded, false otherwise + */ + boolean removeSessionRouting(Session session, AppDefinition appDefinition, String correlationId); + + /** + * Clean up routing when a session is deleted (eager start). The routing path is + * derived from the instance number. + * + * @param session the session being deleted + * @param appDefinition the AppDefinition for the session + * @param instance the instance number for path computation + * @param correlationId the correlation ID for logging + * @return true if cleanup succeeded, false otherwise + */ + boolean removeSessionRouting(Session session, AppDefinition appDefinition, int instance, String correlationId); + + /** + * Compute the full session URL for a given session (lazy start). This does not + * create any routing resources; it only computes the URL that the session will + * be reachable at. + * + * @param appDefinition the AppDefinition for the session + * @param session the session + * @return the full session URL (e.g. + * {@code https://host/path/} for Ingress or + * {@code https://uid.host/} for Routes) + */ + String getSessionURL(AppDefinition appDefinition, Session session); + + /** + * Compute the full session URL for a given instance (eager start). This does + * not create any routing resources; it only computes the URL that the session + * will be reachable at. + * + * @param appDefinition the AppDefinition for the session + * @param instance the instance number + * @return the full session URL + */ + String getSessionURL(AppDefinition appDefinition, int instance); +} diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/util/TheiaCloudDeploymentUtil.java b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/util/TheiaCloudDeploymentUtil.java index 1703dc351..0528080b0 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/util/TheiaCloudDeploymentUtil.java +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/java/org/eclipse/theia/cloud/operator/util/TheiaCloudDeploymentUtil.java @@ -26,7 +26,6 @@ import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinition; import org.eclipse.theia.cloud.common.k8s.resource.session.Session; import org.eclipse.theia.cloud.common.util.NamingUtil; -import org.eclipse.theia.cloud.operator.ingress.IngressPathProvider; import io.fabric8.kubernetes.api.model.apps.Deployment; @@ -40,18 +39,20 @@ public final class TheiaCloudDeploymentUtil { private TheiaCloudDeploymentUtil() { } - public static String getSessionURL(String host, IngressPathProvider ingressPathProvider, - AppDefinition appDefinition, Session session) { - return getSessionURL(host, ingressPathProvider.getPath(appDefinition, session)); + /** + * Extract the host portion from a full URL by stripping the protocol scheme and + * trailing slash. + * + * @param url a URL such as {@code https://host/path/} + * @return the host+path without scheme or trailing slash, e.g. + * {@code host/path} + */ + public static String extractHost(String url) { + return url.replaceFirst("^https?://", "").replaceFirst("/$", ""); } - public static String getSessionURL(String host, IngressPathProvider ingressPathProvider, - AppDefinition appDefinition, int instance) { - return getSessionURL(host, ingressPathProvider.getPath(appDefinition, instance)); - } - - private static String getSessionURL(String host, String path) { - return HOST_PROTOCOL + host + path + "/"; + public static String normalizeExternalBaseUrl(String url) { + return url.replaceFirst("/$", ""); } public static String getDeploymentName(AppDefinition appDefinition, int instance) { diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateDeployment.yaml b/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateDeployment.yaml index 0cfc56f11..68b211182 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateDeployment.yaml +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateDeployment.yaml @@ -19,6 +19,7 @@ spec: app: placeholder-app spec: automountServiceAccountToken: false + serviceAccountName: theia-cloud-sessions containers: - name: oauth2-proxy image: quay.io/oauth2-proxy/oauth2-proxy:placeholder-oauth2-proxy-version diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateDeploymentWithoutOAuthProxy.yaml b/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateDeploymentWithoutOAuthProxy.yaml index e39a8887b..3d6e71b09 100644 --- a/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateDeploymentWithoutOAuthProxy.yaml +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateDeploymentWithoutOAuthProxy.yaml @@ -19,6 +19,7 @@ spec: app: placeholder-app spec: automountServiceAccountToken: false + serviceAccountName: theia-cloud-sessions # initContainers: # - name: wondershaper-init # image: gcr.io/kubernetes-238012/theia-cloud-wondershaper diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateRoute.yaml b/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateRoute.yaml new file mode 100644 index 000000000..652b4f98d --- /dev/null +++ b/java/operator/org.eclipse.theia.cloud.operator/src/main/resources/templateRoute.yaml @@ -0,0 +1,19 @@ +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: placeholder-routename + namespace: placeholder-namespace + ownerReferences: + - apiVersion: theia.cloud/v1beta9 + kind: Session + name: placeholder + uid: placeholder +spec: + host: placeholder-hostname + to: + kind: Service + name: placeholder-servicename + weight: 100 + port: + targetPort: http + wildcardPolicy: None diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/test/java/org/eclipse/theia/cloud/operator/routing/OpenShiftRouteRoutingStrategyTests.java b/java/operator/org.eclipse.theia.cloud.operator/src/test/java/org/eclipse/theia/cloud/operator/routing/OpenShiftRouteRoutingStrategyTests.java new file mode 100644 index 000000000..1f1054b57 --- /dev/null +++ b/java/operator/org.eclipse.theia.cloud.operator/src/test/java/org/eclipse/theia/cloud/operator/routing/OpenShiftRouteRoutingStrategyTests.java @@ -0,0 +1,182 @@ +/******************************************************************************** + * Copyright (C) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +package org.eclipse.theia.cloud.operator.routing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; + +import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinition; +import org.eclipse.theia.cloud.common.k8s.resource.appdefinition.AppDefinitionSpec; +import org.eclipse.theia.cloud.common.k8s.resource.session.Session; +import org.eclipse.theia.cloud.common.k8s.resource.session.SessionSpec; +import org.eclipse.theia.cloud.common.util.NamingUtil; +import org.eclipse.theia.cloud.operator.TheiaCloudOperatorArguments; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ObjectMeta; + +/** + * Unit tests for {@link OpenShiftRouteRoutingStrategy} hostname and route name + * computation methods. + */ +class OpenShiftRouteRoutingStrategyTests { + + private static final String INSTANCES_HOST = "ws.apps-crc.testing"; + + private OpenShiftRouteRoutingStrategy strategy; + + @BeforeEach + void setUp() throws Exception { + TheiaCloudOperatorArguments args = new TheiaCloudOperatorArguments(); + Field instancesHostField = TheiaCloudOperatorArguments.class.getDeclaredField("instancesHost"); + instancesHostField.setAccessible(true); + instancesHostField.set(args, INSTANCES_HOST); + + strategy = new OpenShiftRouteRoutingStrategy(args); + } + + @Test + void computeSessionHostname_usesUidAndInstancesHost() { + Session session = createSession("abc-123-uid"); + + String hostname = strategy.computeSessionHostname(session); + + assertEquals("abc-123-uid.ws.apps-crc.testing", hostname); + } + + @Test + void computeInstanceHostname_usesAppNameInstanceAndHost() { + AppDefinition appDef = createAppDefinition("my-app"); + + String hostname = strategy.computeInstanceHostname(appDef, 0); + + assertEquals("my-app-0.ws.apps-crc.testing", hostname); + } + + @Test + void computeInstanceHostname_differentInstances() { + AppDefinition appDef = createAppDefinition("editor"); + + assertEquals("editor-0.ws.apps-crc.testing", strategy.computeInstanceHostname(appDef, 0)); + assertEquals("editor-1.ws.apps-crc.testing", strategy.computeInstanceHostname(appDef, 1)); + assertEquals("editor-5.ws.apps-crc.testing", strategy.computeInstanceHostname(appDef, 5)); + } + + @Test + void computeInstanceRouteName_endsWith_route() { + AppDefinition appDef = createAppDefinition("my-app"); + + String routeName = strategy.computeInstanceRouteName(appDef, 0); + + assertTrue(routeName.endsWith("-route")); + } + + @Test + void computeInstanceRouteName_matchesNamingUtil() { + AppDefinition appDef = createAppDefinition("my-app"); + + String routeName = strategy.computeInstanceRouteName(appDef, 0); + + String expected = NamingUtil.createNameWithSuffix(appDef, 0, "route"); + assertEquals(expected, routeName); + } + + @Test + void computeInstanceRouteName_differentInstances_produceDifferentNames() { + AppDefinition appDef = createAppDefinition("my-app"); + + String route0 = strategy.computeInstanceRouteName(appDef, 0); + String route1 = strategy.computeInstanceRouteName(appDef, 1); + + assertNotEquals(route0, route1); + } + + @Test + void computeInstanceRouteName_withinKubernetesNameLimit() { + AppDefinition appDef = createAppDefinition("very-long-app-definition-name-for-testing"); + + String routeName = strategy.computeInstanceRouteName(appDef, 99); + + assertTrue(routeName.length() <= NamingUtil.VALID_NAME_LIMIT); + assertTrue(routeName.endsWith("-route")); + } + + @Test + void computeSessionHostname_differentSessions_produceDifferentHostnames() { + Session session1 = createSession("uid-1"); + Session session2 = createSession("uid-2"); + + assertNotEquals(strategy.computeSessionHostname(session1), strategy.computeSessionHostname(session2)); + } + + @Test + void computeInstanceHostname_differentApps_produceDifferentHostnames() { + AppDefinition appDef1 = createAppDefinition("app-one"); + AppDefinition appDef2 = createAppDefinition("app-two"); + + assertNotEquals(strategy.computeInstanceHostname(appDef1, 0), strategy.computeInstanceHostname(appDef2, 0)); + } + + @Test + void computeInstanceHostname_sanitizesInvalidDnsCharacters() { + AppDefinition appDef = createAppDefinition("My.App"); + + String hostname = strategy.computeInstanceHostname(appDef, 0); + + assertEquals("my-app-0.ws.apps-crc.testing", hostname); + } + + @Test + void computeInstanceHostname_truncatesLongNames() { + String longName = "a".repeat(70); + AppDefinition appDef = createAppDefinition(longName); + + String hostname = strategy.computeInstanceHostname(appDef, 0); + String subdomainLabel = hostname.substring(0, hostname.indexOf(".")); + + assertTrue(subdomainLabel.length() <= 63, + "Subdomain label must be at most 63 characters but was " + subdomainLabel.length()); + } + + private Session createSession(String uid) { + Session session = new Session(); + ObjectMeta meta = new ObjectMeta(); + meta.setUid(uid); + session.setMetadata(meta); + SessionSpec spec = new SessionSpec("test-session", "test-app", "user@example.org"); + session.setSpec(spec); + return session; + } + + private AppDefinition createAppDefinition(String appName) { + AppDefinition appDef = new AppDefinition(); + ObjectMeta meta = new ObjectMeta(); + meta.setUid("6f1a8966-4d5a-41dc-82ba-381261d79c23"); + appDef.setMetadata(meta); + AppDefinitionSpec spec = new AppDefinitionSpec() { + @Override + public String getName() { + return appName; + } + }; + appDef.setSpec(spec); + return appDef; + } +} diff --git a/java/operator/org.eclipse.theia.cloud.operator/src/test/java/org/eclipse/theia/cloud/operator/util/TheiaCloudDeploymentUtilTests.java b/java/operator/org.eclipse.theia.cloud.operator/src/test/java/org/eclipse/theia/cloud/operator/util/TheiaCloudDeploymentUtilTests.java new file mode 100644 index 000000000..a7736a6a9 --- /dev/null +++ b/java/operator/org.eclipse.theia.cloud.operator/src/test/java/org/eclipse/theia/cloud/operator/util/TheiaCloudDeploymentUtilTests.java @@ -0,0 +1,74 @@ +/******************************************************************************** + * Copyright (C) 2026 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +package org.eclipse.theia.cloud.operator.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TheiaCloudDeploymentUtil}. + */ +class TheiaCloudDeploymentUtilTests { + + @Test + void extractHost_httpsUrl() { + assertEquals("host/path", TheiaCloudDeploymentUtil.extractHost("https://host/path/")); + } + + @Test + void extractHost_httpUrl() { + assertEquals("host/path", TheiaCloudDeploymentUtil.extractHost("http://host/path/")); + } + + @Test + void extractHost_httpsSubdomain() { + assertEquals("uid.ws.apps-crc.testing", TheiaCloudDeploymentUtil.extractHost("https://uid.ws.apps-crc.testing/")); + } + + @Test + void extractHost_httpSubdomain() { + assertEquals("uid.ws.apps-crc.testing", TheiaCloudDeploymentUtil.extractHost("http://uid.ws.apps-crc.testing/")); + } + + @Test + void extractHost_noTrailingSlash() { + assertEquals("host/path", TheiaCloudDeploymentUtil.extractHost("https://host/path")); + } + + @Test + void extractHost_instanceBasedUrl() { + assertEquals("my-app-0.ws.apps-crc.testing", + TheiaCloudDeploymentUtil.extractHost("http://my-app-0.ws.apps-crc.testing/")); + } + + @Test + void normalizeExternalBaseUrl_httpSubdomain() { + assertEquals("http://uid.ws.apps-crc.testing", + TheiaCloudDeploymentUtil.normalizeExternalBaseUrl("http://uid.ws.apps-crc.testing/")); + } + + @Test + void normalizeExternalBaseUrl_httpsSubdomain() { + assertEquals("https://uid.ws.apps-crc.testing", + TheiaCloudDeploymentUtil.normalizeExternalBaseUrl("https://uid.ws.apps-crc.testing/")); + } + + @Test + void normalizeExternalBaseUrl_httpsPath() { + assertEquals("https://host/path", TheiaCloudDeploymentUtil.normalizeExternalBaseUrl("https://host/path/")); + } +} diff --git a/node/common/src/client.ts b/node/common/src/client.ts index a1c10855a..7cc137c5e 100644 --- a/node/common/src/client.ts +++ b/node/common/src/client.ts @@ -225,8 +225,9 @@ export namespace TheiaCloud { export async function launchAndRedirect(request: LaunchRequest, options: RequestOptions = {}): Promise { const url = await launch(request, options); - console.log(`Redirect to: https://${url}`); - location.replace(`https://${url}`); + const fullUrl = url.startsWith('http') ? url : `https://${url}`; + console.log(`Redirect to: ${fullUrl}`); + location.replace(fullUrl); return url; } diff --git a/node/e2e-tests/configs/playwright.config.ts b/node/e2e-tests/configs/playwright.config.ts index 6b81971fe..745f60302 100644 --- a/node/e2e-tests/configs/playwright.config.ts +++ b/node/e2e-tests/configs/playwright.config.ts @@ -1,5 +1,32 @@ import { PlaywrightTestConfig } from '@playwright/test'; +/** + * Compute the landing-page baseURL for the test run. + * + * Two host-generation patterns are supported: + * + * 1. OpenShift (MicroShift e2e CI). Routes use the apps domain + * configured in `valuesOpenShiftMonitor.yaml` -- by default + * `apps-microshift.testing`. The landing page is served at + * `try.`. There is no `paths` mode for OpenShift + * (the Helm chart's `_helpers.tpl` rejects `usePaths=true` when + * `cloudProvider=OPENSHIFT`). + * + * 2. Minikube (existing nip.io-based tests). The cluster IP is + * embedded in the hostname (e.g. `trynow.192.168.49.2.nip.io`) + * or in the path (`192.168.49.2.nip.io/trynow`) depending on + * `MATRIX_PATHS`. + */ +function getBaseURL(): string { + if (process.env.MATRIX_CLOUD_PROVIDER === 'OPENSHIFT') { + const appsDomain = process.env.APPS_DOMAIN || 'apps-microshift.testing'; + return `https://try.${appsDomain}`; + } + return process.env.MATRIX_PATHS !== 'true' + ? `https://trynow.${process.env.INGRESS_HOST}.nip.io` + : `https://${process.env.INGRESS_HOST}.nip.io/trynow`; +} + const config: PlaywrightTestConfig = { testDir: '../lib/tests', testMatch: ['**/*.js'], @@ -8,10 +35,7 @@ const config: PlaywrightTestConfig = { // Timeout for each test in milliseconds. timeout: 60 * 1000, use: { - baseURL: - process.env.MATRIX_PATHS !== 'true' - ? `https://trynow.${process.env.INGRESS_HOST}.nip.io` - : `https://${process.env.INGRESS_HOST}.nip.io/trynow`, + baseURL: getBaseURL(), browserName: 'chromium', permissions: ['clipboard-read'], screenshot: 'only-on-failure', diff --git a/node/e2e-tests/src/tests/start.test.ts b/node/e2e-tests/src/tests/start.test.ts index 3b2377cd9..e62dec002 100644 --- a/node/e2e-tests/src/tests/start.test.ts +++ b/node/e2e-tests/src/tests/start.test.ts @@ -38,7 +38,13 @@ test.describe('Start Session', () => { /* check redirect url */ const browserUrl = page.url(); - expect(browserUrl).toContain(baseURL!.replace('trynow', 'instances')); + if (process.env.MATRIX_CLOUD_PROVIDER === 'OPENSHIFT') { + const browserHost = new URL(browserUrl).hostname; + const appsDomain = process.env.APPS_DOMAIN || 'apps-microshift.testing'; + expect(browserHost).toMatch(new RegExp(`^.+\\.ws\\.${appsDomain.replace(/\./g, '\\.')}$`)); + } else { + expect(browserUrl).toContain(baseURL!.replace('trynow', 'instances')); + } /* check created session */ const resources: any = await k8sApi.listNamespacedCustomObject({ @@ -110,7 +116,7 @@ test.describe('Start Session', () => { /* Verify access to Theia application (not Access Forbidden page) */ const pageTitle = await newPage.title(); - expect(pageTitle).toBe('Eclipse Theia'); + expect(pageTitle).toContain('Eclipse Theia'); /* Cleanup */ await newContext.close(); diff --git a/terraform/ci-configurations/e2e_tests.tf b/terraform/ci-configurations/e2e_tests.tf index e3ea050a5..ef658ef19 100644 --- a/terraform/ci-configurations/e2e_tests.tf +++ b/terraform/ci-configurations/e2e_tests.tf @@ -146,7 +146,8 @@ resource "helm_release" "theia-cloud" { create_namespace = true values = [ - "${file("${path.module}/valuesE2ECI.yaml")}" + file("${path.module}/../values/valuesE2ECI-base.yaml"), + file("${path.module}/../values/valuesE2ECI-minikube.yaml"), ] set = [{ @@ -177,177 +178,14 @@ resource "helm_release" "theia-cloud" { name = "operator.eagerStart" value = var.eager_start } - ] -} - -resource "kubectl_manifest" "theia-cloud-monitor-theia-popup" { - depends_on = [helm_release.theia-cloud] - yaml_body = <<-EOF - apiVersion: theia.cloud/v1beta10 - kind: AppDefinition - metadata: - name: theia-cloud-monitor-theia-popup - namespace: theia-cloud - spec: - name: theia-cloud-monitor-theia-popup - image: theiacloud/theia-cloud-activity-demo-theia:minikube-ci-e2e - imagePullPolicy: IfNotPresent - uid: 101 - port: 3000 - ingressname: theia-cloud-demo-ws-ingress - ingressHostnamePrefixes: [] - minInstances: ${var.eager_start ? 1 : 0} - maxInstances: 10 - timeout: 15 - requestsMemory: 1000M - requestsCpu: 100m - limitsMemory: 1200M - limitsCpu: "2" - downlinkLimit: 30000 - uplinkLimit: 30000 - mountPath: /home/project/persisted - monitor: - port: 3000 - activityTracker: - timeoutAfter: 15 - notifyAfter: 2 - EOF -} - -resource "kubectl_manifest" "theia-cloud-monitor-theia-timeout" { - depends_on = [helm_release.theia-cloud] - yaml_body = <<-EOF - apiVersion: theia.cloud/v1beta10 - kind: AppDefinition - metadata: - name: theia-cloud-monitor-theia-timeout - namespace: theia-cloud - spec: - name: theia-cloud-monitor-theia-timeout - image: theiacloud/theia-cloud-activity-demo-theia:minikube-ci-e2e - imagePullPolicy: IfNotPresent - uid: 101 - port: 3000 - ingressname: theia-cloud-demo-ws-ingress - ingressHostnamePrefixes: [] - minInstances: ${var.eager_start ? 1 : 0} - maxInstances: 10 - timeout: 15 - requestsMemory: 1000M - requestsCpu: 100m - limitsMemory: 1200M - limitsCpu: "2" - downlinkLimit: 30000 - uplinkLimit: 30000 - mountPath: /home/project/persisted - monitor: - port: 3000 - activityTracker: - timeoutAfter: 4 - notifyAfter: 15 - EOF + ] } -resource "kubectl_manifest" "theia-cloud-monitor-vscode-popup" { - depends_on = [helm_release.theia-cloud] - yaml_body = <<-EOF - apiVersion: theia.cloud/v1beta10 - kind: AppDefinition - metadata: - name: theia-cloud-monitor-vscode-popup - namespace: theia-cloud - spec: - name: theia-cloud-monitor-vscode-popup - image: theiacloud/theia-cloud-activity-demo:minikube-ci-e2e - imagePullPolicy: IfNotPresent - uid: 101 - port: 3000 - ingressname: theia-cloud-demo-ws-ingress - ingressHostnamePrefixes: [] - minInstances: ${var.eager_start ? 1 : 0} - maxInstances: 10 - timeout: 15 - requestsMemory: 1000M - requestsCpu: 100m - limitsMemory: 1200M - limitsCpu: "2" - downlinkLimit: 30000 - uplinkLimit: 30000 - mountPath: /home/project/persisted - monitor: - port: 8081 - activityTracker: - timeoutAfter: 15 - notifyAfter: 2 - EOF -} +module "appdefinitions" { + source = "../modules/theia-cloud-ci-appdefinitions" -resource "kubectl_manifest" "theia-cloud-monitor-vscode-timeout" { depends_on = [helm_release.theia-cloud] - yaml_body = <<-EOF - apiVersion: theia.cloud/v1beta10 - kind: AppDefinition - metadata: - name: theia-cloud-monitor-vscode-timeout - namespace: theia-cloud - spec: - name: theia-cloud-monitor-vscode-timeout - image: theiacloud/theia-cloud-activity-demo:minikube-ci-e2e - imagePullPolicy: IfNotPresent - uid: 101 - port: 3000 - ingressname: theia-cloud-demo-ws-ingress - ingressHostnamePrefixes: [] - minInstances: ${var.eager_start ? 1 : 0} - maxInstances: 10 - timeout: 15 - requestsMemory: 1000M - requestsCpu: 100m - limitsMemory: 1200M - limitsCpu: "2" - downlinkLimit: 30000 - uplinkLimit: 30000 - mountPath: /home/project/persisted - monitor: - port: 8081 - activityTracker: - timeoutAfter: 4 - notifyAfter: 15 - EOF -} -resource "kubectl_manifest" "theia-cloud-demo" { - depends_on = [helm_release.theia-cloud] - yaml_body = <<-EOF - apiVersion: theia.cloud/v1beta10 - kind: AppDefinition - metadata: - name: theia-cloud-demo - namespace: theia-cloud - spec: - name: theia-cloud-demo - image: theiacloud/theia-cloud-activity-demo-theia:minikube-ci-e2e - imagePullPolicy: IfNotPresent - uid: 101 - port: 3000 - ingressname: theia-cloud-demo-ws-ingress - ingressHostnamePrefixes: [] - minInstances: 0 - maxInstances: 10 - timeout: 2 - requestsMemory: 1000M - requestsCpu: 100m - limitsMemory: 1200M - limitsCpu: "2" - downlinkLimit: 30000 - uplinkLimit: 30000 - mountPath: /home/project/persisted - monitor: - port: 3000 - activityTracker: - timeoutAfter: 30 - notifyAfter: 30 - EOF + eager_start = var.eager_start } - diff --git a/terraform/modules/theia-cloud-ci-appdefinitions/main.tf b/terraform/modules/theia-cloud-ci-appdefinitions/main.tf new file mode 100644 index 000000000..5410d29bc --- /dev/null +++ b/terraform/modules/theia-cloud-ci-appdefinitions/main.tf @@ -0,0 +1,174 @@ +# Shared AppDefinitions used by the e2e Playwright suite. Consumed by +# both the minikube CI config (terraform/ci-configurations/) and the +# OpenShift CI config (terraform/test-configurations/5-02_openshift_ci/). +# +# The image references differ between the two: minikube reuses the +# locally-built docker tags directly (`theiacloud/*:minikube-ci-e2e`, +# IfNotPresent), while OpenShift transfers the same images into +# MicroShift's CRI-O storage under `localhost/*:microshift-ci-e2e` and +# uses `Never` so the kubelet does not attempt a registry pull. + +resource "kubectl_manifest" "theia-cloud-monitor-theia-popup" { + yaml_body = <<-EOF + apiVersion: theia.cloud/v1beta10 + kind: AppDefinition + metadata: + name: theia-cloud-monitor-theia-popup + namespace: theia-cloud + spec: + name: theia-cloud-monitor-theia-popup + image: ${var.image_theia} + imagePullPolicy: ${var.image_pull_policy} + uid: 101 + port: 3000 + ingressname: theia-cloud-demo-ws-ingress + ingressHostnamePrefixes: [] + minInstances: ${var.eager_start ? 1 : 0} + maxInstances: 10 + timeout: 15 + requestsMemory: 1000M + requestsCpu: 100m + limitsMemory: 1200M + limitsCpu: "2" + downlinkLimit: 30000 + uplinkLimit: 30000 + mountPath: /home/project/persisted + monitor: + port: 3000 + activityTracker: + timeoutAfter: 15 + notifyAfter: 2 + EOF +} + +resource "kubectl_manifest" "theia-cloud-monitor-theia-timeout" { + yaml_body = <<-EOF + apiVersion: theia.cloud/v1beta10 + kind: AppDefinition + metadata: + name: theia-cloud-monitor-theia-timeout + namespace: theia-cloud + spec: + name: theia-cloud-monitor-theia-timeout + image: ${var.image_theia} + imagePullPolicy: ${var.image_pull_policy} + uid: 101 + port: 3000 + ingressname: theia-cloud-demo-ws-ingress + ingressHostnamePrefixes: [] + minInstances: ${var.eager_start ? 1 : 0} + maxInstances: 10 + timeout: 15 + requestsMemory: 1000M + requestsCpu: 100m + limitsMemory: 1200M + limitsCpu: "2" + downlinkLimit: 30000 + uplinkLimit: 30000 + mountPath: /home/project/persisted + monitor: + port: 3000 + activityTracker: + timeoutAfter: 4 + notifyAfter: 15 + EOF +} + +resource "kubectl_manifest" "theia-cloud-monitor-vscode-popup" { + yaml_body = <<-EOF + apiVersion: theia.cloud/v1beta10 + kind: AppDefinition + metadata: + name: theia-cloud-monitor-vscode-popup + namespace: theia-cloud + spec: + name: theia-cloud-monitor-vscode-popup + image: ${var.image_vscode} + imagePullPolicy: ${var.image_pull_policy} + uid: 101 + port: 3000 + ingressname: theia-cloud-demo-ws-ingress + ingressHostnamePrefixes: [] + minInstances: ${var.eager_start ? 1 : 0} + maxInstances: 10 + timeout: 15 + requestsMemory: 1000M + requestsCpu: 100m + limitsMemory: 1200M + limitsCpu: "2" + downlinkLimit: 30000 + uplinkLimit: 30000 + mountPath: /home/project/persisted + monitor: + port: 8081 + activityTracker: + timeoutAfter: 15 + notifyAfter: 2 + EOF +} + +resource "kubectl_manifest" "theia-cloud-monitor-vscode-timeout" { + yaml_body = <<-EOF + apiVersion: theia.cloud/v1beta10 + kind: AppDefinition + metadata: + name: theia-cloud-monitor-vscode-timeout + namespace: theia-cloud + spec: + name: theia-cloud-monitor-vscode-timeout + image: ${var.image_vscode} + imagePullPolicy: ${var.image_pull_policy} + uid: 101 + port: 3000 + ingressname: theia-cloud-demo-ws-ingress + ingressHostnamePrefixes: [] + minInstances: ${var.eager_start ? 1 : 0} + maxInstances: 10 + timeout: 15 + requestsMemory: 1000M + requestsCpu: 100m + limitsMemory: 1200M + limitsCpu: "2" + downlinkLimit: 30000 + uplinkLimit: 30000 + mountPath: /home/project/persisted + monitor: + port: 8081 + activityTracker: + timeoutAfter: 4 + notifyAfter: 15 + EOF +} + +resource "kubectl_manifest" "theia-cloud-demo" { + yaml_body = <<-EOF + apiVersion: theia.cloud/v1beta10 + kind: AppDefinition + metadata: + name: theia-cloud-demo + namespace: theia-cloud + spec: + name: theia-cloud-demo + image: ${var.image_theia} + imagePullPolicy: ${var.image_pull_policy} + uid: 101 + port: 3000 + ingressname: theia-cloud-demo-ws-ingress + ingressHostnamePrefixes: [] + minInstances: 0 + maxInstances: 10 + timeout: 2 + requestsMemory: 1000M + requestsCpu: 100m + limitsMemory: 1200M + limitsCpu: "2" + downlinkLimit: 30000 + uplinkLimit: 30000 + mountPath: /home/project/persisted + monitor: + port: 3000 + activityTracker: + timeoutAfter: 30 + notifyAfter: 30 + EOF +} diff --git a/terraform/modules/theia-cloud-ci-appdefinitions/variables.tf b/terraform/modules/theia-cloud-ci-appdefinitions/variables.tf new file mode 100644 index 000000000..d5cea8242 --- /dev/null +++ b/terraform/modules/theia-cloud-ci-appdefinitions/variables.tf @@ -0,0 +1,23 @@ +variable "image_theia" { + description = "Container image for the Theia-based AppDefinitions (monitor-theia-* and theia-cloud-demo)." + type = string + default = "theiacloud/theia-cloud-activity-demo-theia:minikube-ci-e2e" +} + +variable "image_vscode" { + description = "Container image for the VS Code-based AppDefinitions (monitor-vscode-*)." + type = string + default = "theiacloud/theia-cloud-activity-demo:minikube-ci-e2e" +} + +variable "image_pull_policy" { + description = "imagePullPolicy applied to all AppDefinitions emitted by this module." + type = string + default = "IfNotPresent" +} + +variable "eager_start" { + description = "When true, sets minInstances=1 on the four monitor-* AppDefinitions (matches the e2e `eagerStart` matrix axis)." + type = bool + default = false +} diff --git a/terraform/modules/theia-cloud-ci-appdefinitions/versions.tf b/terraform/modules/theia-cloud-ci-appdefinitions/versions.tf new file mode 100644 index 000000000..7a56fad2b --- /dev/null +++ b/terraform/modules/theia-cloud-ci-appdefinitions/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + kubectl = { + source = "gavinbunney/kubectl" + version = ">= 1.19.0" + } + } + + required_version = ">= 1.12.2" +} diff --git a/terraform/test-configurations/4_openshift-setup/.terraform.lock.hcl b/terraform/test-configurations/4_openshift-setup/.terraform.lock.hcl new file mode 100644 index 000000000..fd1144bc7 --- /dev/null +++ b/terraform/test-configurations/4_openshift-setup/.terraform.lock.hcl @@ -0,0 +1,110 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/gavinbunney/kubectl" { + version = "1.19.0" + constraints = ">= 1.19.0" + hashes = [ + "h1:9QkxPjp0x5FZFfJbE+B7hBOoads9gmdfj9aYu5N4Sfc=", + "zh:1dec8766336ac5b00b3d8f62e3fff6390f5f60699c9299920fc9861a76f00c71", + "zh:43f101b56b58d7fead6a511728b4e09f7c41dc2e3963f59cf1c146c4767c6cb7", + "zh:4c4fbaa44f60e722f25cc05ee11dfaec282893c5c0ffa27bc88c382dbfbaa35c", + "zh:51dd23238b7b677b8a1abbfcc7deec53ffa5ec79e58e3b54d6be334d3d01bc0e", + "zh:5afc2ebc75b9d708730dbabdc8f94dd559d7f2fc5a31c5101358bd8d016916ba", + "zh:6be6e72d4663776390a82a37e34f7359f726d0120df622f4a2b46619338a168e", + "zh:72642d5fcf1e3febb6e5d4ae7b592bb9ff3cb220af041dbda893588e4bf30c0c", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a1da03e3239867b35812ee031a1060fed6e8d8e458e2eaca48b5dd51b35f56f7", + "zh:b98b6a6728fe277fcd133bdfa7237bd733eae233f09653523f14460f608f8ba2", + "zh:bb8b071d0437f4767695c6158a3cb70df9f52e377c67019971d888b99147511f", + "zh:dc89ce4b63bfef708ec29c17e85ad0232a1794336dc54dd88c3ba0b77e764f71", + "zh:dd7dd18f1f8218c6cd19592288fde32dccc743cde05b9feeb2883f37c2ff4b4e", + "zh:ec4bd5ab3872dedb39fe528319b4bba609306e12ee90971495f109e142d66310", + "zh:f610ead42f724c82f5463e0e71fa735a11ffb6101880665d93f48b4a67b9ad82", + ] +} + +provider "registry.terraform.io/hashicorp/helm" { + version = "3.1.1" + constraints = ">= 3.0.2" + hashes = [ + "h1:5b2ojWKT0noujHiweCds37ZreRFRQLNaErdJLusJN88=", + "zh:1a6d5ce931708aec29d1f3d9e360c2a0c35ba5a54d03eeaff0ce3ca597cd0275", + "zh:3411919ba2a5941801e677f0fea08bdd0ae22ba3c9ce3309f55554699e06524a", + "zh:81b36138b8f2320dc7f877b50f9e38f4bc614affe68de885d322629dd0d16a29", + "zh:95a2a0a497a6082ee06f95b38bd0f0d6924a65722892a856cfd914c0d117f104", + "zh:9d3e78c2d1bb46508b972210ad706dd8c8b106f8b206ecf096cd211c54f46990", + "zh:a79139abf687387a6efdbbb04289a0a8e7eaca2bd91cdc0ce68ea4f3286c2c34", + "zh:aaa8784be125fbd50c48d84d6e171d3fb6ef84a221dbc5165c067ce05faab4c8", + "zh:afecd301f469975c9d8f350cc482fe656e082b6ab0f677d1a816c3c615837cc1", + "zh:c54c22b18d48ff9053d899d178d9ffef7d9d19785d9bf310a07d648b7aac075b", + "zh:db2eefd55aea48e73384a555c72bac3f7d428e24147bedb64e1a039398e5b903", + "zh:ee61666a233533fd2be971091cecc01650561f1585783c381b6f6e8a390198a4", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "3.0.1" + constraints = ">= 2.38.0" + hashes = [ + "h1:vyHdH0p6bf9xp1NPePObAJkXTJb/I09FQQmmevTzZe0=", + "zh:02d55b0b2238fd17ffa12d5464593864e80f402b90b31f6e1bd02249b9727281", + "zh:20b93a51bfeed82682b3c12f09bac3031f5bdb4977c47c97a042e4df4fb2f9ba", + "zh:6e14486ecfaee38c09ccf33d4fdaf791409f90795c1b66e026c226fad8bc03c7", + "zh:8d0656ff422df94575668e32c310980193fccb1c28117e5c78dd2d4050a760a6", + "zh:9795119b30ec0c1baa99a79abace56ac850b6e6fbce60e7f6067792f6eb4b5f4", + "zh:b388c87acc40f6bd9620f4e23f01f3c7b41d9b88a68d5255dec0a72f0bdec249", + "zh:b59abd0a980649c2f97f172392f080eaeb18e486b603f83bf95f5d93aeccc090", + "zh:ba6e3060fddf4a022087d8f09e38aa0001c705f21170c2ded3d1c26c12f70d97", + "zh:c12626d044b1d5501cf95ca78cbe507c13ad1dd9f12d4736df66eb8e5f336eb8", + "zh:c55203240d50f4cdeb3df1e1760630d677679f5b1a6ffd9eba23662a4ad05119", + "zh:ea206a5a32d6e0d6e32f1849ad703da9a28355d9c516282a8458b5cf1502b2a1", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.2.4" + constraints = ">= 3.0.0" + hashes = [ + "h1:hkf5w5B6q8e2A42ND2CjAvgvSN3puAosDmOJb3zCVQM=", + "zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43", + "zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a", + "zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991", + "zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f", + "zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e", + "zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615", + "zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442", + "zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5", + "zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f", + "zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f", + ] +} + +provider "registry.terraform.io/mrparkers/keycloak" { + version = "4.4.0" + constraints = ">= 4.4.0" + hashes = [ + "h1:FH9j76zRv05qxk7I/w0mycmBEuew/+XP+Qx+Ptz/onw=", + "zh:0116d63fb4a4436d67cc793038899e0de23c3a5c78f5bf3cf76ee006ad886979", + "zh:0fa399fcdeef21dd914ff7413b8489e47900cbe7bc65b50eeb0d75b71a2b561d", + "zh:30371fee6d0ae438908b1bf03278f6d0a0cb2992a97814028676a05a55d92f19", + "zh:39218a95fe6430ac2b44470cb991dbb98f57c5306017a80b81d3a319855094f4", + "zh:3b436c471cde4eb9120f609e3aecf12d383e8032aeb9cd12c7476faa7c8b4afb", + "zh:9a2a5cc77332e6cd9f6d101d3aff35520a2361fc02f4d436fe176dbd5351f24b", + "zh:9a89cc61c303100174cda3783b13fa4f6e2648eb436c1259d1c72264998534e8", + "zh:b588cd78d9939523de1fa8202c2757c497a20dcf2bf67cf4daf61836194bfe3a", + "zh:c04e6ac2367f55d9cd0893ebebbecb9da685312077e8a7fff299b8d8009955d5", + "zh:c23286693edf2024272219f6728bb7eded5ee087956fc527a63f10ea9ec9c9e4", + "zh:d7a29a2023f17b24236079789931d53662a2696b13d30140cb75dc0e693a1f94", + "zh:ddc0cad0a8ec9e5afc4f4502aed75089c3e9e0bc6da9d4b796728ef5580b94ef", + "zh:de8833a1a0a726401380e52302892de782dddb7efa51122c33104dde8e119561", + "zh:dee864f90327b149d126d603c5ed58cc196682153ebd1bfa73dd67398f6cbe38", + "zh:f63ef9950ebb06fa1daad784a3d0f342803f65404107186bdadb3198ce4d03b2", + "zh:f6d2414fec3fcaefc80cbe8e49647221dbbcfd2fe1b0f7619bd68d06c93c30f4", + "zh:fb659b5a21ba0ad9ec1c7484f167c51c752abea84dd27e726cc3567e7006e99e", + ] +} diff --git a/terraform/test-configurations/4_openshift-setup/keycloak-route.yaml b/terraform/test-configurations/4_openshift-setup/keycloak-route.yaml new file mode 100644 index 000000000..3d1ed13d9 --- /dev/null +++ b/terraform/test-configurations/4_openshift-setup/keycloak-route.yaml @@ -0,0 +1,17 @@ +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: keycloak + namespace: keycloak +spec: + host: ${hostname} + to: + kind: Service + name: keycloak + weight: 100 + port: + targetPort: http + wildcardPolicy: None + tls: + termination: edge + insecureEdgeTerminationPolicy: Redirect diff --git a/terraform/test-configurations/4_openshift-setup/keycloak-values.yaml b/terraform/test-configurations/4_openshift-setup/keycloak-values.yaml new file mode 100644 index 000000000..3791d3bda --- /dev/null +++ b/terraform/test-configurations/4_openshift-setup/keycloak-values.yaml @@ -0,0 +1,46 @@ +fullnameOverride: "keycloak" +httpRelativePath: "/" + +auth: + adminUser: admin + +image: + # Configure using repository bitnamilegacy because bitnami has removed the bitnami repository in favor of a paid service + repository: bitnamilegacy/keycloak + +postgresql: + enabled: true + # Configure using repository bitnamilegacy because bitnami has removed the bitnami repository in favor of a paid service + image: + repository: bitnamilegacy/postgresql + volumePermissions: + image: + repository: bitnamilegacy/os-shell + enabled: false + metrics: + image: + repository: bitnamilegacy/bitnami-exporter + primary: + podSecurityContext: + enabled: false + containerSecurityContext: + enabled: false + +# Keycloak is exposed via an OpenShift Route, not a Kubernetes Ingress +ingress: + enabled: false + +service: + type: ClusterIP + +# Keycloak sits behind an OpenShift Route with edge TLS termination, +# so it receives plain HTTP internally +proxy: edge + +# OpenShift assigns UIDs from a namespace-specific range via its Security Context +# Constraints (SCC). The Bitnami chart defaults (fsGroup: 1001, runAsUser: 1001) +# are rejected by the restricted SCC. Disabling them lets OpenShift take over. +podSecurityContext: + enabled: false +containerSecurityContext: + enabled: false diff --git a/terraform/test-configurations/4_openshift-setup/main.tf b/terraform/test-configurations/4_openshift-setup/main.tf new file mode 100644 index 000000000..4219ea24e --- /dev/null +++ b/terraform/test-configurations/4_openshift-setup/main.tf @@ -0,0 +1,150 @@ +variable "openshift_server" { + description = "OpenShift API server URL (e.g. https://api.crc.testing:6443)" + default = "https://api.crc.testing:6443" +} + +variable "openshift_token" { + description = "OpenShift login token (get via: oc whoami -t)" + type = string + sensitive = true +} + +variable "apps_domain" { + description = "OpenShift apps domain for Routes (e.g. apps-crc.testing)" + default = "apps-crc.testing" +} + +variable "keycloak_admin_password" { + description = "Keycloak Admin Password" + sensitive = true + default = "admin" +} + +variable "postgres_postgres_password" { + description = "Keycloak Postgres DB Postgres (Admin) Password" + sensitive = true + default = "admin" +} + +variable "postgres_password" { + description = "Keycloak Postgres DB Password" + sensitive = true + default = "admin" +} + +provider "kubernetes" { + host = var.openshift_server + token = var.openshift_token + insecure = true # CRC uses self-signed certs +} + +provider "helm" { + kubernetes = { + host = var.openshift_server + token = var.openshift_token + insecure = true + } +} + +provider "kubectl" { + load_config_file = false + host = var.openshift_server + token = var.openshift_token + insecure = true +} + +# cert-manager is required by the theia-cloud-crds chart for the conversion webhook certificate +resource "helm_release" "cert-manager" { + name = "cert-manager" + repository = "https://charts.jetstack.io" + chart = "cert-manager" + version = "v1.17.4" + namespace = "cert-manager" + create_namespace = true + + set = [ + { + name = "installCRDs" + value = "true" + } + ] +} + +resource "helm_release" "keycloak" { + depends_on = [helm_release.cert-manager] + name = "keycloak" + repository = "https://charts.bitnami.com/bitnami" + chart = "keycloak" + version = "15.1.8" + namespace = "keycloak" + create_namespace = true + + values = [ + file("${path.module}/keycloak-values.yaml") + ] + + set = [ + { + name = "ingress.hostname" + value = "keycloak.${var.apps_domain}" + } + ] + set_sensitive = [ + { + name = "auth.adminPassword" + value = var.keycloak_admin_password + }, + { + name = "postgresql.auth.postgresPassword" + value = var.postgres_postgres_password + }, + { + name = "postgresql.auth.password" + value = var.postgres_password + } + ] +} + +resource "kubectl_manifest" "keycloak_route" { + depends_on = [helm_release.keycloak] + yaml_body = templatefile("${path.module}/keycloak-route.yaml", { + hostname = "keycloak.${var.apps_domain}" + }) +} + +# Wait for Keycloak to be fully ready before configuring the realm. +# The Route may exist before Keycloak pods are ready to serve traffic. +resource "null_resource" "wait_for_keycloak" { + depends_on = [kubectl_manifest.keycloak_route] + provisioner "local-exec" { + command = "until curl -sf -o /dev/null -k https://keycloak.${var.apps_domain}/realms/master; do echo 'Waiting for Keycloak...'; sleep 5; done" + } +} + +provider "keycloak" { + client_id = "admin-cli" + username = "admin" + password = var.keycloak_admin_password + url = "https://keycloak.${var.apps_domain}/" + tls_insecure_skip_verify = true # CRC uses self-signed certs + initial_login = false + client_timeout = 60 +} + +module "keycloak" { + source = "../../modules/keycloak" + depends_on = [null_resource.wait_for_keycloak] + + hostname = "keycloak.${var.apps_domain}" + keycloak_test_user_foo_password = "foo" + keycloak_test_user_bar_password = "bar" + valid_redirect_uri = "*" +} + +resource "keycloak_group_memberships" "admin_group_memberships" { + realm_id = module.keycloak.realm.id + group_id = module.keycloak.admin_group.id + members = [ + module.keycloak.test_users.foo.username + ] +} diff --git a/terraform/test-configurations/4_openshift-setup/outputs.tf b/terraform/test-configurations/4_openshift-setup/outputs.tf new file mode 100644 index 000000000..8d655fbbf --- /dev/null +++ b/terraform/test-configurations/4_openshift-setup/outputs.tf @@ -0,0 +1,20 @@ +output "host" { + description = "The OpenShift API server URL." + value = var.openshift_server +} + +output "token" { + description = "The OpenShift API token." + value = var.openshift_token + sensitive = true +} + +output "hostname" { + description = "Base hostname for Theia Cloud routes." + value = var.apps_domain +} + +output "keycloak" { + description = "Keycloak URL for the OpenShift cluster." + value = "https://keycloak.${var.apps_domain}/" +} diff --git a/terraform/test-configurations/4_openshift-setup/versions.tf b/terraform/test-configurations/4_openshift-setup/versions.tf new file mode 100644 index 000000000..36e716211 --- /dev/null +++ b/terraform/test-configurations/4_openshift-setup/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_providers { + helm = { + source = "hashicorp/helm" + version = ">= 3.0.2" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.38.0" + } + keycloak = { + source = "mrparkers/keycloak" + version = ">= 4.4.0" + } + kubectl = { + source = "gavinbunney/kubectl" + version = ">= 1.19.0" + } + null = { + source = "hashicorp/null" + version = ">= 3.0.0" + } + } + + required_version = ">= 1.12.2" +} diff --git a/terraform/test-configurations/5-01_openshift_monitor/.terraform.lock.hcl b/terraform/test-configurations/5-01_openshift_monitor/.terraform.lock.hcl new file mode 100644 index 000000000..5acb0b46f --- /dev/null +++ b/terraform/test-configurations/5-01_openshift_monitor/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/helm" { + version = "3.1.1" + constraints = ">= 3.0.2" + hashes = [ + "h1:5b2ojWKT0noujHiweCds37ZreRFRQLNaErdJLusJN88=", + "zh:1a6d5ce931708aec29d1f3d9e360c2a0c35ba5a54d03eeaff0ce3ca597cd0275", + "zh:3411919ba2a5941801e677f0fea08bdd0ae22ba3c9ce3309f55554699e06524a", + "zh:81b36138b8f2320dc7f877b50f9e38f4bc614affe68de885d322629dd0d16a29", + "zh:95a2a0a497a6082ee06f95b38bd0f0d6924a65722892a856cfd914c0d117f104", + "zh:9d3e78c2d1bb46508b972210ad706dd8c8b106f8b206ecf096cd211c54f46990", + "zh:a79139abf687387a6efdbbb04289a0a8e7eaca2bd91cdc0ce68ea4f3286c2c34", + "zh:aaa8784be125fbd50c48d84d6e171d3fb6ef84a221dbc5165c067ce05faab4c8", + "zh:afecd301f469975c9d8f350cc482fe656e082b6ab0f677d1a816c3c615837cc1", + "zh:c54c22b18d48ff9053d899d178d9ffef7d9d19785d9bf310a07d648b7aac075b", + "zh:db2eefd55aea48e73384a555c72bac3f7d428e24147bedb64e1a039398e5b903", + "zh:ee61666a233533fd2be971091cecc01650561f1585783c381b6f6e8a390198a4", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/terraform/test-configurations/5-01_openshift_monitor/outputs.tf b/terraform/test-configurations/5-01_openshift_monitor/outputs.tf new file mode 100644 index 000000000..416483b8f --- /dev/null +++ b/terraform/test-configurations/5-01_openshift_monitor/outputs.tf @@ -0,0 +1,11 @@ +output "service" { + value = "https://service.${data.terraform_remote_state.openshift.outputs.hostname}" +} + +output "instance" { + value = "https://ws.${data.terraform_remote_state.openshift.outputs.hostname}" +} + +output "landing" { + value = "https://try.${data.terraform_remote_state.openshift.outputs.hostname}" +} diff --git a/terraform/test-configurations/5-01_openshift_monitor/theia_cloud.tf b/terraform/test-configurations/5-01_openshift_monitor/theia_cloud.tf new file mode 100644 index 000000000..af931b837 --- /dev/null +++ b/terraform/test-configurations/5-01_openshift_monitor/theia_cloud.tf @@ -0,0 +1,99 @@ +data "terraform_remote_state" "openshift" { + backend = "local" + + config = { + path = "${path.module}/../4_openshift-setup/terraform.tfstate" + } +} + +provider "helm" { + kubernetes = { + host = data.terraform_remote_state.openshift.outputs.host + token = data.terraform_remote_state.openshift.outputs.token + insecure = true + } +} + +resource "helm_release" "theia-cloud-base" { + name = "theia-cloud-base" + chart = "../../../../theia-cloud-helm/charts/theia-cloud-base" + namespace = "theia-cloud" + create_namespace = true + + set = [ + { + name = "operator.cloudProvider" + value = "OPENSHIFT" + }, + { + name = "issuerprod.enable" + value = "false" + } + ] +} + +resource "helm_release" "theia-cloud-crds" { + depends_on = [helm_release.theia-cloud-base] + + name = "theia-cloud-crds" + chart = "../../../../theia-cloud-helm/charts/theia-cloud-crds" + namespace = "theia-cloud" + create_namespace = true +} + +resource "helm_release" "theia-cloud" { + depends_on = [helm_release.theia-cloud-crds] + + name = "theia-cloud" + chart = "../../../../theia-cloud-helm/charts/theia-cloud" + namespace = "theia-cloud" + create_namespace = true + + values = [ + file("${path.module}/../../values/valuesOpenShiftMonitor.yaml") + ] + + set = [ + { + name = "hosts.configuration.baseHost" + value = data.terraform_remote_state.openshift.outputs.hostname + }, + { + name = "operator.cloudProvider" + value = "OPENSHIFT" + }, + { + name = "keycloak.enable" + value = "true" + }, + { + name = "keycloak.authUrl" + value = "https://keycloak.${data.terraform_remote_state.openshift.outputs.hostname}/" + }, + # Uncomment to use locally built images (see openshift.md) + # { + # name = "operator.image" + # value = "image-registry.openshift-image-registry.svc:5000/theia-cloud/theia-cloud-operator:dev" + # }, + # { + # name = "operator.imagePullPolicy" + # value = "Always" + # }, + # { + # name = "service.image" + # value = "image-registry.openshift-image-registry.svc:5000/theia-cloud/theia-cloud-service:dev" + # }, + # { + # name = "service.imagePullPolicy" + # value = "Always" + # }, + # { + # name = "landingPage.image" + # value = "image-registry.openshift-image-registry.svc:5000/theia-cloud/theia-cloud-landing-page:dev" + # }, + # { + # name = "landingPage.imagePullPolicy" + # value = "Always" + # }, + ] +} diff --git a/terraform/test-configurations/5-01_openshift_monitor/versions.tf b/terraform/test-configurations/5-01_openshift_monitor/versions.tf new file mode 100644 index 000000000..2cc802279 --- /dev/null +++ b/terraform/test-configurations/5-01_openshift_monitor/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + helm = { + source = "hashicorp/helm" + version = ">= 3.0.2" + } + } + + required_version = ">= 1.12.2" +} diff --git a/terraform/test-configurations/5-02_openshift_ci/outputs.tf b/terraform/test-configurations/5-02_openshift_ci/outputs.tf new file mode 100644 index 000000000..416483b8f --- /dev/null +++ b/terraform/test-configurations/5-02_openshift_ci/outputs.tf @@ -0,0 +1,11 @@ +output "service" { + value = "https://service.${data.terraform_remote_state.openshift.outputs.hostname}" +} + +output "instance" { + value = "https://ws.${data.terraform_remote_state.openshift.outputs.hostname}" +} + +output "landing" { + value = "https://try.${data.terraform_remote_state.openshift.outputs.hostname}" +} diff --git a/terraform/test-configurations/5-02_openshift_ci/theia_cloud.tf b/terraform/test-configurations/5-02_openshift_ci/theia_cloud.tf new file mode 100644 index 000000000..9cd1f5408 --- /dev/null +++ b/terraform/test-configurations/5-02_openshift_ci/theia_cloud.tf @@ -0,0 +1,132 @@ +# OpenShift CI configuration. Mirror of `terraform/ci-configurations/` +# for the [E2E Tests] OpenShift workflow. Reads OpenShift connection +# state from `4_openshift-setup`, installs the three Theia Cloud helm +# releases, and emits the e2e AppDefinition CRs via the shared +# `theia-cloud-ci-appdefinitions` module. + +data "terraform_remote_state" "openshift" { + backend = "local" + + config = { + path = "${path.module}/../4_openshift-setup/terraform.tfstate" + } +} + +variable "enable_keycloak" { + description = "Whether the theia-cloud chart enables Keycloak login. Matches the e2e `keycloak` matrix axis." + type = bool + default = true +} + +variable "use_ephemeral_storage" { + description = "Whether sessions use ephemeral storage (no PVC). Matches the e2e `ephemeral` matrix axis." + type = bool + default = true +} + +variable "eager_start" { + description = "Whether to enable eager start for sessions. Matches the e2e `eagerStart` matrix axis." + type = bool + default = false +} + +provider "helm" { + kubernetes = { + host = data.terraform_remote_state.openshift.outputs.host + token = data.terraform_remote_state.openshift.outputs.token + insecure = true + } +} + +provider "kubectl" { + host = data.terraform_remote_state.openshift.outputs.host + token = data.terraform_remote_state.openshift.outputs.token + insecure = true + load_config_file = false +} + +resource "helm_release" "theia-cloud-base" { + name = "theia-cloud-base" + chart = "../../../../theia-cloud-helm/charts/theia-cloud-base" + namespace = "theia-cloud" + create_namespace = true + + set = [ + { + name = "operator.cloudProvider" + value = "OPENSHIFT" + }, + { + name = "issuerprod.enable" + value = "false" + } + ] +} + +resource "helm_release" "theia-cloud-crds" { + depends_on = [helm_release.theia-cloud-base] + + name = "theia-cloud-crds" + chart = "../../../../theia-cloud-helm/charts/theia-cloud-crds" + namespace = "theia-cloud" + create_namespace = true + + set = [ + { + name = "conversion.image" + value = "localhost/theia-cloud-conversion-webhook:microshift-ci-e2e" + }, + { + name = "conversion.imagePullPolicy" + value = "Never" + } + ] +} + +resource "helm_release" "theia-cloud" { + depends_on = [helm_release.theia-cloud-crds] + + name = "theia-cloud" + chart = "../../../../theia-cloud-helm/charts/theia-cloud" + namespace = "theia-cloud" + create_namespace = true + + values = [ + file("${path.module}/../../values/valuesE2ECI-base.yaml"), + file("${path.module}/../../values/valuesE2ECI-openshift.yaml"), + ] + + set = [ + { + name = "hosts.configuration.baseHost" + value = data.terraform_remote_state.openshift.outputs.hostname + }, + { + name = "keycloak.enable" + value = tostring(var.enable_keycloak) + }, + { + name = "keycloak.authUrl" + value = "https://keycloak.${data.terraform_remote_state.openshift.outputs.hostname}/" + }, + { + name = "landingPage.ephemeralStorage" + value = tostring(var.use_ephemeral_storage) + }, + { + name = "operator.eagerStart" + value = tostring(var.eager_start) + }, + ] +} + +module "appdefinitions" { + source = "../../modules/theia-cloud-ci-appdefinitions" + + depends_on = [helm_release.theia-cloud] + + image_theia = "localhost/theia-cloud-activity-demo-theia:microshift-ci-e2e" + image_vscode = "localhost/theia-cloud-activity-demo:microshift-ci-e2e" + image_pull_policy = "Never" + eager_start = var.eager_start +} diff --git a/terraform/test-configurations/5-02_openshift_ci/versions.tf b/terraform/test-configurations/5-02_openshift_ci/versions.tf new file mode 100644 index 000000000..66c3a9854 --- /dev/null +++ b/terraform/test-configurations/5-02_openshift_ci/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + helm = { + source = "hashicorp/helm" + version = ">= 3.0.2" + } + kubectl = { + source = "gavinbunney/kubectl" + version = ">= 1.19.0" + } + } + + required_version = ">= 1.12.2" +} diff --git a/terraform/test-configurations/openshift.md b/terraform/test-configurations/openshift.md new file mode 100644 index 000000000..bc6f0d760 --- /dev/null +++ b/terraform/test-configurations/openshift.md @@ -0,0 +1,296 @@ +# OpenShift Local Development Setup + +This guide walks you through setting up a local OpenShift cluster using [Red Hat OpenShift Local](https://console.redhat.com/openshift/create/local) and deploying Theia Cloud with OpenShift Route support. + +> **CI note:** the `[E2E Tests] OpenShift` GitHub Actions workflow +> (`.github/workflows/e2e-tests-openshift.yml`) does NOT use OpenShift +> Local — GitHub-hosted runners do not support nested virtualisation. +> Instead, CI runs **MicroShift in a privileged Docker container** on a +> stock `ubuntu-22.04` runner. RPMs come from `mirror.openshift.com` +> (community/anonymous, no Red Hat subscription); the only secret +> needed is the Red Hat pull secret (`REDHAT_PULL_SECRET`). Container +> images are handed off via `docker save | skopeo copy` directly into +> MicroShift's CRI-O containers-storage, no registry required. See +> `.github/microshift-ci/`, `terraform/test-configurations/5-02_openshift_ci/`, +> and `terraform/values/valuesE2ECI-{base,openshift}.yaml` for the CI +> plumbing. Local development uses `5-01_openshift_monitor` with +> OpenShift Local / CRC as described below. + +## Prerequisites + +* **OS**: Ubuntu 24.04, or another supported Linux distribution +* **virtiofsd**: Required by CRC on Ubuntu, install via `sudo apt install virtiofsd` +* **OpenShift Local**: Download and install from [https://console.redhat.com/openshift/create/local](https://console.redhat.com/openshift/create/local) +* **Pull secret**: Obtain from [https://console.redhat.com/openshift/create/local](https://console.redhat.com/openshift/create/local), requires a free Red Hat account +* **Minimum resources**: 4 CPUs, 12 GiB RAM, 50 GiB disk, more than the OpenShift Local defaults to run Theia Cloud + +## Step 1: Start OpenShift Local + +OpenShift Local is the local OpenShift equivalent of minikube. Unlike minikube, there is no Terraform provider for OpenShift Local; the cluster is managed via the `crc` CLI. + +```bash +# Download and install OpenShift Local following the instructions at: +# https://console.redhat.com/openshift/create/local + +# Run the setup, installs required system components +crc setup + +# Configure resources, higher than OpenShift Local defaults for Theia Cloud +crc config set cpus 4 +crc config set memory 12288 # 12 GiB +crc config set disk-size 50 # 50 GiB + +# Start the cluster, first start downloads the OpenShift VM image +crc start +``` + +Once the cluster is running, configure CLI access and verify: + +```bash +# Set up oc CLI access +eval $(crc oc-env) + +# Get the login credentials (CRC generates a random kubeadmin password) +crc console --credentials + +# Log in as cluster admin using the password from above +oc login -u kubeadmin -p https://api.crc.testing:6443 + +# Verify cluster is running +oc get nodes +oc get routes -A # should show OpenShift console routes + +# Get the apps domain, needed for Theia Cloud Route hostnames +# OpenShift Local uses: apps-crc.testing +``` + +## Step 2: Install Dependencies + +The `4_openshift-setup` terraform configuration reads connection details from the running cluster, installs all dependencies (cert-manager, Keycloak with a TheiaCloud realm), and outputs them for downstream terraform steps. + +No external DNS or ingress controller is needed — OpenShift Local configures a local DNS resolver so routes are accessible from the host at `*.apps-crc.testing`, and OpenShift has a built-in HAProxy-based router that handles routes natively. OpenShift uses token-based auth instead of client certificates. + +If you prefer using `kubectl` over `oc`, the `oc login` command in Step 1 already configures the kubeconfig context. Verify with: + +```bash +kubectl cluster-info +``` + +```bash +cd terraform/test-configurations/4_openshift-setup + +# Get your login token +oc login -u kubeadmin https://api.crc.testing:6443 +TOKEN=$(oc whoami -t) + +# Initialize and apply +terraform init +terraform apply -var="openshift_token=$TOKEN" +``` + +The first run may take several minutes while cert-manager, Keycloak, and PostgreSQL start up. + +The configuration accepts the following variables: + +| Variable | Default | Description | +| -------------------------- | ------------------------------ | ---------------------------------------- | +| `openshift_server` | `https://api.crc.testing:6443` | OpenShift API server URL | +| `openshift_token` | required | Login token, get via `oc whoami -t` | +| `apps_domain` | `apps-crc.testing` | Apps domain for route hostnames | +| `keycloak_admin_password` | `admin` | Keycloak admin password | +| `postgres_postgres_password` | `admin` | PostgreSQL admin password for Keycloak | +| `postgres_password` | `admin` | PostgreSQL user password for Keycloak | + +After apply, verify the dependencies are running: + +```bash +# cert-manager +oc get pods -n cert-manager + +# Keycloak +oc get pods -n keycloak +oc get routes -n keycloak + +# Keycloak realm +curl -sk https://keycloak.apps-crc.testing/realms/TheiaCloud/.well-known/openid-configuration +``` + +## Step 3: Deploy Theia Cloud + +With all dependencies installed by `4_openshift-setup`, deploy Theia Cloud: + +```bash +cd terraform/test-configurations/5-01_openshift_monitor + +terraform init +terraform apply +``` + +This installs (in order): + +* `theia-cloud-base`, RBAC, cluster roles, and cert-manager issuers +* `theia-cloud-crds`, Custom Resource Definitions and conversion webhook +* `theia-cloud`, operator, landing page, and service with OpenShift route configuration and Keycloak authentication + +## Step 4: Verify + +After deployment, verify the installation: + +```bash +# Check routes are created, no ingress resources should exist +oc get routes -n theia-cloud +oc get ingress -n theia-cloud + +# Check the session ServiceAccount and SCC RoleBinding +oc get sa theia-cloud-sessions -n theia-cloud +oc get rolebindings theia-cloud-sessions-anyuid -n theia-cloud + +# Access the landing page (should redirect to Keycloak login) +curl -sk -o /dev/null -w "%{http_code}" https://try.apps-crc.testing + +# Login test: navigate to https://try.apps-crc.testing in a browser +# Should redirect to Keycloak login page +# Login with foo/foo +# Should redirect back and start a session +``` + +OpenShift Local exposes routes at `*.apps-crc.testing`. The expected Theia Cloud routes are: + +| Component | Hostname | +| ------------ | --------------------------------- | +| Landing page | `try.apps-crc.testing` | +| Service | `service.apps-crc.testing` | +| Sessions | `ws-.apps-crc.testing` | +| Keycloak | `keycloak.apps-crc.testing` | + +## Step 5: Teardown + +```bash +# Destroy Theia Cloud installation, reverse order +cd terraform/test-configurations/5-01_openshift_monitor +terraform destroy + +cd ../4_openshift-setup +terraform destroy + +# Stop or delete the OpenShift Local cluster +crc stop # pause the cluster, preserves state +crc delete # remove the cluster entirely +``` + +## Appendix A: Building and Pushing Custom Images + +When developing locally, you can build custom Theia Cloud images and push them to the CRC internal image registry. This avoids the need for an external registry. + +### Registry Overview + +OpenShift Local ships with an internal image registry. It is exposed externally via a Route at `default-route-openshift-image-registry.apps-crc.testing` (uses a self-signed certificate). Inside the cluster, pods pull from `image-registry.openshift-image-registry.svc:5000`. + +There are two addresses to keep in mind: + +| Address | Usage | +| ------- | ----- | +| `default-route-openshift-image-registry.apps-crc.testing` | External, used to push images from the host | +| `image-registry.openshift-image-registry.svc:5000` | Internal, used by pods to pull images | + +### Logging in to the Registry + +The registry uses the CRC self-signed CA, so docker/podman needs to trust it or skip verification. + +With podman: + +```bash +oc login -u kubeadmin https://api.crc.testing:6443 +podman login --tls-verify=false -u kubeadmin -p $(oc whoami -t) default-route-openshift-image-registry.apps-crc.testing +``` + +With docker, you need to trust the CRC CA certificate. The `insecure-registries` daemon option alone is not sufficient because Docker's OAuth token exchange still verifies TLS. Extract the CRC CA and install it as a system-trusted certificate: + +```bash +# Extract the CRC ingress CA certificate +oc extract secret/router-ca --keys=tls.crt -n openshift-ingress-operator --confirm +sudo cp tls.crt /usr/local/share/ca-certificates/crc-ingress-ca.crt +sudo update-ca-certificates +sudo systemctl restart docker +``` + +Then log in: + +```bash +docker login -u kubeadmin -p $(oc whoami -t) default-route-openshift-image-registry.apps-crc.testing +``` + +### Building and Pushing Images + +Make sure you have logged in to the registry first (see [Logging in to the Registry](#logging-in-to-the-registry)). + +The OpenShift internal registry requires the target namespace and ImageStreams to exist before you can push images. Create them if they do not exist yet: + +```bash +oc new-project theia-cloud || true +oc create imagestream theia-cloud-operator -n theia-cloud +oc create imagestream theia-cloud-service -n theia-cloud +oc create imagestream theia-cloud-landing-page -n theia-cloud +``` + +All docker builds run from the `theia-cloud` repository root. The tag format is `default-route-openshift-image-registry.apps-crc.testing//:`. Use the `theia-cloud` namespace to match the Helm deployment. + +| Component | Dockerfile | Build context | Helm value to override | +| ------------ | ------------------------------------- | ------------- | ---------------------- | +| Operator | `dockerfiles/operator/Dockerfile` | repo root (`.`) | `operator.image` | +| Service | `dockerfiles/service/Dockerfile` | repo root (`.`) | `service.image` | +| Landing Page | `dockerfiles/landing-page/Dockerfile` | repo root (`.`) | `landingPage.image` | + +All commands below assume you are in the `theia-cloud` repository root. + +Operator: + +```bash +EXT_REG=default-route-openshift-image-registry.apps-crc.testing + +docker build -f dockerfiles/operator/Dockerfile -t $EXT_REG/theia-cloud/theia-cloud-operator:dev . +docker push $EXT_REG/theia-cloud/theia-cloud-operator:dev +``` + +Service: + +```bash +docker build -f dockerfiles/service/Dockerfile -t $EXT_REG/theia-cloud/theia-cloud-service:dev . +docker push $EXT_REG/theia-cloud/theia-cloud-service:dev +``` + +Landing Page: + +```bash +docker build -f dockerfiles/landing-page/Dockerfile -t $EXT_REG/theia-cloud/theia-cloud-landing-page:dev . +docker push $EXT_REG/theia-cloud/theia-cloud-landing-page:dev +``` + +### Overriding Images in the Helm Deployment + +When deploying via terraform (`5-01_openshift_monitor/theia_cloud.tf`), add `set` blocks to override the image. Inside the cluster, use the internal registry address (not the external route): + +```hcl +{ + name = "operator.image" + value = "image-registry.openshift-image-registry.svc:5000/theia-cloud/theia-cloud-operator:dev" +}, +{ + name = "operator.imagePullPolicy" + value = "Always" +} +``` + +Set `imagePullPolicy` to `Always` during development so that new pushes with the same tag are picked up. + +See the commented-out examples in `5-01_openshift_monitor/theia_cloud.tf` for all three images. + +### Verifying the Image Was Pushed + +```bash +# List images in the theia-cloud namespace +oc get imagestreams -n theia-cloud + +# Check a specific image +oc get imagestreamtag -n theia-cloud theia-cloud-operator:dev +``` diff --git a/terraform/test-configurations/test.md b/terraform/test-configurations/test.md index ef5d480c3..16d540cd4 100644 --- a/terraform/test-configurations/test.md +++ b/terraform/test-configurations/test.md @@ -16,14 +16,31 @@ Run `terraform init` and `terraform apply` in both directories below: terraform state rm kubernetes_persistent_volume_v1.minikube ``` +## OpenShift Setup + +For testing on OpenShift (using OpenShift Local / CRC), see [openshift.md](./openshift.md). + +- `4_openshift-setup` captures OpenShift connection details and installs all dependencies (cert-manager, Keycloak with a TheiaCloud realm) for downstream terraform steps. + ## Theia Cloud Setups Pick an installation in one of below directories and run `terraform init` and `terraform apply`. -- `2-01_try-now` installs a local version of -- `2-02_monitor` installs a setup that allows to test the monitor (VSCode extension or Theia extension based) with and without authentication -- `2-03_try-now_paths` installs a local version of using paths instead of subdomains. -- `2-04_try-now_paths_eager-start` installs a local version of using paths and eager instead of lazy starting of pods. See its [README](./2-04_try-now_paths_eager-start/README.md) for more details. +- `3-01_try-now` installs a local version of +- `3-02_monitor` installs a setup that allows to test the monitor (VSCode extension or Theia extension based) with and without authentication +- `3-03_try-now_paths` installs a local version of using paths instead of subdomains. +- `3-04_try-now_paths_eager-start` installs a local version of using paths and eager instead of lazy starting of pods. See its [README](./3-04_try-now_paths_eager-start/README.md) for more details. + +### OpenShift Setups + +These configurations deploy Theia Cloud on an OpenShift cluster (using Routes instead of Ingress). Run `4_openshift-setup` first, see [openshift.md](./openshift.md). + +- `5-01_openshift_monitor` installs Theia Cloud with OpenShift Route support, activity monitoring, and Keycloak authentication, using `valuesOpenShiftMonitor.yaml`. **Local-dev only** — not used in CI. +- `5-02_openshift_ci` is the CI counterpart of `ci-configurations/`: same chart releases plus the e2e AppDefinition CRs, but driven by `valuesE2ECI-base.yaml` + `valuesE2ECI-openshift.yaml`. Used exclusively by `[E2E Tests] OpenShift`. + +#### Continuous Integration + +The `[E2E Tests] OpenShift` workflow (`.github/workflows/e2e-tests-openshift.yml`) exercises the OpenShift code path on every push to `main` and weekly. It uses **MicroShift in a privileged Docker container** on a stock GitHub-hosted `ubuntu-22.04` runner instead of OpenShift Local, because GH-hosted runners do not support nested virtualisation. RPMs come from `mirror.openshift.com` (community/anonymous, no Red Hat subscription); the only secret needed is the Red Hat pull secret for the OpenShift control-plane container images (`REDHAT_PULL_SECRET`). The container-image hand-off uses `docker save | skopeo copy docker-archive:- containers-storage:` rather than a registry. Sources: `.github/microshift-ci/` (Dockerfile, `start.sh`, `entrypoint.sh`), `terraform/test-configurations/5-02_openshift_ci/`, and `terraform/values/valuesE2ECI-{base,openshift}.yaml` (the shared CI values; see also `terraform/values/valuesE2ECI-minikube.yaml` for the minikube counterpart). ## Getting a Keycloak access token @@ -31,6 +48,8 @@ To test the service's APIs via a REST client such as Postman or Bruno, you need You can get such a token from Keycloak with a simple CLI call using `curl` and `jq`. This call gets the token for test user `foo`. +Minikube: + ```sh curl -s --insecure --request POST \ --url https://$(minikube ip).nip.io/keycloak/realms/TheiaCloud/protocol/openid-connect/token \ @@ -40,3 +59,15 @@ curl -s --insecure --request POST \ --data username=foo \ --data password=foo | jq -r '.access_token' ``` + +OpenShift Local: + +```sh +curl -s --insecure --request POST \ + --url https://keycloak.apps-crc.testing/realms/TheiaCloud/protocol/openid-connect/token \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data grant_type=password \ + --data client_id=theia-cloud \ + --data username=foo \ + --data password=foo | jq -r '.access_token' +``` diff --git a/terraform/ci-configurations/valuesE2ECI.yaml b/terraform/values/valuesE2ECI-base.yaml similarity index 57% rename from terraform/ci-configurations/valuesE2ECI.yaml rename to terraform/values/valuesE2ECI-base.yaml index e99ab6e57..856e4b847 100644 --- a/terraform/ci-configurations/valuesE2ECI.yaml +++ b/terraform/values/valuesE2ECI-base.yaml @@ -1,4 +1,9 @@ -imagePullPolicy: IfNotPresent +# Shared CI Helm values for the e2e workflows. Combined with one of: +# - valuesE2ECI-minikube.yaml (consumed by terraform/ci-configurations/) +# - valuesE2ECI-openshift.yaml (consumed by terraform/test-configurations/5-02_openshift_ci/) +# +# The platform overlay is applied AFTER this base, so any value set in +# both wins from the overlay. app: name: "Theia with Theia Extension Monitor" @@ -11,12 +16,8 @@ demoApplication: hosts: usePaths: false - allWildcardInstances: ["*.webview."] - configuration: - baseHost: 192.168.39.173.nip.io landingPage: - image: theiacloud/theia-cloud-landing-page:minikube-ci-e2e appDefinition: "theia-cloud-monitor-theia-popup" ephemeralStorage: true additionalApps: @@ -31,30 +32,12 @@ landingPage: theia-cloud-demo: label: "Theia Cloud Demo (2min timeout)" -keycloak: - enable: false - authUrl: "https://keycloak.url/auth/" - operator: - image: theiacloud/theia-cloud-operator:minikube-ci-e2e - cloudProvider: "MINIKUBE" bandwidthLimiter: "WONDERSHAPER" - wondershaperImage: theiacloud/theia-cloud-wondershaper:minikube-ci-e2e sessionsPerUser: "1" - storageClassName: "default" requestedStorage: "250Mi" replicas: 2 -service: - image: theiacloud/theia-cloud-service:minikube-ci-e2e - -ingress: - certManagerAnnotations: true - clusterIssuer: theia-cloud-selfsigned-issuer - theiaCloudCommonName: true - tls: true - addTLSSecretName: true - monitor: enable: true activityTracker: diff --git a/terraform/values/valuesE2ECI-minikube.yaml b/terraform/values/valuesE2ECI-minikube.yaml new file mode 100644 index 000000000..19d5be031 --- /dev/null +++ b/terraform/values/valuesE2ECI-minikube.yaml @@ -0,0 +1,33 @@ +# Minikube-specific overlay for the [E2E Tests] Minikube workflow. +# Layered on top of valuesE2ECI-base.yaml. See e2e_tests.tf in +# terraform/ci-configurations/. + +imagePullPolicy: IfNotPresent + +hosts: + allWildcardInstances: ["*.webview."] + configuration: + baseHost: 192.168.39.173.nip.io + +landingPage: + image: theiacloud/theia-cloud-landing-page:minikube-ci-e2e + +keycloak: + enable: false + authUrl: "https://keycloak.url/auth/" + +operator: + image: theiacloud/theia-cloud-operator:minikube-ci-e2e + cloudProvider: "MINIKUBE" + wondershaperImage: theiacloud/theia-cloud-wondershaper:minikube-ci-e2e + storageClassName: "default" + +service: + image: theiacloud/theia-cloud-service:minikube-ci-e2e + +ingress: + certManagerAnnotations: true + clusterIssuer: theia-cloud-selfsigned-issuer + theiaCloudCommonName: true + tls: true + addTLSSecretName: true diff --git a/terraform/values/valuesE2ECI-openshift.yaml b/terraform/values/valuesE2ECI-openshift.yaml new file mode 100644 index 000000000..13d4c6e4a --- /dev/null +++ b/terraform/values/valuesE2ECI-openshift.yaml @@ -0,0 +1,60 @@ +# OpenShift-specific overlay for the [E2E Tests] OpenShift workflow. +# Layered on top of valuesE2ECI-base.yaml. See theia_cloud.tf in +# terraform/test-configurations/5-02_openshift_ci/. +# +# Image references use `localhost/...:microshift-ci-e2e` because the CI +# workflow transfers each image into MicroShift's CRI-O via skopeo, +# under the local "localhost/" namespace, not via Docker Hub. With +# `imagePullPolicy: Never` the kubelet uses the locally imported tag +# exactly without any registry contact. +# +# `hosts.configuration.{landing,service,instance}` overrides the chart +# defaults (`trynow` / `servicex` / `instances`) so the workflow's +# wildcard DNS, the route-existence check, and playwright.config.ts's +# getBaseURL() can all assume the `try.` / `service.` / `ws-` prefixes. +# `hosts.configuration.baseHost` and `keycloak.*` are NOT set here; +# terraform `set` blocks override them at apply time from the +# 4_openshift-setup remote state and matrix axes. + +imagePullPolicy: Never + +hosts: + configuration: + landing: try + service: service + instance: ws + +landingPage: + image: localhost/theia-cloud-landing-page:microshift-ci-e2e + +operator: + image: localhost/theia-cloud-operator:microshift-ci-e2e + cloudProvider: "OPENSHIFT" + # OpenShift SCC `anyuid` allows the fixed UID used by the session + # images, but it does not allow adding NET_ADMIN. The base CI values + # use WONDERSHAPER, which injects a NET_ADMIN init container and makes + # session pods fail admission. Use the Kubernetes bandwidth annotation + # strategy for OpenShift, matching valuesOpenShiftMonitor.yaml. + bandwidthLimiter: "K8SANNOTATION" + wondershaperImage: localhost/theia-cloud-wondershaper:microshift-ci-e2e + # The chart default is `default`, but the workflow installs + # local-path-provisioner with StorageClass name `local-path` for + # persistent workspace PVCs. Without this override, the non-ephemeral + # matrix row wedges on unbound PVCs with: + # `storageclass.storage.k8s.io "default" not found`. + storageClassName: "local-path" + +service: + image: localhost/theia-cloud-service:microshift-ci-e2e + protocol: https + +# The chart's image-preloading DaemonSet sets the pause container to +# memory: 8M, which is below CRI-O 1.32+'s hardcoded 12 MiB minimum +# (CRI-O rejects the sandbox with "pod set memory limit too low; +# should be at least 12582912 bytes"). MicroShift ships CRI-O 1.32, so +# the DaemonSet wedges in CrashLoop on every node. The minikube CI +# uses containerd which has no such minimum, so this only affects +# OpenShift. Disable preloading here -- it's a no-op anyway with +# preloading.images: [] from the base values. +preloading: + enable: false diff --git a/terraform/values/valuesOpenShiftMonitor.yaml b/terraform/values/valuesOpenShiftMonitor.yaml new file mode 100644 index 000000000..8049ab504 --- /dev/null +++ b/terraform/values/valuesOpenShiftMonitor.yaml @@ -0,0 +1,61 @@ +imagePullPolicy: IfNotPresent + +# Legacy app configuration (deprecated - use service.authToken instead) +app: + id: asdfghjkl + name: Theia Blueprint + +demoApplication: + name: theiacloud/theia-cloud-activity-demo:1.2.0 + pullSecret: "" + timeoutStrategy: "FIXEDTIME" + timeoutLimit: "0" + imagePullPolicy: IfNotPresent + monitor: + port: 8081 + activityTracker: + timeoutAfter: 4 + notifyAfter: 2 + +hosts: + usePaths: false + configuration: + baseHost: apps-crc.testing + service: service + landing: try + instance: ws + +landingPage: + image: theiacloud/theia-cloud-landing-page:1.2.0 + appDefinition: "theia-cloud-demo" + ephemeralStorage: true + +keycloak: + enable: false + authUrl: "" + realm: "TheiaCloud" + clientId: "theia-cloud" + clientSecret: "publicbutoauth2proxywantsasecret" + cookieSecret: "OQINaROshtE9TcZkNAm5Zs2Pv3xaWytBmc5W7sPX7ws=" + +service: + authToken: asdfghjkl + protocol: https + +operator: + eagerStart: false + bandwidthLimiter: "K8SANNOTATION" + sessionsPerUser: "3" + +ingress: + tls: true + clusterIssuer: "" + theiaCloudCommonName: false + instances: + name: "theia-cloud-demo-ws-ingress" + +monitor: + enable: true + activityTracker: + enable: true + interval: 1