From 4469a787e03d13d0da8d51d1427a992651453d15 Mon Sep 17 00:00:00 2001 From: Barys Serankou Date: Fri, 31 Jul 2026 14:10:14 +0200 Subject: [PATCH 1/2] Bump main to 4.2.0 --- VERSION | 2 +- config/manager/kustomization.yaml | 2 +- config/manager/manager.yaml | 2 +- config/soperatorchecks/kustomization.yaml | 2 +- fluxcd/environment/local/helmrelease.yaml | 2 +- fluxcd/environment/local/slurmCluster.yml | 2 +- fluxcd/environment/local/values.yaml | 8 ++++---- .../dev/bootstrap/flux-kustomization.yaml | 2 +- .../prod/bootstrap/flux-kustomization.yaml | 2 +- helm/nodeconfigurator/Chart.yaml | 4 ++-- helm/nodeconfigurator/values.yaml | 2 +- helm/nodesets/Chart.yaml | 4 ++-- helm/nodesets/values.yaml | 8 ++++---- helm/slurm-cluster-storage/Chart.yaml | 4 ++-- helm/slurm-cluster/Chart.yaml | 4 ++-- helm/slurm-cluster/values.yaml | 18 +++++++++--------- helm/soperator-activechecks/Chart.yaml | 4 ++-- helm/soperator-activechecks/values.yaml | 8 ++++---- helm/soperator-backup-config/Chart.yaml | 4 ++-- helm/soperator-crds/Chart.yaml | 4 ++-- helm/soperator-custom-configmaps/Chart.yaml | 4 ++-- helm/soperator-dcgm-exporter/Chart.yaml | 4 ++-- helm/soperator-fluxcd-bootstrap/Chart.yaml | 4 ++-- helm/soperator-fluxcd-bootstrap/values.yaml | 2 +- helm/soperator-fluxcd/Chart.yaml | 4 ++-- helm/soperator-fluxcd/values.yaml | 16 ++++++++-------- .../soperator-monitoring-dashboards/Chart.yaml | 4 ++-- helm/soperator-notifier/Chart.yaml | 4 ++-- helm/soperator/Chart.yaml | 4 ++-- helm/soperator/values.yaml | 2 +- helm/soperatorchecks/Chart.yaml | 4 ++-- helm/soperatorchecks/values.yaml | 2 +- helm/storageclasses/Chart.yaml | 4 ++-- internal/consts/version.go | 2 +- 34 files changed, 74 insertions(+), 74 deletions(-) diff --git a/VERSION b/VERSION index 9edf2a44f..6aba2b245 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.1.7 +4.2.0 diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index bd9b5d530..9745bc93a 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -3,4 +3,4 @@ resources: images: - name: controller newName: cr.eu-north1.nebius.cloud/soperator/slurm-operator - newTag: 4.1.7 + newTag: 4.2.0 diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 6955403fc..6c7608b65 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -84,7 +84,7 @@ spec: value: "false" - name: SLURM_OPERATOR_WATCH_NAMESPACES value: "*" - image: controller:4.1.7 + image: controller:4.2.0 imagePullPolicy: Always name: manager securityContext: diff --git a/config/soperatorchecks/kustomization.yaml b/config/soperatorchecks/kustomization.yaml index 5203fde9a..7597d96cf 100644 --- a/config/soperatorchecks/kustomization.yaml +++ b/config/soperatorchecks/kustomization.yaml @@ -4,7 +4,7 @@ resources: images: - name: controller newName: cr.eu-north1.nebius.cloud/soperator/soperatorchecks - newTag: 4.1.7 + newTag: 4.2.0 patches: # Serve protected metrics from the manager using controller-runtime authn/authz. - path: ./manager_metrics_patch.yaml diff --git a/fluxcd/environment/local/helmrelease.yaml b/fluxcd/environment/local/helmrelease.yaml index 8080d1308..9e9c1e46d 100644 --- a/fluxcd/environment/local/helmrelease.yaml +++ b/fluxcd/environment/local/helmrelease.yaml @@ -8,7 +8,7 @@ spec: chart: spec: chart: helm-soperator-fluxcd - version: "4.1.7" + version: "4.2.0" sourceRef: kind: HelmRepository name: soperator-fluxcd diff --git a/fluxcd/environment/local/slurmCluster.yml b/fluxcd/environment/local/slurmCluster.yml index 10f05a4de..4f84a1fcc 100644 --- a/fluxcd/environment/local/slurmCluster.yml +++ b/fluxcd/environment/local/slurmCluster.yml @@ -2,7 +2,7 @@ slurmCluster: enabled: true interval: 5m timeout: 5m - version: 4.1.7 + version: 4.2.0 namespace: soperator releaseName: soperator overrideValues: diff --git a/fluxcd/environment/local/values.yaml b/fluxcd/environment/local/values.yaml index 1b1143056..187e5c4b6 100644 --- a/fluxcd/environment/local/values.yaml +++ b/fluxcd/environment/local/values.yaml @@ -10,7 +10,7 @@ ns: soperator: enabled: true namespace: soperator-system - version: "4.1.7" + version: "4.2.0" interval: 5m timeout: 5m releaseName: soperator-controller @@ -57,7 +57,7 @@ slurmCluster: enabled: true interval: 5m timeout: 5m - version: 4.1.7 + version: 4.2.0 namespace: soperator releaseName: soperator overrideValues: @@ -277,10 +277,10 @@ backup: # NFS Server - disabled for local dev nfsServer: enabled: false - version: 4.1.7 + version: 4.2.0 overrideValues: image: - tag: 4.1.7 + tag: 4.2.0 # Notifier - disabled for local dev notifier: enabled: false diff --git a/fluxcd/environment/nebius-cloud/dev/bootstrap/flux-kustomization.yaml b/fluxcd/environment/nebius-cloud/dev/bootstrap/flux-kustomization.yaml index e1e8ba5d3..b3e0ff869 100644 --- a/fluxcd/environment/nebius-cloud/dev/bootstrap/flux-kustomization.yaml +++ b/fluxcd/environment/nebius-cloud/dev/bootstrap/flux-kustomization.yaml @@ -11,7 +11,7 @@ spec: name: nebius-cloud postBuild: substitute: - soperator_version: 4.1.7 + soperator_version: 4.2.0 path: "./fluxcd/enviroment/nebius-cloud/dev" prune: true timeout: 1m diff --git a/fluxcd/environment/nebius-cloud/prod/bootstrap/flux-kustomization.yaml b/fluxcd/environment/nebius-cloud/prod/bootstrap/flux-kustomization.yaml index fc09e36b0..87a78f244 100644 --- a/fluxcd/environment/nebius-cloud/prod/bootstrap/flux-kustomization.yaml +++ b/fluxcd/environment/nebius-cloud/prod/bootstrap/flux-kustomization.yaml @@ -10,7 +10,7 @@ spec: name: nebius-cloud postBuild: substitute: - soperator_version: 4.1.7 + soperator_version: 4.2.0 path: "./fluxcd/enviroment/nebius-cloud/prod" prune: false timeout: 1m diff --git a/helm/nodeconfigurator/Chart.yaml b/helm/nodeconfigurator/Chart.yaml index d2967be8c..9f1a6b2b0 100644 --- a/helm/nodeconfigurator/Chart.yaml +++ b/helm/nodeconfigurator/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 4.1.7 +version: 4.2.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "4.1.7" +appVersion: "4.2.0" diff --git a/helm/nodeconfigurator/values.yaml b/helm/nodeconfigurator/values.yaml index fe913c5fd..7fab3de32 100644 --- a/helm/nodeconfigurator/values.yaml +++ b/helm/nodeconfigurator/values.yaml @@ -28,7 +28,7 @@ rebooter: env: [] image: repository: "cr.eu-north1.nebius.cloud/soperator/rebooter" - tag: "4.1.7" + tag: "4.2.0" pullPolicy: IfNotPresent nodeSelector: {} resources: {} diff --git a/helm/nodesets/Chart.yaml b/helm/nodesets/Chart.yaml index 84003334e..e7853a50e 100644 --- a/helm/nodesets/Chart.yaml +++ b/helm/nodesets/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 4.1.7 +version: 4.2.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "4.1.7" +appVersion: "4.2.0" diff --git a/helm/nodesets/values.yaml b/helm/nodesets/values.yaml index e34503394..3f1cb9cb8 100644 --- a/helm/nodesets/values.yaml +++ b/helm/nodesets/values.yaml @@ -26,13 +26,13 @@ priorityClasses: images: munge: repository: "cr.eu-north1.nebius.cloud/soperator/munge" - tag: "4.1.7-slurm25.11.5" + tag: "4.2.0-slurm25.11.5" sssd: repository: "cr.eu-north1.nebius.cloud/soperator/sssd" tag: "2.9.4-1-63" slurmd: repository: "cr.eu-north1.nebius.cloud/soperator/worker_slurmd" - tag: "4.1.7-slurm25.11.5" + tag: "4.2.0-slurm25.11.5" nodeExporter: repository: "cr.eu-north1.nebius.cloud/soperator-proxy-docker-io/prom/node-exporter" tag: "v1.10.2" @@ -136,7 +136,7 @@ nodesets: # Optional image: repository: "cr.eu-north1.nebius.cloud/soperator/worker_slurmd" - tag: "4.1.7-slurm25.11.5" + tag: "4.2.0-slurm25.11.5" # pullPolicy: "IfNotPresent" # imagePullSecrets: [] customEnv: @@ -227,7 +227,7 @@ nodesets: image: repository: "cr.eu-north1.nebius.cloud/soperator/munge" # Optional - tag: "4.1.7-slurm25.11.5" + tag: "4.2.0-slurm25.11.5" # pullPolicy: "IfNotPresent" # imagePullSecrets: [] # Munge container resources diff --git a/helm/slurm-cluster-storage/Chart.yaml b/helm/slurm-cluster-storage/Chart.yaml index 6b6d6de2c..44947f760 100644 --- a/helm/slurm-cluster-storage/Chart.yaml +++ b/helm/slurm-cluster-storage/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: helm-slurm-cluster-storage description: A Helm chart for Kubernetes type: application -version: "4.1.7" -appVersion: "4.1.7" +version: "4.2.0" +appVersion: "4.2.0" diff --git a/helm/slurm-cluster/Chart.yaml b/helm/slurm-cluster/Chart.yaml index bbbe3ec75..ebab81453 100644 --- a/helm/slurm-cluster/Chart.yaml +++ b/helm/slurm-cluster/Chart.yaml @@ -2,6 +2,6 @@ apiVersion: v2 name: helm-slurm-cluster description: A Helm chart for Kubernetes type: application -version: "4.1.7" -appVersion: "4.1.7" +version: "4.2.0" +appVersion: "4.2.0" kubeVersion: ">=1.29.0-0" diff --git a/helm/slurm-cluster/values.yaml b/helm/slurm-cluster/values.yaml index 3445ce6da..6db6c8e6f 100644 --- a/helm/slurm-cluster/values.yaml +++ b/helm/slurm-cluster/values.yaml @@ -723,18 +723,18 @@ sConfigController: # This field is required in the SlurmCluster CR - it will be automatically populated from this value name: "" images: - slurmctld: "cr.eu-north1.nebius.cloud/soperator/controller_slurmctld:4.1.7-slurm25.11.5" - slurmrestd: "cr.eu-north1.nebius.cloud/soperator/slurmrestd:4.1.7-slurm25.11.5" - slurmd: "cr.eu-north1.nebius.cloud/soperator/worker_slurmd:4.1.7-slurm25.11.5" - sshd: "cr.eu-north1.nebius.cloud/soperator/login_sshd:4.1.7-slurm25.11.5" - munge: "cr.eu-north1.nebius.cloud/soperator/munge:4.1.7-slurm25.11.5" + slurmctld: "cr.eu-north1.nebius.cloud/soperator/controller_slurmctld:4.2.0-slurm25.11.5" + slurmrestd: "cr.eu-north1.nebius.cloud/soperator/slurmrestd:4.2.0-slurm25.11.5" + slurmd: "cr.eu-north1.nebius.cloud/soperator/worker_slurmd:4.2.0-slurm25.11.5" + sshd: "cr.eu-north1.nebius.cloud/soperator/login_sshd:4.2.0-slurm25.11.5" + munge: "cr.eu-north1.nebius.cloud/soperator/munge:4.2.0-slurm25.11.5" sssd: "cr.eu-north1.nebius.cloud/soperator/sssd:2.9.4-1-63" populateJail: "" populateJailRepository: "cr.eu-north1.nebius.cloud/soperator/populate_jail" - populateJailTag: "4.1.7-slurm25.11.5" - slurmdbd: "cr.eu-north1.nebius.cloud/soperator/controller_slurmdbd:4.1.7-slurm25.11.5" - soperatorExporter: "cr.eu-north1.nebius.cloud/soperator/soperator-exporter:4.1.7-slurm25.11.5" - sConfigController: "cr.eu-north1.nebius.cloud/soperator/sconfigcontroller:4.1.7" + populateJailTag: "4.2.0-slurm25.11.5" + slurmdbd: "cr.eu-north1.nebius.cloud/soperator/controller_slurmdbd:4.2.0-slurm25.11.5" + soperatorExporter: "cr.eu-north1.nebius.cloud/soperator/soperator-exporter:4.2.0-slurm25.11.5" + sConfigController: "cr.eu-north1.nebius.cloud/soperator/sconfigcontroller:4.2.0" nodeExporter: "cr.eu-north1.nebius.cloud/soperator-proxy-docker-io/prom/node-exporter:v1.10.2" mariaDB: "docker-registry1.mariadb.com/library/mariadb:12.1.2" # Configuration for slurm scripts. diff --git a/helm/soperator-activechecks/Chart.yaml b/helm/soperator-activechecks/Chart.yaml index ba2cc5e53..f4525d06f 100644 --- a/helm/soperator-activechecks/Chart.yaml +++ b/helm/soperator-activechecks/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: helm-soperator-activechecks description: A Helm chart for Kubernetes type: application -version: "4.1.7" -appVersion: "4.1.7" +version: "4.2.0" +appVersion: "4.2.0" diff --git a/helm/soperator-activechecks/values.yaml b/helm/soperator-activechecks/values.yaml index 2e1a2f465..8b0844ad6 100644 --- a/helm/soperator-activechecks/values.yaml +++ b/helm/soperator-activechecks/values.yaml @@ -43,10 +43,10 @@ jobContainer: persistentVolumeClaim: claimName: "jail-pvc" images: - slurmJob: "cr.eu-north1.nebius.cloud/soperator/slurm_check_job:4.1.7-slurm25.11.5" - k8sJob: "cr.eu-north1.nebius.cloud/soperator/k8s_check_job:4.1.7-slurm25.11.5" - munge: "cr.eu-north1.nebius.cloud/soperator/munge:4.1.7-slurm25.11.5" - sansible: "cr.eu-north1.nebius.cloud/soperator/sansible:4.1.7-slurm25.11.5" + slurmJob: "cr.eu-north1.nebius.cloud/soperator/slurm_check_job:4.2.0-slurm25.11.5" + k8sJob: "cr.eu-north1.nebius.cloud/soperator/k8s_check_job:4.2.0-slurm25.11.5" + munge: "cr.eu-north1.nebius.cloud/soperator/munge:4.2.0-slurm25.11.5" + sansible: "cr.eu-north1.nebius.cloud/soperator/sansible:4.2.0-slurm25.11.5" slurmJob: # Slurm consumes this as SBATCH_GPUS_PER_NODE for GPU ActiveChecks. gpusPerNode: "8" diff --git a/helm/soperator-backup-config/Chart.yaml b/helm/soperator-backup-config/Chart.yaml index d2712abc7..5aefb92da 100644 --- a/helm/soperator-backup-config/Chart.yaml +++ b/helm/soperator-backup-config/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: helm-soperator-backup-config description: Render backup schedule configuration for k8up type: application -version: 4.1.7 -appVersion: "4.1.7" +version: 4.2.0 +appVersion: "4.2.0" diff --git a/helm/soperator-crds/Chart.yaml b/helm/soperator-crds/Chart.yaml index adb8a15d1..6036f394f 100644 --- a/helm/soperator-crds/Chart.yaml +++ b/helm/soperator-crds/Chart.yaml @@ -2,6 +2,6 @@ apiVersion: v2 name: helm-soperator-crds description: A Helm chart for Kubernetes type: application -version: 4.1.7 -appVersion: "4.1.7" +version: 4.2.0 +appVersion: "4.2.0" kubeVersion: ">=1.29.0-0" diff --git a/helm/soperator-custom-configmaps/Chart.yaml b/helm/soperator-custom-configmaps/Chart.yaml index 972ecd5f9..ff68e845f 100644 --- a/helm/soperator-custom-configmaps/Chart.yaml +++ b/helm/soperator-custom-configmaps/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: helm-soperator-custom-configmaps description: Custom ConfigMaps for Soperator deployment type: application -version: 4.1.7 -appVersion: "4.1.7" +version: 4.2.0 +appVersion: "4.2.0" diff --git a/helm/soperator-dcgm-exporter/Chart.yaml b/helm/soperator-dcgm-exporter/Chart.yaml index 6ad96ac64..e322cd5b8 100644 --- a/helm/soperator-dcgm-exporter/Chart.yaml +++ b/helm/soperator-dcgm-exporter/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: helm-soperator-dcgm-exporter description: A Helm chart for Nvidia DCGM Exporter type: application -version: 4.1.7 -appVersion: "4.1.7" +version: 4.2.0 +appVersion: "4.2.0" diff --git a/helm/soperator-fluxcd-bootstrap/Chart.yaml b/helm/soperator-fluxcd-bootstrap/Chart.yaml index 80dfa5e3c..1410ce37c 100644 --- a/helm/soperator-fluxcd-bootstrap/Chart.yaml +++ b/helm/soperator-fluxcd-bootstrap/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: helm-soperator-fluxcd-bootstrap description: Bootstrap Helm chart for installing soperator-fluxcd via FluxCD type: application -version: 4.1.7 -appVersion: "4.1.7" +version: 4.2.0 +appVersion: "4.2.0" diff --git a/helm/soperator-fluxcd-bootstrap/values.yaml b/helm/soperator-fluxcd-bootstrap/values.yaml index 383c0bfcf..2adf0acdb 100644 --- a/helm/soperator-fluxcd-bootstrap/values.yaml +++ b/helm/soperator-fluxcd-bootstrap/values.yaml @@ -15,7 +15,7 @@ helmRelease: timeout: 5m chart: name: helm-soperator-fluxcd - version: "4.1.7" + version: "4.2.0" interval: 5m install: createNamespace: true diff --git a/helm/soperator-fluxcd/Chart.yaml b/helm/soperator-fluxcd/Chart.yaml index 29263fc40..aa74a6f18 100644 --- a/helm/soperator-fluxcd/Chart.yaml +++ b/helm/soperator-fluxcd/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 4.1.7 +version: 4.2.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "4.1.7" +appVersion: "4.2.0" diff --git a/helm/soperator-fluxcd/values.yaml b/helm/soperator-fluxcd/values.yaml index bdbf71c5e..4d9e2974f 100644 --- a/helm/soperator-fluxcd/values.yaml +++ b/helm/soperator-fluxcd/values.yaml @@ -411,7 +411,7 @@ observability: enabled: true interval: 5m timeout: 30m - version: 4.1.7 + version: 4.2.0 namespace: soperator releaseName: soperator-dcgm-exporter install: @@ -532,7 +532,7 @@ slurmCluster: enabled: true interval: 5m timeout: 5m - version: 4.1.7 + version: 4.2.0 namespace: soperator releaseName: soperator values: null @@ -571,7 +571,7 @@ nodesets: mode: warn interval: 5m timeout: 5m - version: 4.1.7 + version: 4.2.0 namespace: soperator releaseName: soperator-nodesets values: null @@ -589,7 +589,7 @@ soperatorActiveChecks: enabled: true interval: 5m timeout: 120m - version: 4.1.7 + version: 4.2.0 releaseName: soperator-activechecks namespace: soperator overrideValues: null @@ -608,7 +608,7 @@ soperator: enabled: true interval: 5m timeout: 5m - version: 4.1.7 + version: 4.2.0 namespace: "soperator-system" releaseName: soperator-controller install: @@ -731,7 +731,7 @@ notifier: enabled: false interval: 5m timeout: 5m - version: 4.1.7 + version: 4.2.0 namespace: monitoring-system releaseName: soperator-notifier install: @@ -790,7 +790,7 @@ storageClasses: enabled: true interval: 5m timeout: 5m - version: 4.1.7 + version: 4.2.0 namespace: storage-system releaseName: soperator-storageclasses values: null @@ -809,7 +809,7 @@ customConfigmaps: enabled: true interval: 5m timeout: 5m - version: 4.1.7 + version: 4.2.0 namespace: soperator releaseName: soperator-custom-configmaps overrideValues: null diff --git a/helm/soperator-monitoring-dashboards/Chart.yaml b/helm/soperator-monitoring-dashboards/Chart.yaml index 7968c7bda..f8921ba00 100644 --- a/helm/soperator-monitoring-dashboards/Chart.yaml +++ b/helm/soperator-monitoring-dashboards/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: helm-soperator-monitoring-dashboards description: Grafana dashboards for Soperator packaged as ConfigMaps type: application -version: 4.1.7 -appVersion: "4.1.7" +version: 4.2.0 +appVersion: "4.2.0" diff --git a/helm/soperator-notifier/Chart.yaml b/helm/soperator-notifier/Chart.yaml index 12c2b939a..e385b1b59 100644 --- a/helm/soperator-notifier/Chart.yaml +++ b/helm/soperator-notifier/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: helm-soperator-notifier description: A set of VictoriaMetrics entities to ease up a setup of Slurm operation notifications to Slack type: application -version: 4.1.7 -appVersion: "4.1.7" +version: 4.2.0 +appVersion: "4.2.0" diff --git a/helm/soperator/Chart.yaml b/helm/soperator/Chart.yaml index dfa45f776..4277a013c 100644 --- a/helm/soperator/Chart.yaml +++ b/helm/soperator/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: helm-soperator description: A Helm chart for Kubernetes type: application -version: 4.1.7 -appVersion: "4.1.7" +version: 4.2.0 +appVersion: "4.2.0" kubeVersion: ">=1.29.0-0" dependencies: - name: kruise diff --git a/helm/soperator/values.yaml b/helm/soperator/values.yaml index 7f1f439bc..242b9b0d1 100644 --- a/helm/soperator/values.yaml +++ b/helm/soperator/values.yaml @@ -24,7 +24,7 @@ controllerManager: topologyLabelPrefix: "topology.nebius.com" image: repository: cr.eu-north1.nebius.cloud/soperator/slurm-operator - tag: 4.1.7 + tag: 4.2.0 imagePullPolicy: Always imagePullSecrets: [] resources: diff --git a/helm/soperatorchecks/Chart.yaml b/helm/soperatorchecks/Chart.yaml index 79a514def..0be96ae48 100644 --- a/helm/soperatorchecks/Chart.yaml +++ b/helm/soperatorchecks/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 4.1.7 +version: 4.2.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "4.1.7" +appVersion: "4.2.0" diff --git a/helm/soperatorchecks/values.yaml b/helm/soperatorchecks/values.yaml index e9031d93b..8c908afd0 100644 --- a/helm/soperatorchecks/values.yaml +++ b/helm/soperatorchecks/values.yaml @@ -35,7 +35,7 @@ checks: maintenanceIgnoreNodeLabels: "slurm.nebius.ai/nodeset=nfs" image: repository: cr.eu-north1.nebius.cloud/soperator/soperatorchecks - tag: 4.1.7 + tag: 4.2.0 imagePullPolicy: Always imagePullSecrets: [] resources: diff --git a/helm/storageclasses/Chart.yaml b/helm/storageclasses/Chart.yaml index 96df1dee4..071ab0ad1 100644 --- a/helm/storageclasses/Chart.yaml +++ b/helm/storageclasses/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: helm-storageclasses description: Generate StorageClasses for provisioner/disk/fs combinations type: application -appVersion: "4.1.7" -version: 4.1.7 +appVersion: "4.2.0" +version: 4.2.0 diff --git a/internal/consts/version.go b/internal/consts/version.go index 89156c1a6..c491ecd17 100644 --- a/internal/consts/version.go +++ b/internal/consts/version.go @@ -2,5 +2,5 @@ package consts const ( - VersionCR = "4.1.7" + VersionCR = "4.2.0" ) From ccadda623742034ed5c3c6c2da5c1d32cd389ce9 Mon Sep 17 00:00:00 2001 From: Barys Serankou Date: Fri, 31 Jul 2026 23:43:36 +0200 Subject: [PATCH 2/2] SCHED-2206 Make E2E branch agnostic --- .github/workflows/e2e_test.yml | 11 +- .github/workflows/e2e_test_scheduler.yml | 3 +- e2e/acceptance/README.md | 58 ++- e2e/acceptance/features/active_checks.feature | 2 +- .../features/cluster_creation.feature | 1 + .../features/docker_containers.feature | 3 +- .../features/enroot_containers.feature | 3 +- e2e/acceptance/features/internal_ssh.feature | 1 + .../features/node_replacement.feature | 7 +- e2e/acceptance/features/observability.feature | 1 + .../features/package_installation.feature | 2 +- .../features/passive_checks.feature | 8 +- e2e/acceptance/features/system_checks.feature | 3 +- e2e/acceptance/features/topology.feature | 1 + e2e/acceptance/framework/model.go | 13 +- e2e/acceptance/framework/version.go | 31 ++ e2e/acceptance/framework/version_test.go | 24 ++ e2e/acceptance/public_api_test.go | 1 + e2e/acceptance/runner.go | 34 +- e2e/acceptance/runner_test.go | 86 ++-- e2e/acceptance/version_filter.go | 18 + e2e/acceptance/version_filter_test.go | 30 ++ .../acceptance/cli}/options.go | 31 +- e2e/cmd/acceptance/cli/options_test.go | 58 +++ e2e/cmd/acceptance/cli/version_discovery.go | 87 ++++ .../acceptance/cli/version_discovery_test.go | 26 ++ e2e/cmd/acceptance/main.go | 4 +- e2e/versionfilter/versionfilter.go | 398 ++++++++++++++++++ e2e/versionfilter/versionfilter_test.go | 226 ++++++++++ go.mod | 6 +- internal/e2e/acceptance.go | 1 + 31 files changed, 1081 insertions(+), 97 deletions(-) create mode 100644 e2e/acceptance/framework/version.go create mode 100644 e2e/acceptance/framework/version_test.go create mode 100644 e2e/acceptance/version_filter.go create mode 100644 e2e/acceptance/version_filter_test.go rename e2e/{acceptance => cmd/acceptance/cli}/options.go (68%) create mode 100644 e2e/cmd/acceptance/cli/options_test.go create mode 100644 e2e/cmd/acceptance/cli/version_discovery.go create mode 100644 e2e/cmd/acceptance/cli/version_discovery_test.go create mode 100644 e2e/versionfilter/versionfilter.go create mode 100644 e2e/versionfilter/versionfilter_test.go diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml index 43bf878b8..fb65b408c 100644 --- a/.github/workflows/e2e_test.yml +++ b/.github/workflows/e2e_test.yml @@ -13,6 +13,10 @@ on: required: false default: "" type: string + soperator_ref: + description: "Soperator ref to use build artifacts from" + required: true + type: string profile_env_var: description: "Name of the GitHub variable containing the profile YAML (defaults to env setting)" required: false @@ -162,6 +166,7 @@ jobs: PATH_TO_INSTALLATION: "${{ github.workspace }}/terraform-repo/soperator/installations/example" TERRAFORM_REPO: "nebius/nebius-solution-library" TERRAFORM_REPO_REF: "${{ github.event.inputs.terraform_repo_ref || github.ref_name }}" + SOPERATOR_REF: "${{ inputs.soperator_ref }}" O11Y_ACCESS_TOKEN: ${{ secrets.E2E_O11Y_ACCESS_TOKEN }} PROFILE_ENV_VAR: ${{ inputs.profile_env_var || vars.PROFILE_ENV_VAR }} E2E_PROFILE: ${{ vars[inputs.profile_env_var || vars.PROFILE_ENV_VAR] }} @@ -279,11 +284,11 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - echo "Looking for successful build run on branch: ${{ github.ref_name }}" + echo "Looking for successful build run on branch: $SOPERATOR_REF" run_info=$(gh api -X GET \ "/repos/${{ github.repository }}/actions/workflows/one_job.yml/runs" \ - -F branch="${{ github.ref_name }}" \ + -F branch="$SOPERATOR_REF" \ -F status=success \ -F per_page=1 \ --jq '.workflow_runs[0] | {id: .id, head_sha: .head_sha, created_at: .created_at, html_url: .html_url}') @@ -291,7 +296,7 @@ jobs: run_id=$(jq -r '.id' <<<"$run_info") if [[ "$run_id" == "null" || -z "$run_id" ]]; then - echo "::error::No successful build found on branch ${{ github.ref_name }}" + echo "::error::No successful build found on branch $SOPERATOR_REF" exit 1 fi diff --git a/.github/workflows/e2e_test_scheduler.yml b/.github/workflows/e2e_test_scheduler.yml index 903af37f0..d48969eb6 100644 --- a/.github/workflows/e2e_test_scheduler.yml +++ b/.github/workflows/e2e_test_scheduler.yml @@ -60,8 +60,9 @@ jobs: echo "Terraform ref: $TERRAFORM_REF" gh workflow run e2e_test.yml \ - --ref "$REF" \ + --ref main \ -f profile_env_var="$PROFILE_NAME" \ + -f soperator_ref="$REF" \ -f terraform_repo_ref="$TERRAFORM_REF" \ -f is_scheduled=true diff --git a/e2e/acceptance/README.md b/e2e/acceptance/README.md index 50af301c9..ed219b560 100644 --- a/e2e/acceptance/README.md +++ b/e2e/acceptance/README.md @@ -27,14 +27,33 @@ Run the shared suite against an existing cluster: bin/acceptance --kubectl-context ``` +The target Soperator version controls which version-tagged scenarios are run. +The CLI resolves it in this order: + +1. `--soperator-version` +2. Flux HelmRelease discovery through the provided kubectl context + +Use `--soperator-version` for a manual override, for example +`--soperator-version 4.2.0`. If the flag is omitted, the CLI uses kubectl to +discover the version from the target cluster. + +Flux discovery reads `status.lastAttemptedRevision` from the standard Soperator +HelmRelease: `flux-system/flux-system-soperator-fluxcd-soperator`. If +discovery cannot do that, the CLI fails before running scenarios and asks for +`--soperator-version`. + All flags: - `--kubectl-context`: required. All local kubectl calls use this context. - `--slurm-cluster-name`: optional, defaults to `soperator`. +- `--soperator-version`: optional target Soperator version. Full + `major.minor.patch` is required, with optional suffix allowed for deployed + versions, for example `4.1.5-reb85d0e5`. - `--run-unstable`: optional, defaults to `false`; when false, scenarios tagged `@unstable` are excluded. -- `--scenario`: optional. Runs only the scenario at the provided feature path - and line, for example `features/internal_ssh.feature:2`. May be repeated. +- `--scenario`: optional. Runs all compatible scenarios in the provided feature + file, or the single scenario at an exact `Scenario:` line, for example + `features/internal_ssh.feature:3`. May be repeated. - `--report-dir`: optional. When set, the runner writes Cucumber and JUnit reports into that directory. @@ -47,7 +66,7 @@ scenarios tagged `@cpu` are excluded. For focused manual runs on a dev cluster, pass the scenario location: ```bash -bin/acceptance --kubectl-context --scenario features/internal_ssh.feature:2 +bin/acceptance --kubectl-context --scenario features/internal_ssh.feature:3 ``` The `--scenario` flag is for local/manual investigation only. The GitHub @@ -76,14 +95,15 @@ import ( func run(ctx context.Context, kubectlContext string) error { features := acceptance.SharedFeatureSource() features.Paths = []string{ - "features/internal_ssh.feature:2", - "features/topology.feature:2", + "features/internal_ssh.feature:3", + "features/topology.feature:3", } runner, err := acceptance.NewRunner(acceptance.Options{ SuiteName: "custom-soperator-acceptance", KubectlContext: kubectlContext, SlurmClusterName: "soperator", + TargetSoperatorVersion: "4.2.0", Features: features, Tags: "~@unstable", ExcludeMissingWorkerKinds: true, @@ -104,7 +124,31 @@ func main() { ``` The caller controls scenario selection through `FeatureSource.Paths` and Godog -tag filtering through `Options.Tags`. Additional runner-specific steps can be -registered by appending more `StepRegistrar` values. +tag filtering through `Options.Tags`. The runner always pre-filters scenarios +by `TargetSoperatorVersion` before invoking Godog. + +Every scenario must have exactly one `@soperator_version_...` tag. Version tags +must be on the scenario itself and use full patch versions only, for example +`@soperator_version_>=4.2.0`; shorthand forms such as +`@soperator_version_>=4.2` are intentionally rejected. Comma means AND and `||` +means OR, for example +`@soperator_version_>=4.1.0,<4.4.0||>=5.0.0,<6.0.0`. + +The runner stores and uses a normalized target version for filtering. Suffixes +and build metadata are stripped before comparison, so `4.1.5-reb85d0e5` is +stored and compared as `4.1.5`. + +GitHub Actions keeps two refs separate: + +1. Soperator/workflow ref: the Soperator repository ref used to run the E2E + code and select the latest successful Soperator build artifact to deploy. +2. Terraform repo ref: the `nebius-solution-library` ref used for cluster + creation. It defaults to the Soperator/workflow ref, but can be overridden + independently. + +The workflow reads the target Soperator version from the selected build artifact +and uses it for Terraform deployment and scenario filtering. When new tests or +behavior are added, the scenarios must be tagged with the full version lower +bound where that behavior exists, for example `@soperator_version_>=4.2.0`. The shared steps currently assume the Soperator namespace is `soperator`. diff --git a/e2e/acceptance/features/active_checks.feature b/e2e/acceptance/features/active_checks.feature index 72fc697c7..77001ac3d 100644 --- a/e2e/acceptance/features/active_checks.feature +++ b/e2e/acceptance/features/active_checks.feature @@ -1,5 +1,5 @@ Feature: Active checks - @gpu + @gpu @soperator_version_>=4.2.0 Scenario: GPU ActiveCheck succeeds on all GPU workers Given healthy GPU workers are available for active checks When the GPU ActiveCheck is triggered diff --git a/e2e/acceptance/features/cluster_creation.feature b/e2e/acceptance/features/cluster_creation.feature index 55d71763f..bf46a927b 100644 --- a/e2e/acceptance/features/cluster_creation.feature +++ b/e2e/acceptance/features/cluster_creation.feature @@ -1,4 +1,5 @@ Feature: Cluster creation + @soperator_version_>=4.0.0 Scenario: The provisioned cluster is ready for acceptance tests Then all non-job pods in soperator are Running and Ready And all HelmReleases are Ready diff --git a/e2e/acceptance/features/docker_containers.feature b/e2e/acceptance/features/docker_containers.feature index 0a9b0a06d..ce063b6c1 100644 --- a/e2e/acceptance/features/docker_containers.feature +++ b/e2e/acceptance/features/docker_containers.feature @@ -1,4 +1,5 @@ Feature: Docker containers + @soperator_version_>=4.0.0 Scenario: Docker container lifecycle uses local storage Given a long-running Docker container job is submitted on two workers When the Docker container job is running @@ -8,7 +9,7 @@ Feature: Docker containers And Docker containers from the job are stopped explicitly Then Docker containers from the job are no longer running - @gpu + @gpu @soperator_version_>=4.0.0 Scenario: Docker containers can access GPUs Given a Docker GPU smoke job is submitted on one GPU worker Then the Docker GPU smoke job succeeds and reports visible GPUs diff --git a/e2e/acceptance/features/enroot_containers.feature b/e2e/acceptance/features/enroot_containers.feature index b045781d7..d5359cc7f 100644 --- a/e2e/acceptance/features/enroot_containers.feature +++ b/e2e/acceptance/features/enroot_containers.feature @@ -1,4 +1,5 @@ Feature: Enroot containers + @soperator_version_>=4.0.0 Scenario: Enroot and Pyxis cache images and clean up runtime state Given a long-running Enroot container job is submitted on two workers When the Enroot container job is running @@ -11,7 +12,7 @@ Feature: Enroot containers When the repeated Enroot container job is cancelled Then Enroot runtime state is cleaned up - @gpu + @gpu @soperator_version_>=4.0.0 Scenario: Enroot containers can access GPUs Given an Enroot GPU smoke job is submitted on one GPU worker Then the Enroot GPU smoke job succeeds and reports visible GPUs diff --git a/e2e/acceptance/features/internal_ssh.feature b/e2e/acceptance/features/internal_ssh.feature index 84e98e8a8..8d7d385b8 100644 --- a/e2e/acceptance/features/internal_ssh.feature +++ b/e2e/acceptance/features/internal_ssh.feature @@ -1,4 +1,5 @@ Feature: Internal SSH + @soperator_version_>=4.0.0 Scenario: A regular user can SSH to a worker without extra options Given a regular user account exists on the login node When the user SSHs from the login node to a worker diff --git a/e2e/acceptance/features/node_replacement.feature b/e2e/acceptance/features/node_replacement.feature index 0fdf3eadc..230321949 100644 --- a/e2e/acceptance/features/node_replacement.feature +++ b/e2e/acceptance/features/node_replacement.feature @@ -1,6 +1,5 @@ Feature: Node replacement - @gpu - @unstable + @gpu @unstable @soperator_version_>=4.0.0 Scenario: A maintenance event replaces the selected worker node Given a test job is submitted and running on a GPU worker node When a maintenance event is triggered for that node @@ -10,9 +9,7 @@ Feature: Node replacement And a replacement node joins the cluster And the replacement node passes GPU validation - @cpu - @gpu - @unstable + @cpu @gpu @unstable @soperator_version_>=4.2.0 Scenario: A maintenance event replaces a CPU worker node when CPU and GPU workers exist Given a test job is submitted and running on a CPU worker node When a maintenance event is triggered for that node diff --git a/e2e/acceptance/features/observability.feature b/e2e/acceptance/features/observability.feature index 1047849a6..edc8476c2 100644 --- a/e2e/acceptance/features/observability.feature +++ b/e2e/acceptance/features/observability.feature @@ -1,3 +1,4 @@ Feature: Observability stack + @soperator_version_>=4.2.0 Scenario: kube-state-metrics scrape config is consumed by the vm-stack chart Then the kube-state-metrics VMServiceScrape carries the soperator scrape endpoints diff --git a/e2e/acceptance/features/package_installation.feature b/e2e/acceptance/features/package_installation.feature index dcf4cf535..f0b44c509 100644 --- a/e2e/acceptance/features/package_installation.feature +++ b/e2e/acceptance/features/package_installation.feature @@ -1,5 +1,5 @@ Feature: Package installation - @gpu + @gpu @soperator_version_>=4.0.0 Scenario: Installing jq does not break the NVIDIA driver Given the NVIDIA driver is working on a worker node When jq is installed on the worker node diff --git a/e2e/acceptance/features/passive_checks.feature b/e2e/acceptance/features/passive_checks.feature index 8ea92f0d1..ca2db7d5d 100644 --- a/e2e/acceptance/features/passive_checks.feature +++ b/e2e/acceptance/features/passive_checks.feature @@ -1,4 +1,5 @@ Feature: Passive checks + @soperator_version_>=4.2.0 Scenario: CPU jobs run expected Prolog and Epilog passive checks Given a worker is selected When a CPU-only Slurm job runs on the selected worker @@ -6,11 +7,13 @@ Feature: Passive checks And the CPU job Epilog check runner output is fresh and healthy And GPU-only passive checks are not executed for the CPU job + @soperator_version_>=4.2.0 Scenario: drop_page_cache runs after CPU jobs Given a worker is selected When a CPU-only Slurm job runs on the selected worker Then the drop_page_cache passive check completed in Epilog + @soperator_version_>=4.2.0 Scenario: Passive Prolog drains a worker when allocated memory exceeds available memory Given a worker is selected When memory pressure is created on the selected worker @@ -21,7 +24,7 @@ Feature: Passive checks Then the selected worker no longer has alloc_mem_used reason And the selected worker is usable after alloc_mem_used - @gpu + @gpu @soperator_version_>=4.2.0 Scenario: GPU jobs run passive GPU health checks Given a GPU worker is selected When a small GPU Slurm job runs on the selected GPU worker @@ -29,13 +32,14 @@ Feature: Passive checks And the GPU job health-check Epilog report is fresh and passing And raw GPU health-check command outputs are present + @soperator_version_>=4.2.0 Scenario: Job tmpfs directory is scoped to the Slurm job lifetime Given a worker is selected When a Slurm job checks its job tmpfs directory on the selected worker Then the job tmpfs directory existed during the job And the job tmpfs directory is removed after the job exits - @gpu + @gpu @soperator_version_>=4.2.0 Scenario: Passive Prolog drains a worker with unmanaged GPU processes Given a GPU worker is selected When an unmanaged GPU workload is started on the selected GPU worker diff --git a/e2e/acceptance/features/system_checks.feature b/e2e/acceptance/features/system_checks.feature index 3ebc8a2b2..0d2c334eb 100644 --- a/e2e/acceptance/features/system_checks.feature +++ b/e2e/acceptance/features/system_checks.feature @@ -1,4 +1,5 @@ Feature: System checks + @soperator_version_>=4.2.0 Scenario: Worker pod ephemeral storage pressure drains and recovers a Slurm node Given a healthy worker pod is selected When pod-local ephemeral storage is filled above the warning threshold @@ -9,7 +10,7 @@ Feature: System checks # TODO: Make soperatorchecks --not-ready-timeout configurable through Helm and # set a lower value for e2e clusters, then remove @unstable from this scenario. - @unstable + @unstable @soperator_version_>=4.2.0 Scenario: Non-responding kubelet recreates a Kubernetes node and recovers the worker Given a healthy worker pod is selected When kubelet is stopped on the selected worker Kubernetes node diff --git a/e2e/acceptance/features/topology.feature b/e2e/acceptance/features/topology.feature index 5c3bb1be4..645d49864 100644 --- a/e2e/acceptance/features/topology.feature +++ b/e2e/acceptance/features/topology.feature @@ -1,4 +1,5 @@ Feature: Slurm network topology + @soperator_version_>=4.0.0 Scenario: scontrol topology and SLURM_TOPOLOGY_ADDR agree across workers Given the Slurm topology plugin is topology/tree When scontrol show topology is parsed into a switch tree diff --git a/e2e/acceptance/framework/model.go b/e2e/acceptance/framework/model.go index adaa1817e..789fef385 100644 --- a/e2e/acceptance/framework/model.go +++ b/e2e/acceptance/framework/model.go @@ -12,12 +12,13 @@ type DiscoveredNodeSet struct { } type ClusterState struct { - SlurmClusterName string - Workers []WorkerRef - CPUWorkers []WorkerRef - GPUWorkers []WorkerRef - WorkersByNodeSet map[string][]WorkerRef - DiscoveredNodeSets []DiscoveredNodeSet + SlurmClusterName string + TargetSoperatorVersion string + Workers []WorkerRef + CPUWorkers []WorkerRef + GPUWorkers []WorkerRef + WorkersByNodeSet map[string][]WorkerRef + DiscoveredNodeSets []DiscoveredNodeSet } func (s *ClusterState) PodName(podName string) string { diff --git a/e2e/acceptance/framework/version.go b/e2e/acceptance/framework/version.go new file mode 100644 index 000000000..defa212c3 --- /dev/null +++ b/e2e/acceptance/framework/version.go @@ -0,0 +1,31 @@ +package framework + +import ( + "fmt" + "strings" + + semver "github.com/Masterminds/semver/v3" + + "nebius.ai/slurm-operator/e2e/versionfilter" +) + +func NormalizeSoperatorVersion(raw string) (string, error) { + version, err := versionfilter.NormalizeVersion(raw) + if err != nil { + return "", fmt.Errorf("Soperator %w", err) + } + return version, nil +} + +func ParseSoperatorBaseVersion(raw string) (*semver.Version, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, fmt.Errorf("Soperator version is required") + } + + version, err := versionfilter.ParseBaseVersion(raw) + if err != nil { + return nil, fmt.Errorf("Soperator %w", err) + } + return version, nil +} diff --git a/e2e/acceptance/framework/version_test.go b/e2e/acceptance/framework/version_test.go new file mode 100644 index 000000000..1e17b9172 --- /dev/null +++ b/e2e/acceptance/framework/version_test.go @@ -0,0 +1,24 @@ +package framework + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeSoperatorVersion(t *testing.T) { + base, err := NormalizeSoperatorVersion("4.1.5-reb85d0e5") + require.NoError(t, err) + assert.Equal(t, "4.1.5", base) + + base, err = NormalizeSoperatorVersion("v4.2.0+build.1") + require.NoError(t, err) + assert.Equal(t, "4.2.0", base) +} + +func TestParseSoperatorBaseVersionRejectsShortVersion(t *testing.T) { + _, err := ParseSoperatorBaseVersion("4.2") + require.Error(t, err) + assert.ErrorContains(t, err, "full major.minor.patch") +} diff --git a/e2e/acceptance/public_api_test.go b/e2e/acceptance/public_api_test.go index cc5a6668e..66a1edac0 100644 --- a/e2e/acceptance/public_api_test.go +++ b/e2e/acceptance/public_api_test.go @@ -18,6 +18,7 @@ func TestPublicRunnerAPIIsImportable(t *testing.T) { runner, err := acceptance.NewRunner(acceptance.Options{ KubectlContext: "dev-context", + TargetSoperatorVersion: "4.2.0", Features: features, Tags: "~@custom_only", ExcludeUnstable: true, diff --git a/e2e/acceptance/runner.go b/e2e/acceptance/runner.go index 9d8c7d66b..2fefcc058 100644 --- a/e2e/acceptance/runner.go +++ b/e2e/acceptance/runner.go @@ -28,8 +28,9 @@ var acceptanceFeatures embed.FS type timingCtxKey string const ( - scenarioStartTimeKey timingCtxKey = "acceptance_scenario_start_time" - stepStartTimeKey timingCtxKey = "acceptance_step_start_time" + scenarioStartTimeKey timingCtxKey = "acceptance_scenario_start_time" + stepStartTimeKey timingCtxKey = "acceptance_step_start_time" + defaultSlurmClusterName = "soperator" ) // Runner executes a configured Godog acceptance suite against a Soperator cluster. @@ -59,6 +60,7 @@ type Options struct { SuiteName string KubectlContext string SlurmClusterName string + TargetSoperatorVersion string ReportDir string Features FeatureSource Tags string @@ -167,6 +169,15 @@ func NewRunner(opts Options) (*Runner, error) { if state.WorkersByNodeSet == nil { state.WorkersByNodeSet = make(map[string][]framework.WorkerRef) } + targetVersion := strings.TrimSpace(opts.TargetSoperatorVersion) + if targetVersion == "" { + return nil, fmt.Errorf("target Soperator version is required") + } + normalizedTargetVersion, err := framework.NormalizeSoperatorVersion(targetVersion) + if err != nil { + return nil, fmt.Errorf("target Soperator version: %w", err) + } + state.TargetSoperatorVersion = normalizedTargetVersion return &Runner{ suiteName: suiteName, @@ -188,9 +199,12 @@ func (r *Runner) Run(ctx context.Context) error { return fmt.Errorf("discover cluster before suite: %w", err) } - features := r.featurePaths() + features, err := r.featurePaths() + if err != nil { + return err + } if len(features) == 0 { - return fmt.Errorf("no acceptance feature files configured") + return fmt.Errorf("no acceptance scenarios compatible with Soperator version %s", r.state.TargetSoperatorVersion) } tags := r.tagFilter() @@ -246,10 +260,14 @@ func (r *Runner) tagFilter() string { return strings.Join(filters, " && ") } -func (r *Runner) featurePaths() []string { - paths := slices.Clone(r.features.Paths) - log.Printf("acceptance: running features: %s", strings.Join(paths, ", ")) - return paths +func (r *Runner) featurePaths() ([]string, error) { + paths, err := selectCompatibleFeaturePaths(r.features, r.state.TargetSoperatorVersion) + if err != nil { + return nil, err + } + log.Printf("acceptance: target Soperator version=%s", r.state.TargetSoperatorVersion) + log.Printf("acceptance: running scenarios: %s", strings.Join(paths, ", ")) + return paths, nil } func discoverCluster(ctx context.Context, w *world, state *framework.ClusterState) error { diff --git a/e2e/acceptance/runner_test.go b/e2e/acceptance/runner_test.go index 56a20b4b2..9df15a853 100644 --- a/e2e/acceptance/runner_test.go +++ b/e2e/acceptance/runner_test.go @@ -3,6 +3,7 @@ package acceptance import ( "path/filepath" "testing" + "testing/fstest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,34 +14,7 @@ import ( "nebius.ai/slurm-operator/e2e/acceptance/framework" ) -func TestParseOptionsDefaults(t *testing.T) { - opts, err := parseOptions([]string{"--kubectl-context", "dev-context"}) - require.NoError(t, err) - - assert.Equal(t, "dev-context", opts.KubectlContext) - assert.Equal(t, "soperator", opts.SlurmClusterName) - assert.False(t, opts.RunUnstableTests) - assert.Empty(t, opts.ScenarioPaths) - assert.Empty(t, opts.ReportDir) -} - -func TestParseOptionsExplicitValues(t *testing.T) { - opts, err := parseOptions([]string{ - "--kubectl-context", "dev-context", - "--slurm-cluster-name", "custom", - "--run-unstable=true", - "--scenario", "features/internal_ssh.feature:2", - "--scenario=features/topology.feature:3", - "--report-dir", "reports", - }) - require.NoError(t, err) - - assert.Equal(t, "dev-context", opts.KubectlContext) - assert.Equal(t, "custom", opts.SlurmClusterName) - assert.True(t, opts.RunUnstableTests) - assert.Equal(t, []string{"features/internal_ssh.feature:2", "features/topology.feature:3"}, opts.ScenarioPaths) - assert.Equal(t, "reports", opts.ReportDir) -} +const testTargetSoperatorVersion = "4.2.0" func TestRunnerTagFilter(t *testing.T) { cpuAndGPUState := &framework.ClusterState{ @@ -86,6 +60,7 @@ func TestRunnerTagFilter(t *testing.T) { t.Run(tt.name, func(t *testing.T) { runner, err := NewRunner(Options{ KubectlContext: "dev-context", + TargetSoperatorVersion: testTargetSoperatorVersion, State: tt.state, ExcludeUnstable: !tt.runUnstableTests, ExcludeMissingWorkerKinds: true, @@ -97,37 +72,48 @@ func TestRunnerTagFilter(t *testing.T) { } func TestRunnerFeaturePaths(t *testing.T) { - runner, err := NewRunner(Options{KubectlContext: "dev-context"}) + features := FeatureSource{ + FS: fstest.MapFS{ + "features/sample.feature": {Data: []byte(`Feature: Sample + @soperator_version_>=4.0.0 + Scenario: old scenario + Then old behavior works + + @soperator_version_>=4.2.0 + Scenario: new scenario + Then new behavior works +`)}, + }, + Paths: []string{"features/sample.feature"}, + } + + runner, err := NewRunner(Options{ + KubectlContext: "dev-context", + TargetSoperatorVersion: "4.1.5-reb85d0e5", + Features: features, + }) + require.NoError(t, err) + paths, err := runner.featurePaths() require.NoError(t, err) - assert.Equal(t, FeaturePaths(), runner.featurePaths()) + assert.Equal(t, []string{"features/sample.feature:3"}, paths) - scenarios := []string{"features/internal_ssh.feature:2", "features/topology.feature:3"} - features := SharedFeatureSource() + scenarios := []string{"features/sample.feature:3", "features/sample.feature:7"} features.Paths = scenarios runner, err = NewRunner(Options{ - KubectlContext: "dev-context", - Features: features, + KubectlContext: "dev-context", + TargetSoperatorVersion: testTargetSoperatorVersion, + Features: features, }) require.NoError(t, err) - assert.Equal(t, scenarios, runner.featurePaths()) -} - -func TestParseOptionsRequiresKubectlContext(t *testing.T) { - _, err := parseOptions(nil) - require.Error(t, err) - assert.ErrorContains(t, err, "--kubectl-context is required") -} - -func TestParseOptionsRejectsExtraArgs(t *testing.T) { - _, err := parseOptions([]string{"--kubectl-context", "dev-context", "extra"}) - require.Error(t, err) - assert.ErrorContains(t, err, "unexpected acceptance arguments") + paths, err = runner.featurePaths() + require.NoError(t, err) + assert.Equal(t, []string{"features/sample.feature:3", "features/sample.feature:7"}, paths) } -func TestParseOptionsRejectsEmptyScenario(t *testing.T) { - _, err := parseOptions([]string{"--kubectl-context", "dev-context", "--scenario", " "}) +func TestNewRunnerRequiresTargetSoperatorVersion(t *testing.T) { + _, err := NewRunner(Options{KubectlContext: "dev-context"}) require.Error(t, err) - assert.ErrorContains(t, err, "--scenario value cannot be empty") + assert.ErrorContains(t, err, "target Soperator version is required") } func TestDiscoveredNodeSetsFromLiveList(t *testing.T) { diff --git a/e2e/acceptance/version_filter.go b/e2e/acceptance/version_filter.go new file mode 100644 index 000000000..18087a90f --- /dev/null +++ b/e2e/acceptance/version_filter.go @@ -0,0 +1,18 @@ +package acceptance + +import "nebius.ai/slurm-operator/e2e/versionfilter" + +const soperatorVersionTagPrefix = "@soperator_version_" + +func selectCompatibleFeaturePaths(source FeatureSource, targetVersion string) ([]string, error) { + return versionfilter.SelectScenarios( + versionfilter.FeatureSource{ + FS: source.FS, + Paths: source.Paths, + }, + versionfilter.Axis{ + TagPrefix: soperatorVersionTagPrefix, + TargetVersion: targetVersion, + }, + ) +} diff --git a/e2e/acceptance/version_filter_test.go b/e2e/acceptance/version_filter_test.go new file mode 100644 index 000000000..9522eff02 --- /dev/null +++ b/e2e/acceptance/version_filter_test.go @@ -0,0 +1,30 @@ +package acceptance + +import ( + "io/fs" + "strings" + "testing" + + gherkin "github.com/cucumber/gherkin/go/v26" + messages "github.com/cucumber/messages/go/v21" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSharedFeaturesParseWithGherkin(t *testing.T) { + for _, path := range FeaturePaths() { + t.Run(path, func(t *testing.T) { + content, err := fs.ReadFile(acceptanceFeatures, path) + require.NoError(t, err) + + _, err = gherkin.ParseGherkinDocument(strings.NewReader(string(content)), (&messages.Incrementing{}).NewId) + require.NoError(t, err) + }) + } +} + +func TestSharedFeaturesHaveValidVersionTags(t *testing.T) { + paths, err := selectCompatibleFeaturePaths(SharedFeatureSource(), "4.2.0") + require.NoError(t, err) + assert.NotEmpty(t, paths) +} diff --git a/e2e/acceptance/options.go b/e2e/cmd/acceptance/cli/options.go similarity index 68% rename from e2e/acceptance/options.go rename to e2e/cmd/acceptance/cli/options.go index ce67b1836..21366a28c 100644 --- a/e2e/acceptance/options.go +++ b/e2e/cmd/acceptance/cli/options.go @@ -1,4 +1,4 @@ -package acceptance +package cli import ( "context" @@ -8,6 +8,7 @@ import ( "os" "strings" + "nebius.ai/slurm-operator/e2e/acceptance" "nebius.ai/slurm-operator/e2e/acceptance/framework" ) @@ -16,6 +17,7 @@ const defaultSlurmClusterName = "soperator" type options struct { KubectlContext string SlurmClusterName string + SoperatorVersion string RunUnstableTests bool ScenarioPaths []string ReportDir string @@ -50,21 +52,26 @@ func Run(ctx context.Context, args []string) error { SlurmClusterName: opts.SlurmClusterName, WorkersByNodeSet: make(map[string][]framework.WorkerRef), } + targetSoperatorVersion, err := resolveTargetSoperatorVersion(ctx, opts) + if err != nil { + return err + } - features := SharedFeatureSource() + features := acceptance.SharedFeatureSource() if len(opts.ScenarioPaths) > 0 { features.Paths = opts.ScenarioPaths } - runner, err := NewRunner(Options{ + runner, err := acceptance.NewRunner(acceptance.Options{ KubectlContext: opts.KubectlContext, SlurmClusterName: opts.SlurmClusterName, + TargetSoperatorVersion: targetSoperatorVersion, ReportDir: opts.ReportDir, Features: features, ExcludeUnstable: !opts.RunUnstableTests, ExcludeMissingWorkerKinds: true, State: state, - StepRegistrars: []StepRegistrar{SharedStepRegistrar()}, + StepRegistrars: []acceptance.StepRegistrar{acceptance.SharedStepRegistrar()}, }) if err != nil { return err @@ -81,8 +88,9 @@ func parseOptions(args []string) (options, error) { fs.SetOutput(os.Stderr) fs.StringVar(&opts.KubectlContext, "kubectl-context", "", "kubectl context to use for acceptance tests") fs.StringVar(&opts.SlurmClusterName, "slurm-cluster-name", opts.SlurmClusterName, "SlurmCluster resource name") + fs.StringVar(&opts.SoperatorVersion, "soperator-version", "", "target Soperator version; when omitted, Flux HelmRelease discovery is used") fs.BoolVar(&opts.RunUnstableTests, "run-unstable", false, "run scenarios tagged @unstable") - fs.Var((*scenarioPathFlag)(&opts.ScenarioPaths), "scenario", "scenario path to run, e.g. features/internal_ssh.feature:2; may be repeated") + fs.Var((*scenarioPathFlag)(&opts.ScenarioPaths), "scenario", "feature file or exact Scenario line to run, e.g. features/internal_ssh.feature:3; may be repeated") fs.StringVar(&opts.ReportDir, "report-dir", "", "optional directory for Cucumber and JUnit reports") if err := fs.Parse(args); err != nil { @@ -97,7 +105,20 @@ func parseOptions(args []string) (options, error) { return options{}, fmt.Errorf("--kubectl-context is required") } opts.SlurmClusterName = strings.TrimSpace(opts.SlurmClusterName) + opts.SoperatorVersion = strings.TrimSpace(opts.SoperatorVersion) opts.ReportDir = strings.TrimSpace(opts.ReportDir) return opts, nil } + +func resolveTargetSoperatorVersion(ctx context.Context, opts options) (string, error) { + if opts.SoperatorVersion != "" { + return opts.SoperatorVersion, nil + } + + version, err := discoverFluxSoperatorVersion(ctx, opts.KubectlContext) + if err != nil { + return "", fmt.Errorf("discover target Soperator version from Flux HelmRelease: %w; pass --soperator-version to override", err) + } + return version, nil +} diff --git a/e2e/cmd/acceptance/cli/options_test.go b/e2e/cmd/acceptance/cli/options_test.go new file mode 100644 index 000000000..a7d41342c --- /dev/null +++ b/e2e/cmd/acceptance/cli/options_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseOptionsDefaults(t *testing.T) { + opts, err := parseOptions([]string{"--kubectl-context", "dev-context"}) + require.NoError(t, err) + + assert.Equal(t, "dev-context", opts.KubectlContext) + assert.Equal(t, "soperator", opts.SlurmClusterName) + assert.False(t, opts.RunUnstableTests) + assert.Empty(t, opts.SoperatorVersion) + assert.Empty(t, opts.ScenarioPaths) + assert.Empty(t, opts.ReportDir) +} + +func TestParseOptionsExplicitValues(t *testing.T) { + opts, err := parseOptions([]string{ + "--kubectl-context", "dev-context", + "--slurm-cluster-name", "custom", + "--soperator-version", "4.1.5-reb85d0e5", + "--run-unstable=true", + "--scenario", "features/internal_ssh.feature:3", + "--scenario=features/topology.feature:3", + "--report-dir", "reports", + }) + require.NoError(t, err) + + assert.Equal(t, "dev-context", opts.KubectlContext) + assert.Equal(t, "custom", opts.SlurmClusterName) + assert.Equal(t, "4.1.5-reb85d0e5", opts.SoperatorVersion) + assert.True(t, opts.RunUnstableTests) + assert.Equal(t, []string{"features/internal_ssh.feature:3", "features/topology.feature:3"}, opts.ScenarioPaths) + assert.Equal(t, "reports", opts.ReportDir) +} + +func TestParseOptionsRequiresKubectlContext(t *testing.T) { + _, err := parseOptions(nil) + require.Error(t, err) + assert.ErrorContains(t, err, "--kubectl-context is required") +} + +func TestParseOptionsRejectsExtraArgs(t *testing.T) { + _, err := parseOptions([]string{"--kubectl-context", "dev-context", "extra"}) + require.Error(t, err) + assert.ErrorContains(t, err, "unexpected acceptance arguments") +} + +func TestParseOptionsRejectsEmptyScenario(t *testing.T) { + _, err := parseOptions([]string{"--kubectl-context", "dev-context", "--scenario", " "}) + require.Error(t, err) + assert.ErrorContains(t, err, "--scenario value cannot be empty") +} diff --git a/e2e/cmd/acceptance/cli/version_discovery.go b/e2e/cmd/acceptance/cli/version_discovery.go new file mode 100644 index 000000000..4d3437f4e --- /dev/null +++ b/e2e/cmd/acceptance/cli/version_discovery.go @@ -0,0 +1,87 @@ +package cli + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "time" + + "nebius.ai/slurm-operator/e2e/acceptance/framework" +) + +const ( + soperatorHelmReleaseNamespace = "flux-system" + soperatorHelmReleaseName = "flux-system-soperator-fluxcd-soperator" + versionDiscoveryCommandTimeout = time.Minute +) + +// discoverFluxSoperatorVersion reads the deployed Soperator chart version from +// the standard Flux HelmRelease installed on Soperator clusters. +func discoverFluxSoperatorVersion(ctx context.Context, kubectlContext string) (string, error) { + version, err := kubectlGetSoperatorHelmReleaseRevision(ctx, kubectlContext) + if err != nil { + return "", fmt.Errorf("get Soperator HelmRelease revision: %w", err) + } + + return soperatorVersionFromHelmReleaseRevision(version) +} + +func kubectlGetSoperatorHelmReleaseRevision(ctx context.Context, kubectlContext string) (string, error) { + cmdCtx, cancel := context.WithTimeout(ctx, versionDiscoveryCommandTimeout) + defer cancel() + + args := appendKubectlContext( + kubectlContext, + "-n", soperatorHelmReleaseNamespace, + "get", "helmrelease", soperatorHelmReleaseName, + "-o", "jsonpath={.status.lastAttemptedRevision}", + ) + cmd := exec.CommandContext(cmdCtx, "kubectl", args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errOut := strings.TrimSpace(stderr.String()) + if errOut != "" { + return stdout.String(), fmt.Errorf("kubectl %s: %w: %s", strings.Join(args, " "), err, errOut) + } + return stdout.String(), fmt.Errorf("kubectl %s: %w", strings.Join(args, " "), err) + } + + return stdout.String(), nil +} + +func appendKubectlContext(kubectlContext string, args ...string) []string { + if strings.TrimSpace(kubectlContext) == "" { + return append([]string(nil), args...) + } + + out := make([]string, 0, len(args)+2) + out = append(out, "--context", kubectlContext) + out = append(out, args...) + return out +} + +func soperatorVersionFromHelmReleaseRevision(raw string) (string, error) { + version := strings.TrimSpace(raw) + if version == "" { + return "", fmt.Errorf("Flux HelmRelease %s/%s has empty status.lastAttemptedRevision", + soperatorHelmReleaseNamespace, + soperatorHelmReleaseName, + ) + } + if _, err := framework.NormalizeSoperatorVersion(version); err != nil { + return "", fmt.Errorf("Flux HelmRelease %s/%s has unsupported deployed version %q: %w", + soperatorHelmReleaseNamespace, + soperatorHelmReleaseName, + version, + err, + ) + } + + return version, nil +} diff --git a/e2e/cmd/acceptance/cli/version_discovery_test.go b/e2e/cmd/acceptance/cli/version_discovery_test.go new file mode 100644 index 000000000..8a6e1b40a --- /dev/null +++ b/e2e/cmd/acceptance/cli/version_discovery_test.go @@ -0,0 +1,26 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSoperatorVersionFromHelmReleaseRevision(t *testing.T) { + version, err := soperatorVersionFromHelmReleaseRevision(" 4.1.5-reb85d0e5\n") + require.NoError(t, err) + assert.Equal(t, "4.1.5-reb85d0e5", version) +} + +func TestSoperatorVersionFromHelmReleaseRevisionRequiresValue(t *testing.T) { + _, err := soperatorVersionFromHelmReleaseRevision(" ") + require.Error(t, err) + assert.ErrorContains(t, err, "status.lastAttemptedRevision") +} + +func TestSoperatorVersionFromHelmReleaseRevisionRejectsInvalidVersion(t *testing.T) { + _, err := soperatorVersionFromHelmReleaseRevision("4.2") + require.Error(t, err) + assert.ErrorContains(t, err, "unsupported deployed version") +} diff --git a/e2e/cmd/acceptance/main.go b/e2e/cmd/acceptance/main.go index a37cd454f..fe32cc50f 100644 --- a/e2e/cmd/acceptance/main.go +++ b/e2e/cmd/acceptance/main.go @@ -7,7 +7,7 @@ import ( "os/signal" "syscall" - "nebius.ai/slurm-operator/e2e/acceptance" + "nebius.ai/slurm-operator/e2e/cmd/acceptance/cli" ) func main() { @@ -16,7 +16,7 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - if err := acceptance.Run(ctx, os.Args[1:]); err != nil { + if err := cli.Run(ctx, os.Args[1:]); err != nil { log.Fatalf("acceptance: %v", err) } } diff --git a/e2e/versionfilter/versionfilter.go b/e2e/versionfilter/versionfilter.go new file mode 100644 index 000000000..c8542496a --- /dev/null +++ b/e2e/versionfilter/versionfilter.go @@ -0,0 +1,398 @@ +package versionfilter + +import ( + "bytes" + "fmt" + "io/fs" + "regexp" + "strconv" + "strings" + + semver "github.com/Masterminds/semver/v3" + gherkin "github.com/cucumber/gherkin/go/v26" + messages "github.com/cucumber/messages/go/v21" +) + +var ( + baseVersionPattern = regexp.MustCompile(`^v?([0-9]+\.[0-9]+\.[0-9]+)(?:[-+].*)?$`) + constraintPattern = regexp.MustCompile(`^(>=|<=|>|<|=)?(v?[0-9]+\.[0-9]+\.[0-9]+)$`) +) + +// FeatureSource describes feature files to filter before Godog runs them. +type FeatureSource struct { + FS fs.FS + Paths []string +} + +// Axis describes one required version tag family and the target version used to +// evaluate it. For example: @soperator_version_ or @another_soperator_version_. +type Axis struct { + TagPrefix string + TargetVersion string +} + +type preparedAxis struct { + tagPrefix string + target *semver.Version +} + +type featureLocation struct { + path string + line int +} + +type featureScenario struct { + featurePath string + name string + tags []string + line int +} + +// NormalizeVersion returns the major.minor.patch base version used by filters. +func NormalizeVersion(raw string) (string, error) { + version, err := ParseBaseVersion(raw) + if err != nil { + return "", err + } + return version.String(), nil +} + +// ParseBaseVersion accepts full major.minor.patch versions and strips suffixes +// from target versions such as 4.2.0-reb85d0e5. +func ParseBaseVersion(raw string) (*semver.Version, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, fmt.Errorf("version is required") + } + + matches := baseVersionPattern.FindStringSubmatch(trimmed) + if matches == nil { + return nil, fmt.Errorf("version %q must use full major.minor.patch form", raw) + } + + version, err := semver.StrictNewVersion(matches[1]) + if err != nil { + return nil, fmt.Errorf("parse version %q: %w", raw, err) + } + return version, nil +} + +// SelectScenarios returns Godog paths for scenarios compatible with all axes. +// Every provided axis is required: each scenario must have exactly one matching +// tag prefix for every axis. +func SelectScenarios(source FeatureSource, axes ...Axis) ([]string, error) { + if source.FS == nil { + return nil, fmt.Errorf("feature source FS is required") + } + + preparedAxes, err := prepareAxes(axes) + if err != nil { + return nil, err + } + + parsedByPath := make(map[string][]featureScenario) + seen := make(map[string]struct{}) + var selected []string + + for _, rawPath := range source.Paths { + location, err := parseFeatureLocation(rawPath) + if err != nil { + return nil, err + } + + scenarios, ok := parsedByPath[location.path] + if !ok { + scenarios, err = parseFeatureScenarios(source.FS, location.path) + if err != nil { + return nil, err + } + if len(scenarios) == 0 { + return nil, fmt.Errorf("feature %s does not contain scenarios", location.path) + } + if err := validateVersionTags(scenarios, preparedAxes); err != nil { + return nil, err + } + parsedByPath[location.path] = scenarios + } + + candidates := scenarios + if location.line > 0 { + candidate, ok := findScenarioStartingAtLine(scenarios, location.line) + if !ok { + return nil, fmt.Errorf("feature %s has no scenario starting at line %d; line-based selection must point to the Scenario line", + location.path, + location.line, + ) + } + candidates = []featureScenario{candidate} + } + + for _, scenario := range candidates { + compatible, err := scenarioCompatibleWithAxes(scenario, preparedAxes) + if err != nil { + return nil, err + } + if !compatible { + continue + } + + selectedPath := fmt.Sprintf("%s:%d", scenario.featurePath, scenario.line) + if _, ok := seen[selectedPath]; ok { + continue + } + seen[selectedPath] = struct{}{} + selected = append(selected, selectedPath) + } + } + + return selected, nil +} + +func prepareAxes(axes []Axis) ([]preparedAxis, error) { + if len(axes) == 0 { + return nil, fmt.Errorf("at least one version axis is required") + } + + prepared := make([]preparedAxis, 0, len(axes)) + seen := make(map[string]struct{}, len(axes)) + for _, axis := range axes { + tagPrefix := strings.TrimSpace(axis.TagPrefix) + if tagPrefix == "" { + return nil, fmt.Errorf("version tag prefix is required") + } + if !strings.HasPrefix(tagPrefix, "@") { + return nil, fmt.Errorf("version tag prefix %q must start with @", tagPrefix) + } + if _, ok := seen[tagPrefix]; ok { + return nil, fmt.Errorf("duplicate version tag prefix %q", tagPrefix) + } + seen[tagPrefix] = struct{}{} + + target, err := ParseBaseVersion(axis.TargetVersion) + if err != nil { + return nil, fmt.Errorf("target version for %s: %w", tagPrefix, err) + } + + prepared = append(prepared, preparedAxis{ + tagPrefix: tagPrefix, + target: target, + }) + } + + return prepared, nil +} + +func parseFeatureLocation(rawPath string) (featureLocation, error) { + trimmed := strings.TrimSpace(rawPath) + if trimmed == "" { + return featureLocation{}, fmt.Errorf("feature path cannot be empty") + } + + colon := strings.LastIndex(trimmed, ":") + if colon == -1 { + return featureLocation{path: trimmed}, nil + } + + lineRaw := strings.TrimSpace(trimmed[colon+1:]) + line, err := strconv.Atoi(lineRaw) + if err != nil || line <= 0 { + return featureLocation{}, fmt.Errorf("feature path %q has invalid line %q", rawPath, lineRaw) + } + path := strings.TrimSpace(trimmed[:colon]) + if path == "" { + return featureLocation{}, fmt.Errorf("feature path cannot be empty") + } + return featureLocation{path: path, line: line}, nil +} + +func parseFeatureScenarios(source fs.FS, path string) ([]featureScenario, error) { + content, err := fs.ReadFile(source, path) + if err != nil { + return nil, fmt.Errorf("read feature %s: %w", path, err) + } + + document, err := gherkin.ParseGherkinDocument(bytes.NewReader(content), (&messages.Incrementing{}).NewId) + if err != nil { + return nil, fmt.Errorf("parse feature %s: %w", path, err) + } + if document.Feature == nil { + return nil, nil + } + + var scenarios []featureScenario + for _, child := range document.Feature.Children { + switch { + case child.Scenario != nil: + scenario, err := newFeatureScenario(path, child.Scenario) + if err != nil { + return nil, err + } + scenarios = append(scenarios, scenario) + case child.Rule != nil: + for _, ruleChild := range child.Rule.Children { + if ruleChild.Scenario == nil { + continue + } + scenario, err := newFeatureScenario(path, ruleChild.Scenario) + if err != nil { + return nil, err + } + scenarios = append(scenarios, scenario) + } + } + } + + return scenarios, nil +} + +func newFeatureScenario(path string, scenario *messages.Scenario) (featureScenario, error) { + line, ok := lineNumber(scenario.Location) + if !ok { + return featureScenario{}, fmt.Errorf("scenario %q in %s has no location", scenario.Name, path) + } + + return featureScenario{ + featurePath: path, + name: scenario.Name, + tags: tagNames(scenario.Tags), + line: line, + }, nil +} + +func tagNames(tags []*messages.Tag) []string { + names := make([]string, 0, len(tags)) + for _, tag := range tags { + names = append(names, tag.Name) + } + return names +} + +func lineNumber(location *messages.Location) (int, bool) { + if location == nil || location.Line <= 0 { + return 0, false + } + return int(location.Line), true +} + +func validateVersionTags(scenarios []featureScenario, axes []preparedAxis) error { + for _, scenario := range scenarios { + for _, axis := range axes { + versionTags := scenarioVersionTags(scenario, axis.tagPrefix) + if len(versionTags) != 1 { + return fmt.Errorf("scenario %q at %s:%d must have exactly one %s tag, got %d", + scenario.name, + scenario.featurePath, + scenario.line, + axis.tagPrefix, + len(versionTags), + ) + } + if err := validateVersionConstraintExpression(strings.TrimPrefix(versionTags[0], axis.tagPrefix)); err != nil { + return fmt.Errorf("scenario %q at %s:%d has invalid version tag %s: %w", + scenario.name, + scenario.featurePath, + scenario.line, + versionTags[0], + err, + ) + } + } + } + return nil +} + +func findScenarioStartingAtLine(scenarios []featureScenario, line int) (featureScenario, bool) { + for _, scenario := range scenarios { + if scenario.line == line { + return scenario, true + } + } + return featureScenario{}, false +} + +func scenarioCompatibleWithAxes(scenario featureScenario, axes []preparedAxis) (bool, error) { + for _, axis := range axes { + versionTags := scenarioVersionTags(scenario, axis.tagPrefix) + if len(versionTags) != 1 { + return false, fmt.Errorf("scenario %q at %s:%d must have exactly one %s tag, got %d", + scenario.name, + scenario.featurePath, + scenario.line, + axis.tagPrefix, + len(versionTags), + ) + } + + expression := strings.TrimPrefix(versionTags[0], axis.tagPrefix) + compatible, err := versionConstraintMatches(expression, axis.target) + if err != nil { + return false, fmt.Errorf("scenario %q at %s:%d has invalid version tag %s: %w", + scenario.name, + scenario.featurePath, + scenario.line, + versionTags[0], + err, + ) + } + if !compatible { + return false, nil + } + } + return true, nil +} + +func scenarioVersionTags(scenario featureScenario, tagPrefix string) []string { + var versionTags []string + for _, tag := range scenario.tags { + if strings.HasPrefix(tag, tagPrefix) { + versionTags = append(versionTags, tag) + } + } + return versionTags +} + +func versionConstraintMatches(expression string, target *semver.Version) (bool, error) { + if err := validateVersionConstraintExpression(expression); err != nil { + return false, err + } + constraint, err := semver.NewConstraint(semverConstraintExpression(expression)) + if err != nil { + return false, err + } + return constraint.Check(target), nil +} + +func validateVersionConstraintExpression(expression string) error { + orGroups := strings.Split(expression, "||") + for _, group := range orGroups { + group = strings.TrimSpace(group) + if group == "" { + return fmt.Errorf("empty version constraint group") + } + + for _, rawConstraint := range strings.Split(group, ",") { + constraint := strings.TrimSpace(rawConstraint) + if constraint == "" { + return fmt.Errorf("empty version constraint") + } + + matches := constraintPattern.FindStringSubmatch(constraint) + if matches == nil { + return fmt.Errorf("constraint %q must use an optional operator and full major.minor.patch version", constraint) + } + } + } + return nil +} + +func semverConstraintExpression(expression string) string { + groups := strings.Split(expression, "||") + for i, group := range groups { + constraints := strings.Split(group, ",") + for j, constraint := range constraints { + constraints[j] = strings.TrimSpace(constraint) + } + groups[i] = strings.Join(constraints, " ") + } + return strings.Join(groups, " || ") +} diff --git a/e2e/versionfilter/versionfilter_test.go b/e2e/versionfilter/versionfilter_test.go new file mode 100644 index 000000000..a74286def --- /dev/null +++ b/e2e/versionfilter/versionfilter_test.go @@ -0,0 +1,226 @@ +package versionfilter + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + soperatorVersionTag = "@soperator_version_" + anotherSoperatorVersionTag = "@another_soperator_version_" +) + +func TestSelectScenariosFiltersByTargetVersion(t *testing.T) { + features := FeatureSource{ + FS: fstest.MapFS{ + "features/sample.feature": {Data: []byte(`Feature: Sample + @soperator_version_>=4.0.0 + Scenario: old scenario + Then old behavior works + + @soperator_version_>=4.2.0 + Scenario: new scenario + Then new behavior works + + @soperator_version_>=4.0.0,<4.1.0||>=4.2.0 + Scenario: split range scenario + Then split range behavior works +`)}, + }, + Paths: []string{"features/sample.feature"}, + } + + paths, err := SelectScenarios(features, Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.1.5-reb85d0e5"}) + require.NoError(t, err) + assert.Equal(t, []string{"features/sample.feature:3"}, paths) + + paths, err = SelectScenarios(features, Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.2.0"}) + require.NoError(t, err) + assert.Equal(t, []string{ + "features/sample.feature:3", + "features/sample.feature:7", + "features/sample.feature:11", + }, paths) +} + +func TestSelectScenariosAcceptsExactScenarioLine(t *testing.T) { + features := FeatureSource{ + FS: fstest.MapFS{ + "features/sample.feature": {Data: []byte(`Feature: Sample + @soperator_version_>=4.0.0 + Scenario: old scenario + Then old behavior works +`)}, + }, + Paths: []string{"features/sample.feature:3"}, + } + + paths, err := SelectScenarios(features, Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.0.0"}) + require.NoError(t, err) + assert.Equal(t, []string{"features/sample.feature:3"}, paths) +} + +func TestSelectScenariosRejectsTagOrBodyLine(t *testing.T) { + features := FeatureSource{ + FS: fstest.MapFS{ + "features/sample.feature": {Data: []byte(`Feature: Sample + @soperator_version_>=4.0.0 + Scenario: old scenario + Then old behavior works +`)}, + }, + Paths: []string{"features/sample.feature:2"}, + } + + _, err := SelectScenarios(features, Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.0.0"}) + require.Error(t, err) + assert.ErrorContains(t, err, "line-based selection must point to the Scenario line") + + features.Paths = []string{"features/sample.feature:4"} + _, err = SelectScenarios(features, Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.0.0"}) + require.Error(t, err) + assert.ErrorContains(t, err, "line-based selection must point to the Scenario line") +} + +func TestSelectScenariosRequiresScenarioLevelVersionTag(t *testing.T) { + features := FeatureSource{ + FS: fstest.MapFS{ + "features/sample.feature": {Data: []byte(`@soperator_version_>=4.0.0 +Feature: Sample + Scenario: old scenario + Then old behavior works +`)}, + }, + Paths: []string{"features/sample.feature"}, + } + + _, err := SelectScenarios(features, Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.0.0"}) + require.Error(t, err) + assert.ErrorContains(t, err, "must have exactly one @soperator_version_") +} + +func TestSelectScenariosRequiresAllAxes(t *testing.T) { + features := FeatureSource{ + FS: fstest.MapFS{ + "features/another-soperator.feature": {Data: []byte(`Feature: Another Soperator + @soperator_version_>=4.2.0 @another_soperator_version_>=1.0.0 + Scenario: compatible scenario + Then another Soperator behavior works + + @soperator_version_>=4.2.0 @another_soperator_version_>=2.0.0 + Scenario: future another Soperator scenario + Then future another Soperator behavior works +`)}, + }, + Paths: []string{"features/another-soperator.feature"}, + } + + paths, err := SelectScenarios( + features, + Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.2.0"}, + Axis{TagPrefix: anotherSoperatorVersionTag, TargetVersion: "1.5.0"}, + ) + require.NoError(t, err) + assert.Equal(t, []string{"features/another-soperator.feature:3"}, paths) +} + +func TestSelectScenariosRejectsMissingAxisTag(t *testing.T) { + features := FeatureSource{ + FS: fstest.MapFS{ + "features/another-soperator.feature": {Data: []byte(`Feature: Another Soperator + @soperator_version_>=4.2.0 + Scenario: missing another Soperator version + Then another Soperator behavior works +`)}, + }, + Paths: []string{"features/another-soperator.feature"}, + } + + _, err := SelectScenarios( + features, + Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.2.0"}, + Axis{TagPrefix: anotherSoperatorVersionTag, TargetVersion: "1.0.0"}, + ) + require.Error(t, err) + assert.ErrorContains(t, err, "must have exactly one @another_soperator_version_") +} + +func TestSelectScenariosRejectsMissingVersionTag(t *testing.T) { + features := FeatureSource{ + FS: fstest.MapFS{ + "features/sample.feature": {Data: []byte(`Feature: Sample + Scenario: untagged scenario + Then behavior works +`)}, + }, + Paths: []string{"features/sample.feature"}, + } + + _, err := SelectScenarios(features, Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.2.0"}) + require.Error(t, err) + assert.ErrorContains(t, err, "must have exactly one @soperator_version_") +} + +func TestSelectScenariosRejectsDuplicateVersionTags(t *testing.T) { + features := FeatureSource{ + FS: fstest.MapFS{ + "features/sample.feature": {Data: []byte(`Feature: Sample + @soperator_version_>=4.0.0 @soperator_version_<5.0.0 + Scenario: duplicated version tags + Then behavior works +`)}, + }, + Paths: []string{"features/sample.feature"}, + } + + _, err := SelectScenarios(features, Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.2.0"}) + require.Error(t, err) + assert.ErrorContains(t, err, "got 2") +} + +func TestSelectScenariosRejectsShortVersionConstraint(t *testing.T) { + features := FeatureSource{ + FS: fstest.MapFS{ + "features/sample.feature": {Data: []byte(`Feature: Sample + @soperator_version_>=4.2 + Scenario: short version + Then behavior works +`)}, + }, + Paths: []string{"features/sample.feature"}, + } + + _, err := SelectScenarios(features, Axis{TagPrefix: soperatorVersionTag, TargetVersion: "4.2.0"}) + require.Error(t, err) + assert.ErrorContains(t, err, "full major.minor.patch") +} + +func TestSelectScenariosRejectsMissingAxes(t *testing.T) { + features := FeatureSource{ + FS: fstest.MapFS{ + "features/sample.feature": {Data: []byte(`Feature: Sample + @soperator_version_>=4.2.0 + Scenario: tagged + Then behavior works +`)}, + }, + Paths: []string{"features/sample.feature"}, + } + + _, err := SelectScenarios(features) + require.Error(t, err) + assert.ErrorContains(t, err, "at least one version axis is required") +} + +func TestNormalizeVersion(t *testing.T) { + version, err := NormalizeVersion("4.1.5-reb85d0e5") + require.NoError(t, err) + assert.Equal(t, "4.1.5", version) + + version, err = NormalizeVersion("v4.2.0+build.1") + require.NoError(t, err) + assert.Equal(t, "4.2.0", version) +} diff --git a/go.mod b/go.mod index bafc85ac9..775808b70 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,10 @@ go 1.26.4 require ( github.com/SlinkyProject/slurm-client v1.2.1 + github.com/Masterminds/semver/v3 v3.4.0 + github.com/cucumber/gherkin/go/v26 v26.2.0 github.com/cucumber/godog v0.15.1 + github.com/cucumber/messages/go/v21 v21.0.1 github.com/go-logr/logr v1.4.4 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/hashicorp/go-retryablehttp v0.7.8 @@ -39,7 +42,6 @@ require ( require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 // indirect cel.dev/expr v0.25.1 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect @@ -49,8 +51,6 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cert-manager/cert-manager v1.18.5 // indirect github.com/containers/common v0.60.4 // indirect - github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect - github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect diff --git a/internal/e2e/acceptance.go b/internal/e2e/acceptance.go index 059a8c786..ac76d6734 100644 --- a/internal/e2e/acceptance.go +++ b/internal/e2e/acceptance.go @@ -37,6 +37,7 @@ func acceptanceOptionsForConfig(cfg Config, kubectlContext string) acceptance.Op return acceptance.Options{ KubectlContext: kubectlContext, SlurmClusterName: cfg.SlurmClusterName, + TargetSoperatorVersion: cfg.SoperatorVersion, ReportDir: defaultAcceptanceReportDir, Features: acceptance.SharedFeatureSource(), ExcludeUnstable: !cfg.RunUnstableTests,